refine_proposals has two explicit operations over immutable proposal snapshots:
action: "revise"applies structured discoverycriteria, typed boundaries, product changes, requested alternatives, and/or the buyer’s free-textask, returning one or more new draft proposals.action: "finalize"changes no commercial terms and returns a new committed proposal backed by an inventory hold untilexpires_at.action: "extend_hold"(experimental) changes no commercial terms and returns a new committed proposal with a laterexpires_at; the hold transfers to it. See Extend a hold.
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
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:
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 supportstotal_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 identifiestotal_budgetas unsatisfied and cannot berevised. - If Sam requests four alternatives against the advertised ceiling of three, the whole call fails with
VALIDATION_ERRORatrefinements[0].alternatives.count; StreamHaus creates no draft. - If StreamHaus omits
criteriafromsupported_dimensions, the whole call fails withUNSUPPORTED_FEATUREbefore mutation. Sam can remove or translate that field and retry with a new valid payload.
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 deprecatedget_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 returnedexpires_at:
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.hold_policy makes the hold plannable. The same object appears in two places:
- On a product (from
get_productsor 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.
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.
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 returnsunablewithreason_code: "hold_unavailable"and otherwise-eligible siblings returnbatch_aborted, as above. An exact retry under the same idempotency key returns the original committed proposal, with its originalhold_placed_atandexpires_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 withmin_durationbefore finalizing. - A
submittedfinalize 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 inhold_unavailable. The buyer learns of the hold only when the completion reaches it, so its usable window is shorter thanmin_durationby 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_deadlineordeadlines.change_cutoff, declared or computed fromdeadline_policy),expires_atMUST NOT be later than the earliest of them. Ifhold_placed_at+min_durationwould fall after that instant, the seller cannot honor the minimum and returnshold_unavailable. - A hold is exclusive unless it is ranked.
available: truemeans an exclusive, first-position hold: untilexpires_at, no other buyer’s hold or booking takes the held inventory. A policy that also declaresrankable: trueallows a ranked hold instead, which may be placed behind other buyers’ holds; see Hold ranking.availableis not reinterpreted: withoutrankable: 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.
availableisfalseif any allocated product is non-holdable.min_durationandmax_durationMUST NOT exceed themax_durationof any allocated product that declares one, andmax_total_durationMUST NOT exceed that of any allocated product that declares one.extendableistrueonly if every allocated product is extendable, andrankableistrueif 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 withouthold_policyat aterms_onlyagent) follows theterms_onlyrules above for that entry. Its terms are committed within the same atomic batch, no hold is placed,expires_atis the acceptance window, and the seller MUST NOT returnhold_unavailablefor it. In an all-or-none batch where a holdable sibling returnshold_unavailable, the terms-only entry returnsbatch_aborted. The 3.3 version rule applies equally: the seller MUST NOT return such a committed proposal to a caller whoseadcp_versionis below 3.3, and fails withUNSUPPORTED_FEATUREinstead. Declaringavailable: falsedoes not require listingmedia_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_policytakes the agent’shold_semantics(andinventory_holdwhen that is absent too). Aterms_onlyagent can hold specific products by declaringavailable: trueon them, and aninventory_holdagent can mark products non-holdable. When no product or proposal carrieshold_policy, behavior is unchanged from 3.2. - Callers below 3.3 never see these fields.
hold_policyandhold_placed_atare 3.3 fields, and 3.2 canonical products and proposals reject unknown properties. A seller MUST omit both, andhold_rankandholds, from every product and proposal it returns to a caller whoseadcp_versionis below 3.3, as serving an older pin recommends, and otherwise applies these rules unchanged, except that it treatsrankableasfalsefor 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,extendableandmax_total_durationbound it: an extended successor carrieshold_policyandhold_placed_atforward unchanged, andmax_total_durationcounts across the whole extension chain from the originalhold_placed_at. Exact retries and the active-holdINVALID_STATErule 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.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: 1has the same acceptance guarantee as an exclusive hold untilexpires_at. The difference between the two is only at placement: an exclusive policy gets first position orhold_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_proposalwithPRODUCT_UNAVAILABLE, or MAY returnsubmittedwhile it settles the holds ahead with their holders outside the protocol. It MUST NOT reprice or substitute products. The proposal stayscommitteduntilexpires_at, so the buyer keeps the hold rather than treatingPRODUCT_UNAVAILABLEas a reason to drop it. A seller that asks a holder ahead to confirm or release (a challenge) cannot shorten that hold’sexpires_atby 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_rankis a snapshot. It is the position when the seller created the committed proposal: athold_placed_atfor a finalize, or at the extension for anextend_holdsuccessor. 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 thehold_rankon anextend_holdsuccessor (where the policy isextendable), or callsaccept_proposalwhen it is ready to book, which either succeeds or returnsPRODUCT_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_rankis the worst of them, because the proposal can only be accepted as a whole. - Only the buyer’s own position.
hold_ranktells 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 thatexpected_confirmation_aton asubmittedacceptance 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
rankableis a planning hint. A draft isrankableif any allocated product’s effective policy is, and its policy binds finalize: a draft withoutrankable: truegets a first-position hold orhold_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_policyorhold_rank, and they readcommittedas an exclusive hold. For them the seller treatsrankableasfalse: it places a first-position hold or returnshold_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.holds:
- Readback of one hold, not partial holds.
holdsdescribes 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 returnshold_unavailableand no installment is held. Every entry shareshold_placed_atand the proposal’sexpires_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.holdsis readback, not contract:commercial_termsstays 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 returnhold_unavailableor a worse position. - One deadline. Entries carry no
expires_at. The proposal’sexpires_atis the deadline for every entry, and it is already capped at the earliestbooking_deadlineorchange_cutoffof the held installments. The same cap applies to eachextend_holdsuccessor: an extension never movesexpires_atpast it. - Complete when present.
holdslists every installment the hold covers, once perproduct_idandinstallment_ref. Everyproduct_idis a product incommercial_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 listsmedia_buy.installment_holdsMUST includeholdson every committed proposal it returns to a 3.3+ caller that allocates a product declaring specificinstallments. It is allowed only on a non-draft proposal whosehold_policy.availableistrue, 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’shold_rankis 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 ofhold_rankapply 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.
holdslists this proposal’s hold only. A seller MUST NOT include or describe other buyers’ holds on the same installments. - Extension. An
extend_holdsuccessor 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 withhold_policy.
Extend a hold (experimental)
Buyers often need more time for legal review or client sign-off than the seller’s firstexpires_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 withINVALID_STATE; an expired source fails withPROPOSAL_EXPIRED, and the buyer finalizes the draft again. An unknown or invisible source returnsunablewithreason_code: "source_unavailable", and otherwise-eligible siblings returnbatch_aborted. - No hold, no extension.
extend_holdapplies only to committed proposals that hold inventory. A committed proposal with no hold has an acceptance window, not a hold, and carries nohold_placed_at: the rule is per entry, for any committed proposal whose effective policy is terms-only (itshold_policy.availableisfalse, or it has nohold_policyand the seller declareshold_semantics: "terms_only"). Anextend_holdentry targeting it fails the request at task level withINVALID_STATE, even when its siblings hold inventory; the batch is atomic, so no successor is created. This keeps theterms_onlyrule that such a seller never returnshold_unavailable, and matcheshold_policy, which forbidsextendablewhenavailableisfalse. - Success. The result is
outcome: "finalized"with a new committed proposal whoseparent_proposal_idis the source, whosecommercial_termsandterms_digestequal the source’s, and whoseexpires_atis later than the source’s. The successor carries the source’shold_policyandhold_placed_atforward unchanged. Under a ranked policy, it reportshold_rankas of the extension, which is never worse than the source’s. It lists the same installment holds as the source, and itsexpires_atnever 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’sproposal_status; it SHOULD verify the successor’sterms_digestequals the source’s before moving its own approval records to the newproposal_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_proposalagainst it fails withINVALID_STATE, and a secondextend_holdagainst it also fails withINVALID_STATE. To extend again, target the successor. If anaccept_proposaland anextend_holdrace 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
unablewithreason_code: "hold_unavailable"and areason, and otherwise-eligible siblings returnbatch_aborted. The source and its hold are unchanged and remain acceptable until their originalexpires_at. - Batches. A request containing
extend_holdcontains onlyextend_holdentries 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
submittedpath and SHOULD setexpected_confirmation_aton 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 throughextend_hold, never through a new finalize key. - Limits.
hold_extension: trueis the agent-level gate for the action; the source’shold_policy, when present, sets the limits for that hold.extendabledefaults tofalse: when it isfalseor absent the seller declines (hold_unavailable), and buyers SHOULD NOT sendextend_holdfor that proposal. The successor’sexpires_atMUST NOT exceedhold_placed_atplusmax_total_duration. Because every successor carrieshold_placed_atforward, that is the instant the root finalize placed the hold, somax_total_durationis 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 laterexpires_atfits under that ceiling, returnshold_unavailable. Absenthold_policy(a seller that does not listmedia_buy.hold_policy), limits remain seller policy expressed throughhold_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 farexpires_atmoves 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: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.