> ## Documentation Index
> Fetch the complete documentation index at: https://docs.adcontextprotocol.org/llms.txt
> Use this file to discover all available pages before exploring further.

# refine_proposals

> Revise proposals, finalize drafts into inventory holds, or extend a committed hold.

`refine_proposals` has two explicit operations over immutable proposal snapshots:

* `action: "revise"` applies structured discovery `criteria`, typed boundaries, product changes, requested alternatives, and/or the buyer's free-text `ask`, returning one or more new draft proposals.
* `action: "finalize"` changes no commercial terms and returns a new committed proposal backed by an inventory hold until `expires_at`.
* `action: "extend_hold"` (experimental) changes no commercial terms and returns a new committed proposal with a later `expires_at`; the hold transfers to it. See [Extend a hold](#extend-a-hold-experimental).

For an end-to-end buyer and seller implementation workflow, see [Proposal negotiation](/dist/docs/3.3.0-beta.0/media-buy/product-discovery/proposal-negotiation).

Revision may target a draft, committed, or accepted proposal; finalization must target a draft. A request contains at most 25 refinements. Proposal IDs must be unique within the request, and results preserve request order. Every successful operation returns a new `proposal_id`; that ID is the complete version identity for the immutable commercial snapshot, and the source remains unchanged.

**Request schema:** [`/schemas/3.2.1/media-buy/refine-proposals-request.json`](https://adcontextprotocol.org/schemas/3.2.1/media-buy/refine-proposals-request.json)

```json theme={null}
{
  "idempotency_key": "550e8400-e29b-41d4-a716-446655441002",
  "refinements": [
    {
      "proposal_id": "proposal_123",
      "action": "revise",
      "constraints": {
        "total_budget": { "max": 50000, "currency": "USD" },
        "cpm": { "max": 18, "currency": "USD" },
        "flight": { "end_no_earlier_than": "2026-10-31T23:59:59Z" }
      },
      "product_changes": {
        "premium_video": "include",
        "display_ros": "omit"
      },
      "alternatives": { "count": 3 },
      "ask": "Use materially different product mixes while preserving reach."
    },
    {
      "proposal_id": "proposal_456",
      "action": "revise",
      "criteria": {
        "targeting_overlay": {
          "geo_countries": ["US", "CA"]
        }
      }
    }
  ]
}
```

Typed fields are deliberately small and mechanically verifiable:

| Field | Meaning |
| - | - |
| `constraints.total_budget` | Inclusive `min`/`max` bounds checked against a present `commercial_terms.total_budget`; `currency` must match and `min` must not exceed `max`. Each `constraints` key is its own stable constraint identifier. |
| `constraints.cpm` | Hard rate ceiling. Satisfied only when every purchase carries fixed `cpm`/`vcpm` pricing in the requested `currency` with `fixed_price` at or below `max`; auction-priced or non-CPM purchases leave it unsatisfied. |
| `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. |
| `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`. |
| `ask` | Free text for substitution, trade-offs, diversity, preferences, or other long-tail context. |

`vary` and typed preference enums are intentionally absent: concepts such as “prioritize reach” cannot be verified consistently against `commercial_terms`. A seller may interpret them from `ask`, but cannot report them as deterministically satisfied.

Each present `criteria` field replaces that field from the source proposal; omitted fields remain bound to the revision. This makes a targeting change deterministic and ensures the revised proposal's price and forecast use the new effective overlay. When hard targeting appears only in `ask`, the seller confirms any structured interpretation that materially affects the revision in that result's `targeting_resolution.brief_targeting`. Exact structured targeting is not repeated; product-specific alternatives remain sparse modifications on the returned configured products.

**Response schema:** [`/schemas/3.2.1/media-buy/refine-proposals-response.json`](https://adcontextprotocol.org/schemas/3.2.1/media-buy/refine-proposals-response.json)

For `revise`, the seller reevaluates returned products against the revision's effective criteria. When those criteria contain property or collection lists, each affected canonical product carries fresh `list_applications` receipts with new evaluation timestamps and counts. `finalize` changes no commercial terms and does not trigger product reevaluation; a finalize-only response MAY repeat the receipts already bound to the source proposal.

When the request sets `include_collections: true` (experimental, `media_buy.collection_cards`, default `false`), the response can carry, alongside `products`, a top-level `collections` array with the full object for each collection those products reference. Sellers MUST NOT return it otherwise. It follows the same rules as on [`request_proposals`](/dist/docs/3.3.0-beta.0/media-buy/task-reference/request_proposals); see [Collections in responses](/dist/docs/3.3.0-beta.0/media-buy/product-discovery/collections-and-installments#collections-in-responses).

The response contains one ordered `results[]` entry per requested source proposal. Each entry echoes `source_proposal_id`; buyers cross-check by that echo rather than by position alone. Every returned proposal carries `parent_proposal_id` equal to the entry's `source_proposal_id`, so negotiation lineage survives in the proposals themselves:

| Outcome | Proposal field | Meaning |
| - | - | - |
| `revised` | `proposals[]` | Every hard constraint is satisfied. Without `alternatives`, the array has one draft; with `alternatives.count`, it has exactly that many and every `terms_digest` is unique. |
| `partial` | `proposals[]` | Honest drafts were produced, but a hard constraint, typed change, requested count, or free-text ask was not fully applied. `reason_code` and `reason` are required. |
| `finalized` | `proposal` | One committed proposal backed by an inventory hold. Also reports a successful `extend_hold`: the committed successor of the extended source. |
| `unable` | none | No honest draft was produced. `reason_code` and `reason` are required. |

`reason_code` distinguishes commercial refusal from implementation limits:

| 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. |
| `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. |
| `source_unavailable` | The source proposal was unavailable or ineligible for this caller. |
| `hold_unavailable` | The seller declined or failed to reserve inventory for a finalize, or declined to extend the hold on an `extend_hold`. |
| `batch_aborted` | This entry failed only because a sibling in the all-or-none finalize or `extend_hold` batch failed. |
| `definition_changed` | A third-party-published definition pinned in the source proposal no longer matches its pin. Valid only on `unable`, with required `drifted_pins[]`. |

Typed failures always use `constraint_unsatisfiable`, regardless of whether the refusal is capability or commercial; won't-versus-can't nuance belongs in `reason` or a `commercially_declined` response to an `ask`. When multiple shortfalls co-occur, `constraint_unsatisfiable` takes precedence over every other code, and an alternatives shortfall remains visible through `proposals.length`.

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 both.

A `partial` response to the request above looks like:

```json theme={null}
{
  "adcp_version": "3.2.0",
  "status": "completed",
  "results": [
    {
      "source_proposal_id": "proposal_123",
      "outcome": "partial",
      "reason_code": "alternatives_unavailable",
      "reason": "Two commercially distinct packages are available at these terms.",
      "proposals": [
        { "proposal_id": "proposal_9a1", "parent_proposal_id": "proposal_123", "proposal_status": "draft", "...": "..." },
        { "proposal_id": "proposal_9a2", "parent_proposal_id": "proposal_123", "proposal_status": "draft", "...": "..." }
      ]
    },
    {
      "source_proposal_id": "proposal_456",
      "outcome": "revised",
      "proposals": [
        { "proposal_id": "proposal_9b7", "parent_proposal_id": "proposal_456", "proposal_status": "draft", "...": "..." }
      ]
    }
  ],
  "products": ["..."]
}
```

Every returned proposal carries typed `commercial_terms` and a `terms_digest` — the base64url SHA-256 of the [RFC 8785 JCS](https://www.rfc-editor.org/rfc/rfc8785) serialization of `commercial_terms`, prefixed with `sha256:`. Buyers can and should recompute it: digest agreement, not seller assertion, is what makes drafts comparable and alternatives verifiably distinct. Only `commercial_terms` is contractual. Narrative fields — `reason`, `suggestions`, `description`, and anything a buyer wrote in `ask` — are explanatory and never part of the deal a later [`accept_proposal`](/dist/docs/3.3.0-beta.0/media-buy/task-reference/accept_proposal) binds.

## A realistic negotiation

Sam is buying a \$50,000 Acme Outdoor campaign from StreamHaus. Capability discovery says StreamHaus supports `total_budget`, `product_changes`, `alternatives`, and `criteria`, with `max_alternatives: 3`. Sam can therefore construct one mechanically checkable request: cap spend at USD 50,000, include premium video, omit run-of-site display, narrow geography to the US and Canada, and ask for three distinct mixes.

Suppose StreamHaus can satisfy the budget and targeting but can produce only two commercially distinct drafts. It returns `partial` with two proposals, `reason_code: "alternatives_unavailable"`, and unique `terms_digest` values. Sam does not need to parse sales prose to understand the counteroffer: the typed budget and product checks still pass, while the requested alternative count does not. Sam can compare the two drafts, revise one again, or finalize the preferred draft.

The failure paths are equally deterministic:

* If a returned draft omits `commercial_terms.total_budget`, uses another currency, or exceeds the cap, the result identifies `total_budget` as unsatisfied and cannot be `revised`.
* If Sam requests four alternatives against the advertised ceiling of three, the whole call fails with [`VALIDATION_ERROR`](/dist/docs/3.3.0-beta.0/building/verification/compliance-catalog#error-code-validation-error) at `refinements[0].alternatives.count`; StreamHaus creates no draft.
* If StreamHaus omits `criteria` from `supported_dimensions`, the whole call fails with [`UNSUPPORTED_FEATURE`](/dist/docs/3.3.0-beta.0/building/verification/compliance-catalog#error-code-unsupported-feature) before mutation. Sam can remove or translate that field and retry with a new valid payload.

This separation is the value of the compact negotiation surface: capabilities prevent impossible asks, typed fields make compliance testable, and `ask` remains available for the commercial nuance that cannot be reduced to a reliable boolean.

Pass only a finalized committed proposal to [`accept_proposal`](/dist/docs/3.3.0-beta.0/media-buy/task-reference/accept_proposal), or use [`decline_proposals`](/dist/docs/3.3.0-beta.0/media-buy/task-reference/decline_proposals) when the buyer stops pursuing it.

## Relationship to legacy refinement

The deprecated [`get_products`](/dist/docs/3.3.0-beta.0/media-buy/task-reference/get_products) `buying_mode: "refine"` surface is the 3.x compatibility façade; it has no lossless representation of `refinements[].constraints.total_budget`. A compatibility adapter MUST NOT map that proposal-term constraint to `get_products.filters.budget_range`, because the latter filters product candidates and does not guarantee the returned proposal's aggregate `commercial_terms.total_budget`. The adapter MUST instead return a typed `PRE_MUTATION_UNSUPPORTED` result with an RFC 6901 path identifying the exact field before it sends a legacy request.

Initial proposal discovery is different: `request_proposals.criteria.offer_filters.budget_range` MAY map to `get_products.filters.budget_range` with exact amounts, exact currency, inclusive bounds, no currency conversion, and no proposal-level guarantee. Compact revision criteria use whole-field replacement, while legacy filters are absolute. Legacy finalize is compatible only when the adapter can guarantee unchanged complete commercial terms. See [Legacy budget compatibility](/dist/docs/3.3.0-beta.0/media-buy/product-discovery/proposal-negotiation#legacy-budget-compatibility-normative) for the complete normative mapping, typed rejection shape, and lifecycle rules.

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`.

## 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`:

```json theme={null}
{
  "idempotency_key": "550e8400-e29b-41d4-a716-446655441003",
  "refinements": [
    {
      "proposal_id": "proposal_draft_789",
      "action": "finalize"
    }
  ]
}
```

For a committed proposal, `expires_at` is the inventory-hold deadline—not a
quote-freshness hint and not a channel-specific booking horizon. Until that
instant, the committed proposal remains executable under
[`accept_proposal`](/dist/docs/3.3.0-beta.0/media-buy/task-reference/accept_proposal). After it,
the hold lapses and the buyer must obtain and finalize a new proposal. Under
`terms_only` semantics (below) there is no hold, and `expires_at` is the
acceptance window: the instant after which the committed terms can no longer be
accepted.

**The hold write is atomic** (`inventory_hold` semantics). For each finalized proposal, the seller MUST
persist the committed successor and reserve the inventory it represents as one
state transition. For a [ranked hold](#hold-ranking-experimental), what is reserved is
the hold's position in the seller's queue. A committed proposal MUST NOT become observable without its
corresponding reservation, and a failed finalization MUST NOT leave an orphaned
reservation. If the seller cannot complete that transition synchronously, it
uses the task's `submitted` path and exposes the committed proposal only after
the reservation has succeeded.

A request containing `finalize` must contain only finalize entries. Sellers validate the complete batch before creating any holds; multi-proposal finalization is atomic. When any hold cannot be created, no result is `finalized`: the failing entry returns `unable` with `reason_code: "hold_unavailable"`, and otherwise-eligible siblings return `unable` with `reason_code: "batch_aborted"`. Exact retries use the same idempotency key.

Not every proposal-capable seller holds inventory. A seller declaring `hold_semantics: "terms_only"` under `media_buy.proposal_refinement` in [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.0/protocol/get_adcp_capabilities) commits **commercial terms only** at finalize: the committed proposal is returned with the same atomic all-or-none batch semantics, but no inventory hold is placed. Reservation is owned by the requesting side — the shape used in delegated composition, where the requester owns the sellable catalog and performs its own availability control at acceptance. Absent the declaration, `inventory_hold` semantics (everything above) apply. `hold_semantics` is the default for products and drafts that do not declare a [hold policy](#hold-policy-experimental); a draft whose `hold_policy` says otherwise follows its policy. The rules in the rest of this paragraph therefore apply per entry, to an entry whose effective policy is terms-only. For such an entry the seller MUST NOT return `hold_unavailable`. It returns `batch_aborted` when a sibling entry failed for a non-hold reason or, in a batch that also contains holdable entries, when a holdable sibling returned `hold_unavailable`. Because `committed` meant "inventory reserved" before this field existed, the seller MUST NOT return a `committed` proposal whose effective policy is terms-only to a caller whose `adcp_version` is below 3.3; it fails the request at task level with [`UNSUPPORTED_FEATURE`](/dist/docs/3.3.0-beta.0/building/verification/compliance-catalog#error-code-unsupported-feature). Under `inventory_hold`, a new key does not extend or recreate an existing hold: while a committed successor's hold is unexpired, finalizing the same draft again MUST fail at task level with [`INVALID_STATE`](/dist/docs/3.3.0-beta.0/building/verification/compliance-catalog#error-code-invalid-state) rather than minting a second hold; after expiry a new finalize MAY create a new one. To lengthen an unexpired hold, use [`extend_hold`](#extend-a-hold-experimental). For a terms-only entry, the same rule applies to an unexpired committed successor and its acceptance window.

**Holds that wait on a person (added in 3.3).** A seller whose hold depends on someone's agreement, such as a creator, host, or talent, MAY return `submitted` for a finalize-only request, or for an [`extend_hold`](#extend-a-hold-experimental)-only request when the extension depends on that agreement, and SHOULD set `expected_confirmation_at` on that response. It estimates when the seller expects to resolve the finalize or extension. Buyers schedule a status check for that time instead of polling tightly. It carries the [same semantics](/dist/docs/3.3.0-beta.0/media-buy/task-reference/create_media_buy#expected-confirmation-time) as on [`create_media_buy`](/dist/docs/3.3.0-beta.0/media-buy/task-reference/create_media_buy): an estimate, not a commitment, and not the hold's `expires_at`, which exists only on the committed proposal once the hold is placed, or on the committed successor once it is extended. Sellers SHOULD NOT send it on a revise request; the response cannot see the request, so that is a seller obligation rather than a schema rule. A `completed` response MUST NOT carry it.

**Definition pins are verified before the hold (or, under `terms_only`, before terms are committed).** Finalization re-resolves each entry in the source proposal's `commercial_terms.definition_pins[]` (see [Definition pins](/dist/docs/3.3.0-beta.0/media-buy/specification#definition-pins)) and, if any published definition no longer matches, takes no hold under `inventory_hold` semantics and commits no terms under `terms_only` semantics. The entry returns `unable` with `reason_code: "definition_changed"` and a required `drifted_pins[]`; each item carries the `pin` exactly as the draft carried it and the `current_content_digest` the definition resolves to now, omitted only when the entry was withdrawn from a successfully fetched, validated document. The seller verifies every entry's pins before creating any hold. A pin failure is an entry failure: that entry's `definition_changed` takes precedence over `batch_aborted`, and otherwise-eligible siblings in the finalize batch return `batch_aborted`. A source document that cannot be fetched or parsed is not drift; the seller returns [`SERVICE_UNAVAILABLE`](/dist/docs/3.3.0-beta.0/building/verification/compliance-catalog#error-code-service-unavailable) at task level without taking any hold. The buyer revises the proposal, which re-resolves the definitions and issues fresh pins, reviews the changed definition, and finalizes again. Only `commercial_terms` is frozen at finalize: `forecast` may differ between a draft and its committed successor, and a rename or other change outside the pin's projection is not drift.

Holds are a seller commitment against real inventory, so hold policy is the seller's. Sellers MAY decline any hold (`hold_unavailable`), MAY cap concurrent unexpired holds per buyer to prevent inventory squatting, and choose `expires_at` themselves, within any declared [hold policy](#hold-policy-experimental) — the hold window should be long enough to complete acceptance and short enough that unaccepted holds do not function as free inventory options. Nothing in this task obliges a seller to grant holds without whatever credit or relationship checks it applies to reservations generally; the async `submitted` path exists for exactly that re-underwriting.

This contract does not define a pDOOH booking horizon, a frame/loop overlap
model, or a locking or scheduling algorithm for separate concurrent finalize
requests. Those policies cannot be inferred from `expires_at`. Whatever policy
a seller implements, every unexpired committed proposal that has not been superseded by
[`extend_hold`](#extend-a-hold-experimental), and whose [ranked hold](#hold-ranking-experimental) is not behind
another hold, retains the existing acceptance
guarantee; sellers cannot issue a committed hold and later reject it
merely because the same inventory was allocated elsewhere.

Every revised proposal inherits the source proposal's opportunity association. Callers do not repeat opportunity context during refinement.

### Hold policy (experimental)

> [!NOTE]
>
> Experimental (`media_buy.hold_policy`): sellers that emit `hold_policy` or `hold_placed_at` declare the feature id in `experimental_features`, and the shape may change in 3.x with the notice described in [Experimental status](/dist/docs/3.3.0-beta.0/reference/experimental-status). A seller that lists `media_buy.hold_policy` MUST emit `hold_policy` on every draft and committed proposal it returns to a caller at 3.3 or later. A 3.3+ buyer that relies on a reservation MUST read the committed proposal's `hold_policy.available`, and `rankable` and `hold_rank` when the seller lists `media_buy.hold_ranking`; when `hold_policy` is absent, the agent's `hold_semantics` default applies.

Without a declared policy, a buyer cannot know before finalizing whether the hold will outlast its approval chain. `hold_policy` makes the hold plannable. The same object appears in two places:

* **On a product** (from [`get_products`](/dist/docs/3.3.0-beta.0/media-buy/task-reference/get_products) or alongside proposals), it is a planning hint: what the seller generally offers when finalizing a proposal that allocates the product, so the buyer can plan before asking for a proposal. Product values bind nothing.
* **On a proposal.** On a draft, it is binding: what a successful finalize of that draft will get, derived by the seller from the allocated products. On the committed successor, the seller carries the draft's policy forward unchanged; it governs the hold that finalize placed.

Proposal-level policy exists only in the compact lifecycle described on this page. Legacy [`get_products`](/dist/docs/3.3.0-beta.0/media-buy/task-reference/get_products) proposals carry no `hold_policy`, because the legacy refinement envelope cannot express `hold_unavailable` or `batch_aborted`; on that surface, a product's `hold_policy` is planning information only.

| Field | Meaning |
| - | - |
| `available` (required) | Whether a successful finalize places an inventory hold. |
| `min_duration` | Minimum hold the seller promises. |
| `max_duration` | Longest initial hold the seller will place. |
| `extendable` | Whether the seller accepts requests to extend a hold placed under this policy. Defaults to `false`. |
| `max_total_duration` | Ceiling on the total hold across all extensions, measured from the original `hold_placed_at`. Allowed only when `extendable` is `true`. |
| `rankable` | Whether the hold may be placed behind other buyers' holds (experimental, `media_buy.hold_ranking`; see [Hold ranking](#hold-ranking-experimental)). Defaults to `false`. Allowed only when `available` is `true`. |

Durations are strictly positive ISO 8601 durations in days, hours, minutes, and seconds only (`P2D`, `PT36H`). A duration is elapsed time: a day is exactly 24 hours (86,400 seconds) added to the UTC instant, never a calendar day, so time zones and daylight-saving changes do not affect it. Compare durations after normalizing them, so `P1D` equals `PT24H`, as for [SLA windows](https://adcontextprotocol.org/schemas/3.3.0-beta.0/core/sla-window.json). Durations, `extendable`, and `rankable` are forbidden when `available` is `false`. When present, `min_duration` MUST NOT exceed `max_duration`, and `max_duration` MUST NOT exceed `max_total_duration`. The schema cannot compare durations, so test runners check this ordering after normalizing.

```json theme={null}
{
  "$schema": "/schemas/3.3.0-beta.0/core/hold-policy.json",
  "available": true,
  "min_duration": "P2D",
  "max_duration": "P5D",
  "extendable": true,
  "max_total_duration": "P14D"
}
```

A committed proposal whose `hold_policy.available` is `true` also carries `hold_placed_at` (experimental, same feature id): the instant the seller placed the hold, which is when it created the committed proposal. It is required there, and forbidden on drafts and when `available` is `false`. `hold_placed_at` MUST be no earlier than when the seller received the finalize request, and no later than its synchronous response or, for a `submitted` finalize, the task's completion. The bounds below are measured from it.

* **The bounds are promises.** A successful finalize MUST satisfy `hold_placed_at` + `min_duration` ≤ `expires_at` ≤ `hold_placed_at` + `max_duration`, for whichever of the two durations the draft declares. If the seller cannot honor the minimum, the entry returns `unable` with `reason_code: "hold_unavailable"` and otherwise-eligible siblings return `batch_aborted`, as above. An exact retry under the same idempotency key returns the original committed proposal, with its original `hold_placed_at` and `expires_at`; a retry never restarts the clock. The policy reserves nothing: the draft stays immutable, availability stays live, and finalize can still fail. A buyer whose approval chain needs a known window compares it with `min_duration` before finalizing.
* **A `submitted` finalize starts the clock at completion.** The hold is placed when the task completes, not when the buyer sent the request, and nothing protects the inventory while the task is pending, so it can still end in `hold_unavailable`. The buyer learns of the hold only when the completion reaches it, so its usable window is shorter than `min_duration` by that notification delay.
* **A hold never outlives the booking deadline.** When an allocated product's installments have a booking deadline or change cutoff (`deadlines.booking_deadline` or `deadlines.change_cutoff`, declared or computed from `deadline_policy`), `expires_at` MUST NOT be later than the earliest of them. If `hold_placed_at` + `min_duration` would fall after that instant, the seller cannot honor the minimum and returns `hold_unavailable`.
* **A hold is exclusive unless it is ranked.** `available: true` means an exclusive, first-position hold: until `expires_at`, no other buyer's hold or booking takes the held inventory. A policy that also declares `rankable: true` allows a ranked hold instead, which may be placed behind other buyers' holds; see [Hold ranking](#hold-ranking-experimental). `available` is not reinterpreted: without `rankable: true`, a hold is exclusive and first-position.
* **Drafts bind; products plan.** The seller derives a draft's policy from the allocated products' effective policies when it creates the draft. `available` is `false` if any allocated product is non-holdable. `min_duration` and `max_duration` MUST NOT exceed the `max_duration` of any allocated product that declares one, and `max_total_duration` MUST NOT exceed that of any allocated product that declares one. `extendable` is `true` only if every allocated product is extendable, and `rankable` is `true` if any allocated product's effective policy is rankable. Because the draft is immutable, its policy governs finalize even if a product's policy changes before then.
* **Terms-only entries finalize without a hold.** There are no partial holds within a proposal. An entry whose effective policy is terms-only (a draft with `available: false`, or a draft without `hold_policy` at a `terms_only` agent) follows the `terms_only` rules above for that entry. Its terms are committed within the same atomic batch, no hold is placed, `expires_at` is the acceptance window, and the seller MUST NOT return `hold_unavailable` for it. In an all-or-none batch where a holdable sibling returns `hold_unavailable`, the terms-only entry returns `batch_aborted`. The 3.3 version rule applies equally: the seller MUST NOT return such a committed proposal to a caller whose `adcp_version` is below 3.3, and fails with [`UNSUPPORTED_FEATURE`](/dist/docs/3.3.0-beta.0/building/verification/compliance-catalog#error-code-unsupported-feature) instead. Declaring `available: false` does not require listing `media_buy.delegated_composition`. Per-installment hold readback ([Installment holds](#installment-holds-experimental)) describes how one hold maps onto installments and does not change this rule.
* **Agent-level defaults.** A product or draft without `hold_policy` takes the agent's `hold_semantics` (and `inventory_hold` when that is absent too). A `terms_only` agent can hold specific products by declaring `available: true` on them, and an `inventory_hold` agent can mark products non-holdable. When no product or proposal carries `hold_policy`, behavior is unchanged from 3.2.
* **Callers below 3.3 never see these fields.** `hold_policy` and `hold_placed_at` are 3.3 fields, and 3.2 canonical products and proposals reject unknown properties. A seller MUST omit both, and `hold_rank` and `holds`, from every product and proposal it returns to a caller whose `adcp_version` is below 3.3, as [serving an older pin](/dist/docs/3.3.0-beta.0/reference/versioning#serving-an-older-pin) recommends, and otherwise applies these rules unchanged, except that it treats `rankable` as `false` for such a caller. Such a caller still never receives a committed proposal whose effective policy is terms-only.
* **Extension.** Where a seller supports [`extend_hold`](#extend-a-hold-experimental), `extendable` and `max_total_duration` bound it: an extended successor carries `hold_policy` and `hold_placed_at` forward unchanged, and `max_total_duration` counts across the whole extension chain from the original `hold_placed_at`. Exact retries and the active-hold [`INVALID_STATE`](/dist/docs/3.3.0-beta.0/building/verification/compliance-catalog#error-code-invalid-state) rule above are unchanged, so finalizing again is still not a way to extend a hold.

### Hold ranking (experimental)

> [!NOTE]
>
> Experimental (`media_buy.hold_ranking`): sellers that emit `hold_policy.rankable` or `hold_rank` declare the feature id in `experimental_features`, alongside `media_buy.hold_policy`. The shape may change in 3.x with the notice described in [Experimental status](/dist/docs/3.3.0-beta.0/reference/experimental-status).

Sellers of scheduled media often hold the same slot for several buyers in order: a first hold, then a second behind it that converts if the first lapses or is released. A seller declares that its holds work this way with `hold_policy.rankable: true`, and the committed proposal reports the buyer's own position in `hold_rank`. Ranking is advisory: AdCP defines no right-of-first-refusal deadline, no rank-change notification, and no way to challenge a hold ahead. Those stay bilateral between buyer and seller.

| `hold_policy` | What a successful finalize places | `hold_rank` on the committed proposal |
| - | - | - |
| `available: false` | No hold; terms only | Forbidden |
| `available: true`, `rankable` absent or `false` | An exclusive, first-position hold, or the entry returns `hold_unavailable` | Forbidden |
| `available: true`, `rankable: true` | A hold at any position | Required: `1` is first position; `n` means `n - 1` holds are ahead, counting only holds whose booking could prevent this one |

```json theme={null}
{
  "$schema": "/schemas/3.3.0-beta.0/core/canonical-proposal.json",
  "proposal_id": "prop_committed_042",
  "proposal_kind": "new_media_buy",
  "proposal_status": "committed",
  "parent_proposal_id": "prop_draft_042",
  "name": "Creator launch sponsorship",
  "expires_at": "2027-01-20T17:00:00Z",
  "commercial_terms": {
    "brand": { "domain": "nova-athletics.example" },
    "purchases": [
      {
        "product_id": "availnet_creator_slot_0412",
        "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",
  "hold_policy": { "available": true, "min_duration": "P2D", "max_duration": "P5D", "rankable": true },
  "hold_placed_at": "2027-01-16T17:00:00Z",
  "hold_rank": 2
}
```

* **First position keeps the guarantee.** A hold at `hold_rank: 1` has the same acceptance guarantee as an exclusive hold until `expires_at`. The difference between the two is only at placement: an exclusive policy gets first position or `hold_unavailable`, while a rankable one may be placed behind other holds.
* **A hold behind first position does not.** While a hold ahead of it is live or has been accepted, the seller MAY decline [`accept_proposal`](/dist/docs/3.3.0-beta.0/media-buy/task-reference/accept_proposal) with [`PRODUCT_UNAVAILABLE`](/dist/docs/3.3.0-beta.0/building/verification/compliance-catalog#error-code-product-unavailable), or MAY return `submitted` while it settles the holds ahead with their holders outside the protocol. It MUST NOT reprice or substitute products. The proposal stays `committed` until `expires_at`, so the buyer keeps the hold rather than treating `PRODUCT_UNAVAILABLE` as a reason to drop it. A seller that asks a holder ahead to confirm or release (a challenge) cannot shorten that hold's `expires_at` by doing so. Once every hold ahead has lapsed, been released, or been declined, the hold is at first position and has the full guarantee.
* **Positions never get worse.** A seller MUST NOT move a live hold to a worse position after placing it. No later hold or booking stands ahead of it, an extension keeps its place, and commitments the seller must honor, such as an incumbent's renewal rights, are counted when the hold is placed.
* **`hold_rank` is a snapshot.** It is the position when the seller created the committed proposal: at `hold_placed_at` for a finalize, or at the extension for an [`extend_hold`](#extend-a-hold-experimental) successor. The proposal is immutable, so the value is never updated, an exact retry replays it, and no notification reports a change. Because positions never get worse, the buyer's current position is at least as good as the one reported. To refresh it, the buyer reads the `hold_rank` on an `extend_hold` successor (where the policy is `extendable`), or calls `accept_proposal` when it is ready to book, which either succeeds or returns `PRODUCT_UNAVAILABLE`. There is no separate read for a committed proposal.
* **One position per proposal.** Finalize stays atomic and there are no partial holds. When the hold covers inventory at different positions, `hold_rank` is the worst of them, because the proposal can only be accepted as a whole.
* **Only the buyer's own position.** `hold_rank` tells the buyer how many holds are ahead and nothing else. The seller returns it only on proposals it returns to the buyer that holds them, and MUST NOT disclose any other buyer's identity or terms through any field, `reason`, or error. It MUST NOT disclose another hold's expiry either, except that `expected_confirmation_at` on a `submitted` acceptance MAY reflect when a hold ahead is due to resolve. The existing per-buyer cap on concurrent unexpired holds also limits finalizing just to learn how contested the inventory is.
* **Drafts and products.** A product's `rankable` is a planning hint. A draft is `rankable` if any allocated product's effective policy is, and its policy binds finalize: a draft without `rankable: true` gets a first-position hold or `hold_unavailable`. A buyer that does not want a hold behind first position can let it lapse or [decline](/dist/docs/3.3.0-beta.0/media-buy/task-reference/decline_proposals) the committed proposal.
* **Callers below 3.3.** They never receive `hold_policy` or `hold_rank`, and they read `committed` as an exclusive hold. For them the seller treats `rankable` as `false`: it places a first-position hold or returns `hold_unavailable`.

### Installment holds (experimental)

> [!NOTE]
>
> Experimental (`media_buy.installment_holds`): sellers that emit `holds` declare the feature id in `experimental_features`, alongside `media_buy.hold_policy`. The shape may change in 3.x with the notice described in [Experimental status](/dist/docs/3.3.0-beta.0/reference/experimental-status).

For installment products such as creator posts, podcast episodes, or linear airings, the thing being held is a specific post date or episode. A committed proposal can list them in `holds`:

```json theme={null}
{
  "$schema": "/schemas/3.3.0-beta.0/core/canonical-proposal.json",
  "proposal_id": "prop_committed_043",
  "proposal_kind": "new_media_buy",
  "proposal_status": "committed",
  "parent_proposal_id": "prop_draft_043",
  "name": "Creator launch sponsorship",
  "expires_at": "2027-01-20T17:00:00Z",
  "commercial_terms": {
    "brand": { "domain": "nova-athletics.example" },
    "purchases": [
      {
        "product_id": "availnet_creator_slot_0412",
        "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",
  "hold_policy": { "available": true, "min_duration": "P2D", "max_duration": "P5D", "rankable": true },
  "hold_placed_at": "2027-01-16T17:00:00Z",
  "hold_rank": 2,
  "holds": [
    {
      "product_id": "availnet_creator_slot_0412",
      "installment_ref": { "collection_ref": { "publisher_domain": "availnet.example", "collection_id": "pinnacle_creator" }, "installment_id": "ep_041" },
      "hold_rank": 1
    },
    {
      "product_id": "availnet_creator_slot_0412",
      "installment_ref": { "collection_ref": { "publisher_domain": "availnet.example", "collection_id": "pinnacle_creator" }, "installment_id": "ep_042" },
      "hold_rank": 2
    }
  ]
}
```

* **Readback of one hold, not partial holds.** `holds` describes how the single hold that finalize placed maps onto installments. Every entry was placed by the same all-or-none finalize: if any installment cannot be held, the finalize entry returns `hold_unavailable` and no installment is held. Every entry shares `hold_placed_at` and the proposal's `expires_at`, and is accepted, extended, superseded, or lapses with the proposal as a whole. No task addresses an entry on its own, and the rule that there are no partial holds within a proposal is unchanged. `holds` is readback, not contract: `commercial_terms` stays authoritative. To hold fewer installments, the buyer [declines](/dist/docs/3.3.0-beta.0/media-buy/task-reference/decline_proposals) the committed proposal or lets it lapse, then finalizes a revised draft. Until the earlier hold is released, it counts like any other hold, so finalizing the revised draft while it stands can return `hold_unavailable` or a worse position.
* **One deadline.** Entries carry no `expires_at`. The proposal's `expires_at` is the deadline for every entry, and it is already capped at the earliest `booking_deadline` or `change_cutoff` of the held installments. The same cap applies to each `extend_hold` successor: an extension never moves `expires_at` past it.
* **Complete when present.** `holds` lists every installment the hold covers, once per `product_id` and `installment_ref`. Every `product_id` is a product in `commercial_terms.purchases`, and every installment is one that product includes, scheduled within that purchase's flight. Inventory that is not a specific installment, such as a run-of-collection product, is not listed. A seller that lists `media_buy.installment_holds` MUST include `holds` on every committed proposal it returns to a 3.3+ caller that allocates a product declaring specific `installments`. It is allowed only on a non-draft proposal whose `hold_policy.available` is `true`, so drafts and terms-only commitments never carry it.
* **Positions per installment.** Under a [rankable](#hold-ranking-experimental) policy, every entry carries `hold_rank`, the buyer's own position on that installment. The proposal's `hold_rank` is no better than any entry's, and equals the worst entry's unless unlisted inventory is held at a worse position. The snapshot, never-worse, and privacy rules of `hold_rank` apply to each entry. An entry behind first position shows the buyer which installment is contested, so it can drop or move that installment in a revised draft, as above.
* **Only the buyer's own hold.** `holds` lists this proposal's hold only. A seller MUST NOT include or describe other buyers' holds on the same installments.
* **Extension.** An `extend_hold` successor lists the same entries, since the extension transfers the same hold. Under a rankable policy, each entry's position is as of the extension and no worse than in the source.
* **Callers below 3.3** never receive `holds`, together with `hold_policy`.

## Extend a hold (experimental)

Buyers often need more time for legal review or client sign-off than the seller's first `expires_at` allows. `action: "extend_hold"` lengthens a hold without lapsing it and re-finalizing. It is experimental under feature id `media_buy.hold_extension`: a seller that supports it lists that id in `experimental_features` and declares `media_buy.proposal_refinement.hold_extension: true` in [`get_adcp_capabilities`](/dist/docs/3.3.0-beta.0/protocol/get_adcp_capabilities#proposal_refinement). Buyers MUST NOT send `extend_hold` unless the seller declares it; a seller that does not declare it fails the unknown `action` value with [`VALIDATION_ERROR`](/dist/docs/3.3.0-beta.0/building/verification/compliance-catalog#error-code-validation-error). The shared request schema lists `extend_hold`, so this is enforced by the seller at runtime, not by schema validation.

```json theme={null}
{
  "idempotency_key": "550e8400-e29b-41d4-a716-446655441021",
  "refinements": [
    {
      "proposal_id": "prop_committed_001",
      "action": "extend_hold"
    }
  ]
}
```

`extend_hold` carries only `proposal_id` and `action`, like `finalize`, and forbids every revise field. Because proposals are immutable, extension never mutates `expires_at` in place:

* **Source.** The source MUST be a committed, unexpired proposal that holds inventory and has not already been extended. A draft source fails at task level with [`PROPOSAL_NOT_COMMITTED`](/dist/docs/3.3.0-beta.0/building/verification/compliance-catalog#error-code-proposal-not-committed); an accepted or already-extended source fails with [`INVALID_STATE`](/dist/docs/3.3.0-beta.0/building/verification/compliance-catalog#error-code-invalid-state); an expired source fails with [`PROPOSAL_EXPIRED`](/dist/docs/3.3.0-beta.0/building/verification/compliance-catalog#error-code-proposal-expired), and the buyer finalizes the draft again. An unknown or invisible source returns `unable` with `reason_code: "source_unavailable"`, and otherwise-eligible siblings return `batch_aborted`.
* **No hold, no extension.** `extend_hold` applies only to committed proposals that hold inventory. A committed proposal with no hold has an acceptance window, not a hold, and carries no `hold_placed_at`: the rule is per entry, for any committed proposal whose effective policy is terms-only (its `hold_policy.available` is `false`, or it has no `hold_policy` and the seller declares `hold_semantics: "terms_only"`). An `extend_hold` entry targeting it fails the request at task level with [`INVALID_STATE`](/dist/docs/3.3.0-beta.0/building/verification/compliance-catalog#error-code-invalid-state), even when its siblings hold inventory; the batch is atomic, so no successor is created. This keeps the `terms_only` rule that such a seller never returns `hold_unavailable`, and matches `hold_policy`, which forbids `extendable` when `available` is `false`.
* **Success.** The result is `outcome: "finalized"` with a new committed proposal whose `parent_proposal_id` is the source, whose `commercial_terms` and `terms_digest` equal the source's, and whose `expires_at` is later than the source's. The successor carries the source's `hold_policy` and `hold_placed_at` forward unchanged. Under a [ranked](#hold-ranking-experimental) policy, it reports `hold_rank` as of the extension, which is never worse than the source's. It lists the same [installment holds](#installment-holds-experimental) as the source, and its `expires_at` never passes the earliest booking deadline or change cutoff of the held installments. The seller MUST persist the successor and transfer the hold to it as one state transition, as for finalize. A buyer tells an extension from a finalize by the request it sent (a batch is all one or the other) and by the source's `proposal_status`; it SHOULD verify the successor's `terms_digest` equals the source's before moving its own approval records to the new `proposal_id`.
* **The source is superseded.** The source snapshot is immutable and keeps `proposal_status: committed`, but from that transition it is superseded by its successor and can no longer be accepted: [`accept_proposal`](/dist/docs/3.3.0-beta.0/media-buy/task-reference/accept_proposal) against it fails with `INVALID_STATE`, and a second `extend_hold` against it also fails with `INVALID_STATE`. To extend again, target the successor. If an `accept_proposal` and an `extend_hold` race on the same source, the seller serializes them: the first to commit wins and the other receives the error above. An accepted source consumes its hold, so a racing extension cannot succeed after it.
* **Failure.** The entry returns `unable` with `reason_code: "hold_unavailable"` and a `reason`, and otherwise-eligible siblings return `batch_aborted`. The source and its hold are unchanged and remain acceptable until their original `expires_at`.
* **Batches.** A request containing `extend_hold` contains only `extend_hold` entries and is atomic: every hold is extended or none is, the same pattern as finalize.
* **Async.** A seller whose extension depends on a person's confirmation MAY use the task's `submitted` path and SHOULD set `expected_confirmation_at` on that response, as for a [finalize that waits on a person](#finalize-and-hold-inventory); the source stays acceptable until the extension completes.
* **Retries.** An exact retry with the same idempotency key replays the original response. A new key after a successful extension targets a source that is no longer acceptable, so it fails with `INVALID_STATE`; extending a hold always goes through `extend_hold`, never through a new finalize key.
* **Limits.** `hold_extension: true` is the agent-level gate for the action; the source's [`hold_policy`](#hold-policy-experimental), when present, sets the limits for that hold. `extendable` defaults to `false`: when it is `false` or absent the seller declines (`hold_unavailable`), and buyers SHOULD NOT send `extend_hold` for that proposal. The successor's `expires_at` MUST NOT exceed `hold_placed_at` plus `max_total_duration`. Because every successor carries `hold_placed_at` forward, that is the instant the root finalize placed the hold, so `max_total_duration` is measured across the whole chain of extension successors and is not reset by an extension. A request the seller cannot extend at all, for example because no later `expires_at` fits under that ceiling, returns `hold_unavailable`. Absent `hold_policy` (a seller that does not list `media_buy.hold_policy`), limits remain seller policy expressed through `hold_unavailable`. In either case sellers MAY decline any extension and MAY cap extensions per root lineage, so repeated extension cannot turn a hold into a free option, and how far `expires_at` moves within those bounds is the seller's choice, as for finalize.

## Negotiation security

Both sides of this task are agents, and both sides' inputs are untrusted. `ask` is attacker-controlled free text: sellers MUST NOT let it alter pricing floors, authorization, or policy, and MUST NOT expose other buyers' terms through it — fence it from any LLM with pricing authority and treat its interpretation as suggestion, never instruction. In the other direction, deterministic `constraint_unsatisfiable` responses form a price oracle: repeated budget or rate probes can binary-search a floor at no cost. Sellers SHOULD apply rate or commercial friction to sustained un-held revision rounds and MAY keep `reason` deliberately coarse rather than revealing how near a rejected constraint came to clearing.

## Amend an accepted MediaBuy

An accepted proposal is the immutable commercial envelope attached to its MediaBuy. Refining it forks a successor proposal rather than reopening or mutating the accepted snapshot:

```json theme={null}
{
  "idempotency_key": "550e8400-e29b-41d4-a716-446655441013",
  "refinements": [
    {
      "proposal_id": "proposal_accepted_123",
      "action": "revise",
      "change_kind": "amendment",
      "ask": "Extend the flight by two weeks and add the premium video product."
    }
  ]
}
```

Use `change_kind: "cancellation"` when cancellation requires counterparty agreement. The returned draft has `proposal_kind: "media_buy_cancellation"`, identifies the MediaBuy and base revision, and must be finalized before it can be applied through [`accept_proposal`](/dist/docs/3.3.0-beta.0/media-buy/task-reference/accept_proposal).

Sellers MAY respond asynchronously with `status: "submitted"` when a complex multi-publisher revision or inventory hold requires upstream pricing queries or manual re-underwriting. In that case the response contains a `task_id` for polling via [`get_task_status`](https://adcontextprotocol.org/schemas/3.3.0-beta.0/protocol/get-task-status-request.json); terminal results are delivered on the completion artifact or via push notification if `push_notification_config` was supplied.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.