Skip to main content
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.
For an end-to-end buyer and seller implementation workflow, see 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
Typed fields are deliberately small and mechanically verifiable: 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 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; see 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: reason_code distinguishes commercial refusal from implementation limits: 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:
Every returned proposal carries typed commercial_terms and a terms_digest — the base64url SHA-256 of the RFC 8785 JCS 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 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 at refinements[0].alternatives.count; StreamHaus creates no draft.
  • If StreamHaus omits criteria from supported_dimensions, the whole call fails with 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, or use decline_proposals when the buyer stops pursuing it.

Relationship to legacy refinement

The deprecated 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 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:
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. 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, 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 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; 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. 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 rather than minting a second hold; after expiry a new finalize MAY create a new one. To lengthen an unexpired hold, use extend_hold. 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-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 as on 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) 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 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 — 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, and whose ranked hold 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)

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. 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 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 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. 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. 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.
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. 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 instead. Declaring available: false does not require listing media_buy.delegated_composition. Per-installment hold readback (Installment holds) 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 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, 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 rule above are unchanged, so finalizing again is still not a way to extend a hold.

Hold ranking (experimental)

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.
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.
  • 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 with 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 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 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)

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.
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:
  • 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 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 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. 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. The shared request schema lists extend_hold, so this is enforced by the seller at runtime, not by schema validation.
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; an accepted or already-extended source fails with INVALID_STATE; an expired source fails with 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, 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 policy, it reports hold_rank as of the extension, which is never worse than the source’s. It lists the same installment holds 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 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; 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, 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:
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. 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; terminal results are delivered on the completion artifact or via push notification if push_notification_config was supplied.