From b5006b7c2819ad453d2fb92b69ddc1c9f8dcb301 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 14 Jun 2026 09:22:48 -0400 Subject: [PATCH 1/4] feat(media-buy): add proposal decline refinement --- .changeset/proposal-decline-refinement.md | 5 + .../building/by-layer/L3/async-operations.mdx | 2 +- .../product-discovery/media-products.mdx | 4 + .../product-discovery/refinement.mdx | 68 +++- docs/media-buy/specification.mdx | 28 +- .../task-reference/create_media_buy.mdx | 14 + .../media-buy/task-reference/get_products.mdx | 113 ++++++- docs/reference/migration/index.mdx | 2 +- docs/reference/migration/v3-readiness.mdx | 2 +- docs/reference/release-notes.mdx | 2 +- docs/reference/whats-new-in-3-1.mdx | 4 +- .../media-buy/scenarios/proposal_decline.yaml | 274 +++++++++++++++ .../specialisms/sales-guaranteed/index.yaml | 4 +- .../sales-proposal-mode/index.yaml | 4 +- .../source/core/opportunity-context.json | 101 ++++++ static/schemas/source/core/proposal.json | 6 + .../schemas/source/core/x-entity-types.json | 2 + static/schemas/source/enums/error-code.json | 4 +- static/schemas/source/index.json | 4 + .../media-buy/create-media-buy-request.json | 46 +++ ...get-products-async-response-submitted.json | 2 +- .../media-buy/get-products-request.json | 213 +++++++++++- .../media-buy/get-products-response.json | 6 + .../get-adcp-capabilities-response.json | 4 +- tests/example-validation-simple.test.cjs | 311 ++++++++++++++++++ 25 files changed, 1182 insertions(+), 43 deletions(-) create mode 100644 .changeset/proposal-decline-refinement.md create mode 100644 static/compliance/source/protocols/media-buy/scenarios/proposal_decline.yaml create mode 100644 static/schemas/source/core/opportunity-context.json diff --git a/.changeset/proposal-decline-refinement.md b/.changeset/proposal-decline-refinement.md new file mode 100644 index 0000000000..9798cd1255 --- /dev/null +++ b/.changeset/proposal-decline-refinement.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Add proposal opportunity and decline feedback to the media-buy flow. Buyers can now provide optional buyer-reported `opportunity` context across `get_products` and `create_media_buy` calls, while proposal refine entries support `action: "decline"` with a required `proposal_version` and reason taxonomy. `get_products` also supports `buying_mode: "open"` to reload unresolved products/proposals already associated with the caller/account/opportunity without adding a new task or response grouping object. Versioned proposals carry `proposal_version` through `create_media_buy` so sellers can verify the accepted offer version and reject execution of previously declined proposal versions. Opportunity close reasons distinguish `accepted_with_seller` from loss outcomes, and proposal-capable sellers get compliance coverage for decline acknowledgement, duplicate declines, and create rejection after decline. diff --git a/docs/building/by-layer/L3/async-operations.mdx b/docs/building/by-layer/L3/async-operations.mdx index 49ab80323c..6598f044ca 100644 --- a/docs/building/by-layer/L3/async-operations.mdx +++ b/docs/building/by-layer/L3/async-operations.mdx @@ -60,7 +60,7 @@ Any AdCP task can return one of these statuses. The server chooses based on what | `sync_catalogs` | Large feeds or feeds requiring content policy review | | `activate_signal` | Platform deployment pipelines | -These operations integrate with external systems or require human approval. Wholesale feed reads are the exception: `get_products buying_mode: "wholesale"` and `get_signals discovery_mode: "wholesale"` stay synchronous repair/reconciliation reads and report partial completion with `incomplete[]`, not `submitted`. +These operations integrate with external systems or require human approval. Wholesale feed reads are the exception: `get_products buying_mode: "wholesale"` and `get_signals discovery_mode: "wholesale"` stay synchronous repair/reconciliation reads and report partial completion with `incomplete[]`, not `submitted`. `get_products buying_mode: "open"` is also a synchronous state read; sellers return the unresolved products/proposals they already associate with the caller/account/opportunity rather than routing through `submitted`. ## Timeout Configuration diff --git a/docs/media-buy/product-discovery/media-products.mdx b/docs/media-buy/product-discovery/media-products.mdx index 0c2b89ed74..77b9cc725b 100644 --- a/docs/media-buy/product-discovery/media-products.mdx +++ b/docs/media-buy/product-discovery/media-products.mdx @@ -899,6 +899,8 @@ A proposal is a recommended buying strategy that groups products with suggested Key characteristics: - **Actionable**: A returned proposal is buyable through its lifecycle. `proposal_status: "draft"` means finalize first; `proposal_status: "committed"` means execute via `create_media_buy` with `proposal_id` before `expires_at`. +- **Versioned**: Sellers SHOULD include `proposal_version` when a proposal has mutable execution terms, and MUST include it on proposals they expect buyers to resolve explicitly by accepting or declining a specific version. Buyers use that version when declining a proposal and MUST carry it into `create_media_buy` when accepting a versioned proposal. +- **Opportunity-aware**: Buyers MAY carry the same `opportunity.opportunity_id` across discovery, `open` retrieval, refinement, proposal decline, and `create_media_buy` so both sides can connect proposal outcomes to the broader planning cycle. - **Budget-agnostic**: Allocations use percentages, allowing the same proposal to scale to any budget - **Forecast-equipped**: Proposals and allocations can include delivery forecasts to help buyers evaluate expected performance before purchase @@ -907,6 +909,7 @@ Key characteristics: ```json { "proposal_id": "swiss_balanced_v1", + "proposal_version": "1", "name": "Swiss Multi-Channel Plan", "description": "Balanced coverage across devices and language regions", "allocations": [ @@ -1010,6 +1013,7 @@ To execute a committed proposal, provide the `proposal_id` and `total_budget` in ```json { "proposal_id": "swiss_balanced_v1", + "proposal_version": "1", "total_budget": { "amount": 50000, "currency": "USD" diff --git a/docs/media-buy/product-discovery/refinement.mdx b/docs/media-buy/product-discovery/refinement.mdx index d2a85a42ce..933b044e40 100644 --- a/docs/media-buy/product-discovery/refinement.mdx +++ b/docs/media-buy/product-discovery/refinement.mdx @@ -4,23 +4,27 @@ description: "AdCP product refinement — iterate on discovered products and pro "og:title": "AdCP — Refinement" --- -Refinement turns product discovery into a conversation. After an initial `brief` or `wholesale` discovery, use `buying_mode: "refine"` to iterate on specific products and proposals — adjusting the selection, requesting changes, and exploring alternatives — before committing to a [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy). +Refinement turns product discovery into a conversation. After an initial `brief` or `wholesale` discovery, use `buying_mode: "open"` to reload unresolved products/proposals or `buying_mode: "refine"` to iterate on specific products and proposals — adjusting the selection, requesting changes, and exploring alternatives — before committing to a [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy). ## The refinement lifecycle A typical media buying workflow follows this pattern: ``` -discover → refine → refine → ... → buy +discover → open/load → refine → refine → ... → buy or decline ``` 1. **Discover** — Call `get_products` with `buying_mode: "brief"` or `"wholesale"` to find matching inventory. The seller returns products (and optionally proposals). -2. **Refine** — Call `get_products` with `buying_mode: "refine"` and a `refine` array of change requests. Each entry declares a scope and what the buyer is asking for. The seller returns updated products with revised pricing and configurations. +2. **Reload open work** — Call `get_products` with `buying_mode: "open"` to retrieve unresolved products/proposals already associated with the account or opportunity. The response reuses the existing `products` and `proposals` arrays. -3. **Repeat** — Refine as many times as needed. Each call is self-contained and stateless. +3. **Refine** — Call `get_products` with `buying_mode: "refine"` and a `refine` array of change requests. Each entry declares a scope and what the buyer is asking for. The seller returns updated products with revised pricing and configurations. -4. **Buy** — When satisfied, execute the final selection via `create_media_buy`. +4. **Repeat** — Refine as many times as needed. Each call is self-contained and stateless. + +5. **Buy or decline** — When satisfied, execute the final selection via `create_media_buy`. If a proposal is not being pursued, send a proposal-scoped `action: "decline"` so the seller can resolve that proposal version. + +If the buyer is working within a named planning cycle, include the same `opportunity.opportunity_id` on each related `brief`, `open`, `refine`, and `create_media_buy` call. `opportunity_id` connects the overall opportunity; `proposal_id` + `proposal_version` resolves one exact seller offer. To close the planning cycle without buying, send a `refine` call with the same `opportunity_id`, `opportunity.status: "closed"`, optional close reason/detail, and a request-level ask to close the opportunity. Refinement is not required. Simple campaigns can go straight from discovery to purchase. But for campaigns involving multiple products, proposals with budget allocations, or iterative negotiation, refinement is where the value is. @@ -85,6 +89,13 @@ The seller may add, remove, or rebalance products based on this direction. Produ Reference proposals by `proposal_id` to request adjustments or remove them. Like product entries, `action` defaults to `"include"`: +| Action | Behavior | `ask` / feedback | +|--------|----------|------------------| +| `include` (default) | Return this proposal with updated allocations, pricing, and terms | Optional — specific changes to request | +| `omit` | Exclude this proposal from the response without marking a terminal outcome | Ignored | +| `finalize` | Request firm pricing, terms, and inventory hold for a draft proposal | Ignored | +| `decline` | Terminally report that the buyer evaluated this proposal version and is not pursuing it | Requires `proposal_version` and `reason`; optional `detail` | + ```json { "buying_mode": "refine", @@ -96,6 +107,36 @@ Reference proposals by `proposal_id` to request adjustments or remove them. Like } ``` +### Decline a proposal + +Use `action: "decline"` when the buyer has evaluated a specific proposal version and decided not to pursue it. This is distinct from `omit`: `omit` only removes a proposal from the next response, while `decline` is a terminal outcome for the referenced proposal version. + +Sellers that expect buyers to resolve a proposal version explicitly, whether by accepting it via `create_media_buy` or declining it here, MUST include `proposal_version` on the proposal. Buyers MUST NOT send `decline` for an unversioned proposal; use `omit`, ignore it, or re-run discovery/refinement to obtain a versioned proposal. + +```json +{ + "buying_mode": "refine", + "refine": [ + { + "scope": "proposal", + "proposal_id": "prop_balanced_v1", + "proposal_version": "4", + "action": "decline", + "reason": "price", + "detail": "Above target CPM range" + } + ] +} +``` + +For `action: "decline"`, sellers MUST acknowledge the request with `refinement_applied`. `status: "applied"` means the decline was recorded and the proposal version is terminal. `status: "partial"` or `"unable"` means the buyer MUST NOT treat the proposal as declined. + +After a seller applies a proposal-scoped `decline`, the buyer MUST NOT execute that same `proposal_id` + `proposal_version` with `create_media_buy`. Sellers MUST reject later `create_media_buy` attempts against a declined proposal version with `INVALID_STATE`. + +Duplicate declines for the same buyer, account, `proposal_id`, and `proposal_version` are idempotent: sellers MUST NOT double-count the feedback. If a duplicate decline repeats the same `reason` and `detail`, sellers SHOULD acknowledge it as already applied. If a duplicate decline supplies different feedback, sellers SHOULD preserve the first applied reason and MAY return `refinement_applied[].status: "partial"` or `"unable"` with notes explaining that the proposal version was already declined. + +Decline feedback is aggregate-friendly. Use `reason` for seller analytics and keep `detail` short and non-identifying. Buyers MUST NOT include competing seller/platform/product names, clearing prices, bid strategy, confidential audience definitions, budget reallocations, campaign IDs, NDA-scoped terms, or other sensitive campaign information in `detail`. + ### Combining scopes All scopes work together. A single refinement call can set direction for the selection, act on specific products, and request changes to a proposal: @@ -144,7 +185,7 @@ When a buyer sends a `refine` array, the seller responds with `refinement_applie } ``` -The `refinement_applied` array MUST contain the same number of entries in the same order as the `refine` array. Each entry MUST echo `scope` and the matching id (`product_id` on product scope, `proposal_id` on proposal scope) so orchestrators can cross-validate alignment. The entire field is optional — sellers that don't track per-ask outcomes can omit it — but sellers that return it MUST return valid, position-matched entries. +The `refinement_applied` array MUST contain the same number of entries in the same order as the `refine` array. Each entry MUST echo `scope` and the matching id (`product_id` on product scope, `proposal_id` on proposal scope) so orchestrators can cross-validate alignment. For proposal entries where the buyer supplied `proposal_version`, sellers MUST echo `proposal_version` on the matching response entry. The entire field is optional for ordinary refinements — sellers that don't track per-ask outcomes can omit it — but sellers MUST return `refinement_applied` for any request containing proposal-scoped `action: "decline"`. Orchestrators SHOULD cross-check entries by the echoed id rather than trusting positional order alone — a seller bug that reorders entries would otherwise silently mis-attribute each outcome. @@ -201,7 +242,7 @@ The product entries define which products the seller should consider for the pro `action: "finalize"` is seller commitment, not buyer acceptance — it transitions a draft proposal to committed with firm pricing and an expires_at hold window. The buyer accepts/executes a committed proposal later via `create_media_buy(proposal_id)`. When a buyer wants to finalize, the spec requires the `refine[]` array contain **only** finalize entries: -- If any entry has `action: "finalize"`, **all** entries in the array MUST be proposal-scoped with `action: "finalize"`. Mixing finalize with `include` / `omit` entries, or with request- or product-scoped entries, MUST be rejected by the seller with `INVALID_REQUEST`. +- If any entry has `action: "finalize"`, **all** entries in the array MUST be proposal-scoped with `action: "finalize"`. Mixing finalize with `include` / `omit` / `decline` entries, or with request- or product-scoped entries, MUST be rejected by the seller with `INVALID_REQUEST`. - Buyers needing to refine *and* commit in close succession **sequence** the calls: first a refine call (no finalize), then a finalize call against the resulting `proposal_id`(s). The two intents are distinct decisions and the spec treats them as distinct calls. ```yaml @@ -235,7 +276,7 @@ Key points: - **Proposals are not guaranteed.** Sellers are not required to generate proposals in refine mode. Allocation and campaign optimization are primarily orchestrator (buyer-side agent) responsibilities. - **Signal interest via a request-level ask.** Include `{ "scope": "request", "ask": "suggest how to combine these products" }` to indicate you'd welcome a proposal. -- **Unsolicited proposals can be refined or ignored.** If a seller returns a proposal you didn't request, you can refine it in a follow-up call, or simply ignore it and build packages manually via `create_media_buy`. +- **Unsolicited proposals can be refined, declined, or ignored.** If a seller returns a proposal you didn't request, you can refine it in a follow-up call, explicitly decline it when you know you will not pursue it, or ignore it and build packages manually via `create_media_buy`. Publishers typically omit proposals in `wholesale` mode, where the buyer is directing targeting and allocation themselves. @@ -255,7 +296,7 @@ This design enables: Orchestrators should validate refinement requests before sending: - **Non-empty refine** — The `refine` array requires at least one entry. An empty `[]` is rejected by schema validation. -- **Valid entries** — Each product entry requires `scope` and `product_id`. Each proposal entry requires `scope` and `proposal_id`. Request-level entries require `scope` and `ask`. `action` is optional on product and proposal entries (defaults to `"include"`); valid values are `include` / `omit` / `more_like_this` for products and `include` / `omit` / `finalize` for proposals. +- **Valid entries** — Each product entry requires `scope` and `product_id`. Each proposal entry requires `scope` and `proposal_id`. Request-level entries require `scope` and `ask`. `action` is optional on product and proposal entries (defaults to `"include"`); valid values are `include` / `omit` / `more_like_this` for products and `include` / `omit` / `finalize` / `decline` for proposals. Proposal declines require `proposal_version` and `reason`. - **Filters are absolute** — Send the full filter set you want applied, not a delta from the previous request. Client implementations should validate refinement requests against the [request schema](/docs/building/schemas-and-sdks) before sending. @@ -268,6 +309,7 @@ Client implementations should validate refinement requests against the [request | `PROPOSAL_EXPIRED` | A referenced proposal ID has passed its `expires_at` | Re-discover with a new `brief` or `wholesale` request | | `PROPOSAL_NOT_FOUND` | The referenced `proposal_id` is unknown to the seller (never finalized, wrong tenant, or evicted from cache) | Re-issue `get_products` in `refine` mode with `action: 'finalize'` to obtain a current proposal_id | | `MULTI_FINALIZE_UNSUPPORTED` | `refine[]` carried multiple `action: 'finalize'` entries but the seller cannot guarantee atomic multi-proposal commit | Sequence single-proposal finalize calls (one finalize per `get_products` call) | +| `INVALID_STATE` | The buyer attempts to execute a proposal version that was previously declined | Re-discover or refine to obtain a current proposal version before creating the media buy | | `INVALID_REQUEST` | `refine` provided in `brief` or `wholesale` mode, empty `refine` array, or missing required fields | Check `buying_mode` and required fields | ### Troubleshooting: "must NOT have additional properties" on `refine[].id` @@ -295,14 +337,20 @@ The [Media Buy Specification](/docs/media-buy/specification#get_products) define **Orchestrators:** - MUST include `refine` when `buying_mode` is `"refine"` -- MUST NOT include `refine` when `buying_mode` is `"brief"` or `"wholesale"` +- MUST NOT include `refine` when `buying_mode` is `"brief"`, `"wholesale"`, or `"open"` - MUST provide `scope` and `product_id` for each product entry, and `scope` and `proposal_id` for each proposal entry - MAY omit `action` on product and proposal entries — sellers treat missing `action` as `"include"` - MUST NOT include multiple entries for the same product ID or proposal ID in a single `refine` array +- MUST include `proposal_version` and `reason` when sending proposal-scoped `action: "decline"` +- MUST NOT execute a declined `proposal_id` + `proposal_version` with `create_media_buy` +- MUST NOT send `action: "decline"` for an unversioned proposal **Sales agents:** - MUST omit products with `action: "omit"` from the response - MUST omit proposals with `action: "omit"` from the response +- MUST treat proposal-scoped `action: "decline"` as a terminal not-pursued outcome for the referenced `proposal_id` + `proposal_version` +- MUST return `refinement_applied` for requests containing proposal-scoped `action: "decline"`; `status: "applied"` is the signal that the decline is recorded +- MUST reject `create_media_buy` attempts against a declined proposal version with `INVALID_STATE` - MUST return products with `action: "include"`, with updated pricing - SHOULD fulfill the `ask` on product entries with `action: "include"` - SHOULD return additional products similar to those with `action: "more_like_this"`, plus the original product diff --git a/docs/media-buy/specification.mdx b/docs/media-buy/specification.mdx index eccd1b4b1f..2b25c7cac8 100644 --- a/docs/media-buy/specification.mdx +++ b/docs/media-buy/specification.mdx @@ -218,17 +218,24 @@ The Media Buy Protocol defines the following tasks. See task reference pages for Discover advertising inventory using natural language briefs or explicit wholesale intent. **Requirements:** -- Orchestrators MUST set `buying_mode` to `"brief"`, `"wholesale"`, or `"refine"` +- Orchestrators MUST set `buying_mode` to `"brief"`, `"wholesale"`, `"refine"`, or `"open"` - Orchestrators MUST include `brief` when `buying_mode` is `"brief"` -- Orchestrators MUST NOT include `brief` when `buying_mode` is `"wholesale"` or `"refine"` +- Orchestrators MUST NOT include `brief` when `buying_mode` is `"wholesale"`, `"refine"`, or `"open"` - Orchestrators MUST include `refine` when `buying_mode` is `"refine"` -- Orchestrators MUST NOT include `refine` when `buying_mode` is `"brief"` or `"wholesale"` +- Orchestrators MUST NOT include `refine` when `buying_mode` is `"brief"`, `"wholesale"`, or `"open"` - Orchestrators MUST provide `scope` and `product_id` for each product entry in `refine`, and `scope` and `proposal_id` for each proposal entry - Orchestrators MAY omit `action` on product and proposal entries (defaults to `"include"`) +- Orchestrators MUST include `proposal_version` and `reason` when a proposal entry uses `action: "decline"` +- Orchestrators MUST NOT send `action: "decline"` for an unversioned proposal +- Orchestrators MAY include `opportunity` on `brief`, `open`, and `refine` requests to identify or narrow a buyer planning cycle across related discovery, proposal outcome, and `create_media_buy` calls +- Orchestrators MUST treat `opportunity_id` as an opaque seller/account-scoped identifier and MUST NOT embed advertiser names, campaign names, audience names, budget references, competitor names, internal authorization IDs, personal data, or confidential campaign identifiers - Orchestrators MUST NOT include multiple entries for the same product ID or proposal ID in a single `refine` array - Sales agents MUST return products matching the brief criteria when a brief is provided - Sales agents MUST include `product_id` and `pricing_options` for each product - Sales agents SHOULD include relevance scoring when multiple products match +- Sales agents MUST handle `buying_mode: "open"` by returning unresolved products and proposals already associated with the caller/account, optionally narrowed by `opportunity.opportunity_id` +- Sales agents MUST NOT create new products, proposals, firm prices, inventory holds, or proposal versions solely to satisfy an `open` request +- Sales agents SHOULD complete `open` requests synchronously and MUST NOT route them through the async Submitted arm solely because `push_notification_config` is present **Refinement requirements:** @@ -237,14 +244,21 @@ Each `get_products` request with `buying_mode: "refine"` is self-contained — s - Sales agents MUST treat a missing `action` on product and proposal refine entries as `action: "include"` - Sales agents MUST omit products with `action: "omit"` from the response - Sales agents MUST omit proposals with `action: "omit"` from the response +- Sales agents MUST treat proposal-scoped `action: "decline"` as a terminal not-pursued outcome for the referenced `proposal_id` + `proposal_version` +- Sales agents MUST return `refinement_applied` for any request containing proposal-scoped `action: "decline"`; `status: "applied"` means the decline was recorded, while `status: "partial"` or `"unable"` means the buyer MUST NOT treat the proposal as declined +- Sales agents MUST treat duplicate declines for the same buyer, account, `proposal_id`, and `proposal_version` as idempotent and MUST NOT double-count feedback +- Orchestrators MUST NOT execute a declined `proposal_id` + `proposal_version` with `create_media_buy` - Sales agents MUST return products with `action: "include"`, with updated pricing - Sales agents SHOULD fulfill the `ask` on product entries with `action: "include"` - Sales agents SHOULD return additional products similar to those with `action: "more_like_this"`, plus the original product - Sales agents SHOULD consider request-level asks (`scope: "request"`) when composing the response — this MAY result in additional products beyond those explicitly referenced. Per-product actions take precedence over request-level direction. - Sales agents SHOULD fulfill the `ask` on proposal entries with `action: "include"` - Sales agents SHOULD include `refinement_applied` in the response, with one entry per change request matched by position -- Sales agents that return `refinement_applied` MUST echo `scope` on each entry and MUST echo `product_id` / `proposal_id` for product and proposal scopes, so orchestrators can cross-validate alignment +- Sales agents that return `refinement_applied` MUST echo `scope` on each entry and MUST echo `product_id` / `proposal_id` for product and proposal scopes, so orchestrators can cross-validate alignment. For proposal entries where the buyer supplied `proposal_version`, sales agents MUST echo `proposal_version`. - Sales agents MAY return proposals alongside products in refine mode, even when the orchestrator did not include proposal entries +- To close an opportunity without buying, orchestrators SHOULD send `buying_mode: "refine"` with the same `opportunity_id`, `opportunity.status: "closed"`, optional `close_reason` / `close_detail`, and a request-level refine entry such as "Close this opportunity; no further proposals requested." +- Sales agents that persist opportunity state SHOULD record buyer-reported opportunity closure, but opportunity closure has no dedicated protocol acknowledgement surface beyond the ordinary task response in this version +- Opportunity closure does not replace proposal decline; orchestrators that need acknowledgement for an evaluated proposal version MUST send proposal-scoped `action: "decline"` for that `proposal_id` + `proposal_version` ### list_creative_formats @@ -270,11 +284,17 @@ Create a media buy from selected packages or execute a proposal. **Requirements:** - Orchestrators MUST include either `packages` array or `proposal_id` - Orchestrators MUST include `start_time` and `end_time` for the campaign +- Orchestrators MUST include `proposal_version` when executing a proposal that carried `proposal_version` +- Orchestrators SHOULD carry the same `opportunity_id` into `create_media_buy` when the buy came from prior `get_products` calls +- Orchestrators MUST use `close_reason: "accepted_with_seller"` when `create_media_buy` also sends `opportunity.status: "closed"` with a `close_reason` - Sales agents MUST return `media_buy_id` on successful creation - Sales agents MUST return `confirmed_at` on successful creation. The value is the seller commitment timestamp, or `null` when the response creates a provisional buy that is not yet committed. - Sales agents MUST return `revision` on successful creation - Sales agents MUST return `creative_deadline` indicating when creatives must be uploaded - Sales agents MUST validate budget against pricing options +- Sales agents MUST reject attempts to execute a declined proposal version with `INVALID_STATE` +- Sales agents MUST reject `create_media_buy` requests that omit `proposal_version` when the referenced executable proposal carried a version with `INVALID_REQUEST` +- Sales agents MUST reject stale or unknown `proposal_version` values with `CONFLICT` when the proposal is known but no longer at that version, or `PROPOSAL_NOT_FOUND` when the proposal/version cannot be resolved for the caller - On validation failure, sales agents MUST return an `errors` array ### update_media_buy diff --git a/docs/media-buy/task-reference/create_media_buy.mdx b/docs/media-buy/task-reference/create_media_buy.mdx index 67d5e03797..bb3a4777cd 100644 --- a/docs/media-buy/task-reference/create_media_buy.mdx +++ b/docs/media-buy/task-reference/create_media_buy.mdx @@ -159,6 +159,8 @@ npx @adcp/sdk@latest \ |-----------|------|----------|-------------| | `account` | [account-ref](/docs/building/integration/accounts-and-agents#account-references) | Yes | Account reference. Pass `{ "account_id": "..." }` or `{ "brand": {...}, "operator": "..." }` if the seller supports implicit resolution. Required for billing and policy evaluation. | | `proposal_id` | string | No* | ID of a committed proposal from `get_products` to execute. Alternative to providing packages. Draft proposals must be finalized first; sellers reject draft proposal execution with `PROPOSAL_NOT_COMMITTED`. | +| `proposal_version` | string | Conditional | Opaque version from the proposal being executed. Required when the selected proposal carried `proposal_version`. Sellers reject execution of a previously declined proposal version with `INVALID_STATE`. Requires `proposal_id`. | +| `opportunity` | OpportunityContext | No | Buyer-supplied planning-cycle context. When the buy came from prior `get_products` calls, buyers SHOULD carry the same `opportunity_id` so the seller can connect discovery/refinement activity to the accepted media buy. If the create request also marks the opportunity closed, `close_reason` must be `accepted_with_seller`. | | `total_budget` | TotalBudget | No* | Total budget when executing a proposal. Publisher applies allocation percentages. | | `packages` | Package[] | No* | Array of package configurations (see below). Required when not using proposal_id. | | `brand` | BrandRef | Yes | Brand reference — resolved to full identity at execution time. See [brand.json](/docs/brand-protocol/brand-json) | @@ -175,6 +177,14 @@ npx @adcp/sdk@latest \ When executing a proposal, `proposal_status` on the returned proposal determines whether `create_media_buy` is valid. `committed` proposals can be executed before `expires_at`; `draft` proposals require a prior `get_products` refine call with `action: "finalize"`. Finalization is seller commitment to firm terms, not buyer acceptance. This `create_media_buy` call is the acceptance/execution step. +If the proposal carried `proposal_version`, buyers MUST include that version in `create_media_buy` to identify the exact offer state being accepted. Sellers MUST reject a proposal execution that omits `proposal_version` when the referenced executable proposal is versioned with `INVALID_REQUEST`. If the buyer previously declined the same `proposal_id` + `proposal_version` through `get_products` refine action `decline`, the seller MUST reject the create request with `INVALID_STATE`. + +When `proposal_version` is provided, sellers MUST verify it matches the executable offer state. If the proposal is known but no longer at that version, reject with `CONFLICT` so the buyer re-reads or refines before retrying. If the proposal/version cannot be resolved for the caller, reject with `PROPOSAL_NOT_FOUND`. + +If the buyer used `opportunity` during discovery or refinement, it SHOULD carry the same `opportunity_id` into `create_media_buy`. `opportunity_id` connects the accepted buy back to the planning cycle; it is not an idempotency key and does not change execution semantics. + +`create_media_buy` is the in-protocol close-won signal for an opportunity. If the buyer sends `opportunity.status: "closed"` on `create_media_buy`, the only valid `close_reason` is `accepted_with_seller`; loss-style reasons such as `not_pursued`, `selected_alternative`, or `purchased_elsewhere` belong on a `get_products` refine call that closes the opportunity without creating a buy. + ### TotalBudget Object | Parameter | Type | Required | Description | @@ -1022,7 +1032,11 @@ Common errors and resolutions: | `BUDGET_TOO_LOW` | Budget below product minimum | Increase budget or choose different product | | `TARGETING_TOO_NARROW` | Targeting yields zero inventory | Broaden geographic or audience criteria | | `POLICY_VIOLATION` | Brand/product violates policy | Review publisher's content policies | +| `INVALID_REQUEST` | The buyer omitted `proposal_version` while executing a proposal that carried a version | Include the proposal's current `proposal_version`, or re-read/refine the proposal before retrying | | `INVALID_PRICING_OPTION` | pricing_option_id not found | Use ID from product's `pricing_options` | +| `INVALID_STATE` | The referenced proposal version was previously declined through `get_products` refine action `decline` | Re-discover or refine to obtain a current proposal version before creating the media buy | +| `CONFLICT` | The supplied `proposal_version` is stale for a known proposal | Re-read or refine the proposal and retry with the current version | +| `PROPOSAL_NOT_FOUND` | The referenced `proposal_id` or `proposal_version` cannot be resolved for the caller | Re-discover or finalize the proposal again | | `CREATIVE_ID_EXISTS` | Creative ID already exists in the seller's creative namespace | For library-backed sellers, assign existing creatives via `creative_assignments` or update via `sync_creatives`; for inline-only sellers, use a different package-scoped `creative_id` | Example error response: diff --git a/docs/media-buy/task-reference/get_products.mdx b/docs/media-buy/task-reference/get_products.mdx index ca0732f5bf..fc18f53952 100644 --- a/docs/media-buy/task-reference/get_products.mdx +++ b/docs/media-buy/task-reference/get_products.mdx @@ -131,12 +131,13 @@ asyncio.run(discover_with_filters()) | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| `buying_mode` | string | Yes | `"brief"`, `"wholesale"`, or `"refine"`. `"brief"`: publisher curates products from the brief. `"wholesale"`: raw product feed access for buyer-directed targeting, `brief` must not be provided. `"refine"`: iterate on products and proposals from a previous response using the `refine` array of change requests. v3 clients MUST include `buying_mode`. Sellers receiving requests from pre-v3 clients without `buying_mode` SHOULD default to `"brief"`. **Timing semantics:** `"wholesale"` is a wholesale product feed read — sellers SHOULD return a synchronous response and MUST NOT route a `"wholesale"` request through the async/Submitted arm. Partial completion is signalled via [`incomplete[]`](#incomplete-array), not a task handoff. `"brief"` and `"refine"` MAY complete synchronously OR MAY return a `Submitted` envelope when curation requires upstream-system queries or HITL review the seller cannot complete inside `time_budget`. Buyers needing predictable fast wholesale product feed access MUST use `"wholesale"`. | -| `brief` | string | Conditional | Natural language description of campaign requirements. Required when `buying_mode` is `"brief"`. Must not be provided when `buying_mode` is `"wholesale"` or `"refine"`. | -| `refine` | [Refine[]](#refine-array) | Conditional | Array of change requests for iterating on products and proposals. Required when `buying_mode` is `"refine"`. Must not be provided when `buying_mode` is `"brief"` or `"wholesale"`. See [Refine array](#refine-array) below. | +| `buying_mode` | string | Yes | `"brief"`, `"wholesale"`, `"refine"`, or `"open"`. `"brief"`: publisher curates products from the brief. `"wholesale"`: raw product feed access for buyer-directed targeting, `brief` must not be provided. `"refine"`: iterate on products and proposals from a previous response using the `refine` array of change requests. `"open"`: retrieve unresolved products/proposals already associated with the caller/account, optionally narrowed by `opportunity.opportunity_id`. v3 clients MUST include `buying_mode`. Sellers receiving requests from pre-v3 clients without `buying_mode` SHOULD default to `"brief"`. **Timing semantics:** `"wholesale"` is a wholesale product feed read — sellers SHOULD return a synchronous response and MUST NOT route a `"wholesale"` request through the async/Submitted arm. Partial completion is signalled via [`incomplete[]`](#incomplete-array), not a task handoff. `"open"` is a state read and SHOULD complete synchronously. `"brief"` and `"refine"` MAY complete synchronously OR MAY return a `Submitted` envelope when curation requires upstream-system queries or HITL review the seller cannot complete inside `time_budget`. Buyers needing predictable fast wholesale product feed access MUST use `"wholesale"`. | +| `brief` | string | Conditional | Natural language description of campaign requirements. Required when `buying_mode` is `"brief"`. Must not be provided when `buying_mode` is `"wholesale"`, `"refine"`, or `"open"`. | +| `refine` | [Refine[]](#refine-array) | Conditional | Array of change requests for iterating on products and proposals. Required when `buying_mode` is `"refine"`. Must not be provided when `buying_mode` is `"brief"`, `"wholesale"`, or `"open"`. See [Refine array](#refine-array) below. | | `brand` | BrandRef | No | Brand reference (domain + optional brand_id). Resolved to full identity at execution time. | | `account` | AccountRef | No | Account reference for account-specific pricing. Returns products with pricing from this account's rate card. | | `catalog` | [Catalog](/docs/creative/catalogs) | No | Catalog of items the buyer wants to promote. The seller matches catalog items against its inventory and returns products where matches exist. Requires `brand`. See [Catalog discovery](#catalog-discovery) below. | +| `opportunity` | OpportunityContext | No | Buyer-supplied planning-cycle context for connecting related briefs, open-state reads, refinements, proposals, declines, and buys. Meaningful for `"brief"`, `"open"`, and `"refine"` requests; sellers ignore it for `"wholesale"` feed reads. In `"open"` mode, `opportunity_id` narrows the unresolved products/proposals returned. See [Opportunity context](#opportunity-context) below. | | `filters` | Filters | No | Structured filters (see below) | | `fields` | string[] | No | Specific product fields to include in the response for lightweight discovery. When requesting signal metadata, buyers SHOULD request `included_signals` for non-selectable bundled/planned signals, and `signal_targeting_allowed`, `signal_targeting_options`, and `signal_targeting_rules` for package-level signal selection. | | `property_list` | PropertyListRef | No | [AdCP 3.0] Reference to a property list for filtering. See [Property Lists](/docs/governance/property/tasks/property_lists) | @@ -144,7 +145,7 @@ asyncio.run(discover_with_filters()) | `if_wholesale_feed_version` | string | No | Opaque `wholesale_feed_version` token from a prior `get_products` response. When provided, the seller compares against its current wholesale product feed version and MAY return `unchanged: true` (with `products` omitted) if nothing has changed. Versions are scoped to the (agent, account, filters, `buying_mode`, `property_list`, `catalog`) tuple. See [Wholesale feed versioning](#wholesale-feed-versioning). | | `if_pricing_version` | string | No | Opaque `pricing_version` token from a prior response. MUST only be sent together with `if_wholesale_feed_version`. Evaluation order: `if_wholesale_feed_version` mismatch → full payload; `if_wholesale_feed_version` matches but `if_pricing_version` mismatches → full payload (so the buyer sees updated `pricing_options`); both match → seller MAY return `unchanged: true`. Sellers that don't track pricing separately ignore this. | | `time_budget` | Duration | No | Maximum time the buyer will commit to this request. The seller returns the best results achievable within this budget and does not start processes (human approvals, expensive external queries) that cannot complete in time. When omitted, the seller decides timing. Example: `{"interval": 30, "unit": "seconds"}`. | -| `push_notification_config` | PushNotificationConfig | No | Optional webhook channel for async terminal completion/failure notifications on `brief` / `refine` curated discovery. `submitted` responses with a `task_id` remain pollable through `get_task_status` (legacy `tasks/get`) whether or not this field is present. If the request includes this field and the seller returns `submitted`, the seller MUST deliver at least the terminal completion/failure notification to the configured webhook; intermediate progress notifications are MAY. If the seller cannot honor the webhook channel, it MUST reject the request with a structured error instead of silently accepting. Ignored for `wholesale`; sellers MUST NOT route wholesale reads through the Submitted arm because this field is present. | +| `push_notification_config` | PushNotificationConfig | No | Optional webhook channel for async terminal completion/failure notifications on `brief` / `refine` curated discovery. `submitted` responses with a `task_id` remain pollable through `get_task_status` (legacy `tasks/get`) whether or not this field is present. If the request includes this field and the seller returns `submitted`, the seller MUST deliver at least the terminal completion/failure notification to the configured webhook; intermediate progress notifications are MAY. If the seller cannot honor the webhook channel, it MUST reject the request with a structured error instead of silently accepting. Ignored for `wholesale` and `open`; sellers MUST NOT route those reads through the Submitted arm because this field is present. | **Property Governance** @@ -159,6 +160,73 @@ To use property list filtering: The seller must declare `features.property_list_filtering: true` in [`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities) to support this filter. See the [Property Governance overview](/docs/governance/property/index) for the full workflow. +### Opportunity context + +`opportunity` lets a buyer identify the planning cycle behind a request. A buyer might run several experimental briefs for the same Nova Soda Q3 planning cycle, refine multiple proposal variants, decline individual proposal versions, and eventually execute a media buy. The same `opportunity_id` connects those calls without turning any one call into a commitment. + +Use `opportunity` for curated discovery (`buying_mode: "brief"`), unresolved-state retrieval (`buying_mode: "open"`), and proposal iteration (`buying_mode: "refine"`). Sellers ignore it for `buying_mode: "wholesale"` because wholesale requests are feed reads, not curated pre-buy interactions. + +```json +{ + "$schema": "/schemas/media-buy/get-products-request.json", + "buying_mode": "brief", + "brief": "Nova Soda Q3 campaign ideas for Gen Z summer moments", + "brand": { + "domain": "novasoda.example" + }, + "opportunity": { + "opportunity_id": "opp_01HX7W8S3Q9K2M4N6P0R5T1V", + "phase": "exploratory", + "planning_horizon": { + "start": "2026-07-01", + "end": "2026-09-30" + }, + "response_deadline": "2026-06-30T21:00:00Z" + } +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `opportunity_id` | string | Yes | Opaque buyer-assigned identifier for this planning cycle with this seller. Stable across related `get_products`, proposal decline, and `create_media_buy` calls. Do not embed advertiser names, campaign names, audience names, budget references, competitor names, internal authorization IDs, globally correlatable values, personal data, or confidential campaign identifiers. | +| `phase` | string | No | `"exploratory"`: early market scan or option gathering. `"planning"`: a concrete opportunity is being planned, but the buyer is not yet actively requesting seller proposals. `"active_sourcing"`: the buyer is actively requesting seller responses or proposals, but has not requested proposal finalization, an inventory hold, or a committed media buy. | +| `status` | string | No | `"open"` or `"closed"`. Use `"closed"` when the buyer is no longer pursuing this planning cycle with the seller. | +| `close_reason` | string | Conditional | Optional when `status` is `"closed"`: `accepted_with_seller`, `purchased_elsewhere`, `selected_alternative`, `not_pursued`, `budget_changed`, `timing_changed`, or `other`. Not valid unless `status` is `"closed"`. | +| `close_detail` | string | Conditional | Optional short, non-identifying explanation when `status` is `"closed"`. Not valid unless `status` is `"closed"`. | +| `planning_horizon` | DateRange | No | Advisory planning or campaign window for the opportunity, using inclusive ISO 8601 `start` and `end` dates (`YYYY-MM-DD`). | +| `response_deadline` | string | No | ISO 8601 date-time by which the buyer would like a seller response or proposal update. | + +`opportunity_id` is not an idempotency key. It spans many calls. `idempotency_key` identifies one mutating request attempt. `proposal_id` + `proposal_version` identifies one seller offer state. Opportunity closure is broader than proposal decline: `opportunity.status: "closed"` says the planning cycle is done, while proposal-scoped `action: "decline"` says the buyer is not pursuing one exact proposal version. + +To close an opportunity without buying, send `buying_mode: "refine"` with the same `opportunity_id`, `opportunity.status: "closed"`, an optional `close_reason` / `close_detail`, and a request-level refine entry such as `{ "scope": "request", "ask": "Close this opportunity; no further proposals requested." }`. Sellers that persist opportunity state SHOULD record this buyer-reported closure. This does not replace proposal-level acknowledgement: decline each evaluated proposal version separately when the buyer needs the seller to acknowledge that exact proposal outcome. + +When closing because the buyer accepted this seller's offer, carry the same `opportunity_id` into `create_media_buy`. If that create request marks the opportunity closed, `close_reason` MUST be `accepted_with_seller`. + +Sellers MAY use `opportunity` for queueing, SLA expectations, async routing, human-review prioritization, CRM or pipeline labels, and aggregate win-rate analytics. Sellers MUST NOT use `opportunity` to alter price, rate card, product eligibility, inventory availability, or buyability. Opportunity closure is buyer-reported advisory state; it has no dedicated protocol acknowledgement surface beyond the ordinary task response in this version. `response_deadline` does not require or authorize firm pricing, inventory availability confirmation, inventory hold, proposal finalization, or buyer/seller commitment. + +If `opportunity` is absent, sellers MUST treat the opportunity context as unknown. Absence MUST NOT reduce response quality, product availability, or pricing fairness. + +### Open mode + +Use `buying_mode: "open"` to retrieve unresolved products and proposals the seller already associates with the caller/account. This is a read of existing state, not a new discovery or refinement request: sellers MUST NOT create new products, proposals, firm prices, inventory holds, or proposal versions solely to satisfy an open request. + +```json +{ + "$schema": "/schemas/media-buy/get-products-request.json", + "buying_mode": "open", + "account": { + "account_id": "acct_123" + }, + "opportunity": { + "opportunity_id": "opp_01HX7W8S3Q9K2M4N6P0R5T1V" + } +} +``` + +When `opportunity.opportunity_id` is present, the seller narrows the returned unresolved `products` and `proposals` to that planning cycle. When it is omitted, the seller MAY return unresolved products/proposals for the account that it can safely associate with the caller. Buyers SHOULD include `account` unless authentication unambiguously scopes the request to a single account. + +The response reuses the existing `products` and `proposals` arrays. There is no separate grouping object in this version. If no unresolved products or proposals match, the seller returns `products: []`, the required `cache_scope`, and `proposals: []` when it emits the optional proposal array. + ### Filters Object | Parameter | Type | Description | @@ -300,8 +368,11 @@ Each entry is a discriminated union on `scope`: |-------|------|----------|-------------| | `scope` | string | Yes | `"proposal"` | | `proposal_id` | string | Yes | Proposal ID from a previous `get_products` response | -| `action` | string | No | `"include"` (default): return with updated allocations and pricing. `"omit"`: exclude from the response. `"finalize"`: request firm pricing and inventory hold (transitions a draft proposal to committed). When omitted, the seller treats the entry as `"include"`. | -| `ask` | string | No | What the buyer is asking for (e.g., `"shift more budget toward video"`, `"reduce total by 10%"`). Ignored when `action` is `"omit"`. | +| `proposal_version` | string | Conditional | Opaque version from the proposal being acted on. Required when `action` is `"decline"`. Buyers SHOULD include it for other proposal actions when the proposal carried `proposal_version`. | +| `action` | string | No | `"include"` (default): return with updated allocations and pricing. `"omit"`: exclude from the response without marking a terminal outcome. `"finalize"`: request firm pricing and inventory hold (transitions a draft proposal to committed). `"decline"`: terminal not-pursued outcome for this `proposal_id` + `proposal_version`; requires `reason`. When omitted, the seller treats the entry as `"include"`. | +| `reason` | string | Conditional | Machine-readable reason for `action: "decline"`: `price`, `inventory_fit`, `audience_fit`, `creative_unsupported`, `measurement_unsupported`, `policy`, `timing`, `budget_changed`, `selected_alternative`, or `other`. Required for decline. | +| `detail` | string | Conditional | Optional short, non-identifying explanation for `action: "decline"`. Required when `reason` is `"other"`. Must not include competitor identity or sensitive campaign information. | +| `ask` | string | No | What the buyer is asking for (e.g., `"shift more budget toward video"`, `"reduce total by 10%"`). Ignored when `action` is `"omit"` or `"decline"`. | ### refinement_applied (response) @@ -312,6 +383,7 @@ When the seller receives a `refine` array, the response includes `refinement_app | `scope` | string | Yes | Echoes the scope (`"request"` / `"product"` / `"proposal"`) from the corresponding `refine` entry. | | `product_id` | string | Yes when `scope` is `"product"` | Echoes `product_id` from the corresponding refine entry. | | `proposal_id` | string | Yes when `scope` is `"proposal"` | Echoes `proposal_id` from the corresponding refine entry. | +| `proposal_version` | string | Conditional | Echoes `proposal_version` from the corresponding proposal refine entry when supplied. Required when acknowledging `action: "decline"`. | | `status` | string | Yes | `"applied"`: ask fulfilled. `"partial"`: partially fulfilled. `"unable"`: could not fulfill. | | `notes` | string | No | Seller explanation. Recommended when status is `"partial"` or `"unable"`. | @@ -392,6 +464,7 @@ Publishers may return proposals alongside products - structured media plans with | Field | Type | Description | |-------|------|-------------| | `proposal_id` | string | Unique identifier for finalizing this proposal and, once committed, executing it via `create_media_buy` | +| `proposal_version` | string | Opaque seller-assigned version for the proposal's current offer state. Changes when execution-relevant terms change. Buyers include this when declining a proposal and MUST include it when executing a versioned proposal. | | `proposal_status` | string | Lifecycle state. `draft` means the proposal must be finalized via `get_products` refine action `finalize` before create. `committed` means the proposal can be executed via `create_media_buy` before `expires_at`. When absent, treat the proposal as ready to buy for backward compatibility. | | `name` | string | Human-readable name for the media plan | | `allocations` | ProductAllocation[] | Budget allocations across products (percentages must sum to 100). Each allocation may include optional `start_time` and `end_time` for per-flight scheduling. | @@ -425,7 +498,7 @@ Pagination is optional. When omitted, the server returns all results (or a serve |-------|------|-------------| | `property_list_applied` | boolean | [AdCP 3.0] `true` if the agent filtered products based on the provided `property_list`. Absent or `false` if not provided or not supported. | | `catalog_applied` | boolean | `true` if the seller filtered results based on the provided `catalog`. Absent or `false` if no catalog was provided or the seller does not support catalog matching. | -| `refinement_applied` | [RefinementResult[]](#refinement_applied-response) | Seller acknowledgment of each `refine` entry, matched by position. Only present when `buying_mode` is `"refine"`. See [refinement_applied](#refinement_applied-response) above. | +| `refinement_applied` | [RefinementResult[]](#refinement_applied-response) | Seller acknowledgment of each `refine` entry, matched by position. Only present when `buying_mode` is `"refine"`; never present for `"open"` mode. See [refinement_applied](#refinement_applied-response) above. | | `incomplete` | [IncompleteEntry[]](#incomplete-array) | Declares what the seller could not finish within the `time_budget` or due to internal limits. Each entry identifies a scope with a human-readable explanation. Absent when the response is fully complete. See [incomplete array](#incomplete-array) below. | | `filter_diagnostics` | object | Optional non-fatal observability block describing how `filters` narrowed the candidate set — `total_candidates` plus per-filter `excluded_by` counts (keyed by filter name). Disambiguates "no inventory" from "your filter excluded everything" when the result list is empty or unexpectedly small. Counts only — never product names — to avoid leaking competitive intelligence. See [filter_diagnostics](#filter_diagnostics) below. | | `wholesale_feed_version` | string | Opaque token representing the version of the wholesale product feed state used to compose this response. Sellers implementing conditional-fetch (`if_wholesale_feed_version`) MUST return this on every response so buyers can cache and probe later. Treat as opaque — no format, no ordering, no inspection. See [Wholesale feed versioning](#wholesale-feed-versioning). | @@ -1181,10 +1254,33 @@ Minimal example: } ``` +Decline example: + +```json test=false +{ + "buying_mode": "refine", + "refine": [ + { + "scope": "proposal", + "proposal_id": "prop_awareness_q2", + "proposal_version": "4", + "action": "decline", + "reason": "price", + "detail": "Above target CPM range" + } + ] +} +``` + Key rules to know before sending: -- **`refine` is only valid in `refine` mode.** Requests that include this field in `brief` or `wholesale` mode are rejected with `INVALID_REQUEST`. +- **`open` reloads unresolved state.** `buying_mode: "open"` returns unresolved products/proposals already associated with the caller/account/opportunity. It does not create new products or proposals, and it reuses the existing `products` and `proposals` arrays. +- **`refine` is only valid in `refine` mode.** Requests that include this field in `brief`, `wholesale`, or `open` mode are rejected with `INVALID_REQUEST`. - **Filters are absolute**, not deltas. Always send the full filter set you want applied. +- **`omit` is not terminal.** It excludes a proposal from the next response but does not report a final evaluation outcome. +- **`decline` is terminal for a proposal version.** Proposal-scoped `action: "decline"` requires `proposal_version` and `reason`. After a seller acknowledges it with `refinement_applied[].status: "applied"`, the buyer MUST NOT execute the same `proposal_id` + `proposal_version` with `create_media_buy`; sellers reject such attempts with `INVALID_STATE`. +- **Unversioned proposals cannot be declined.** Sellers that support proposal decline MUST include `proposal_version` on proposals they expect buyers to decline. Buyers MUST NOT send `action: "decline"` for unversioned proposals; use `omit`, ignore the proposal, or re-run discovery/refinement to obtain a versioned proposal. +- **Keep decline feedback non-identifying.** Use `reason` for aggregate seller feedback. Keep `detail` short and do not include competing seller/platform/product names, clearing prices, bid strategy, confidential audience definitions, budget reallocations, campaign IDs, or NDA-scoped terms. - **Proposals are actionable through status.** `proposal_status: "draft"` requires finalization before create; `proposal_status: "committed"` can be executed with `create_media_buy(proposal_id)` before `expires_at`; absent status is legacy ready-to-buy. - **Proposals are ephemeral.** Proposals typically include an `expires_at` timestamp. After expiration, the seller returns `PROPOSAL_EXPIRED`. - **Product IDs are stable catalog identifiers.** Custom products (`is_custom: true`) may have an `expires_at` timestamp, after which refinement returns `PRODUCT_NOT_FOUND`. @@ -1200,6 +1296,7 @@ Key rules to know before sending: | `PROPOSAL_EXPIRED` | A referenced proposal ID has passed its `expires_at` timestamp | Re-discover with a new `brief` or `wholesale` request | | `PROPOSAL_NOT_FOUND` | The referenced `proposal_id` is unknown to the seller (never finalized, wrong tenant, or evicted from cache) | Re-issue `get_products` in `refine` mode with `action: 'finalize'` to obtain a current proposal_id | | `MULTI_FINALIZE_UNSUPPORTED` | `refine[]` carried multiple `action: 'finalize'` entries but the seller cannot guarantee atomic multi-proposal commit | Sequence single-proposal finalize calls — one finalize entry per `get_products` call | +| `INVALID_STATE` | A buyer attempts to execute a proposal version previously declined with `action: "decline"` | Re-discover or refine to obtain a current proposal version | | `POLICY_VIOLATION` | Category blocked for advertiser | See policy response message for details | ### Authentication Comparison diff --git a/docs/reference/migration/index.mdx b/docs/reference/migration/index.mdx index 6a8d86b72e..2f7d6f9ec5 100644 --- a/docs/reference/migration/index.mdx +++ b/docs/reference/migration/index.mdx @@ -62,7 +62,7 @@ Each row is a breaking change. **Effort** indicates the typical work involved: | Regulatory invariants | GDPR Art 22 / EU AI Act Annex III enforced via schema `if/then` | New requirement | [Governance § Annex III obligations](/docs/governance/annex-iii-obligations) | -`buying_mode` on `get_products` is checked by storyboard testing. Sellers must handle all three buying modes (`browse`, `brief`, `refine`). See the [v3 readiness checklist](/docs/reference/migration/v3-readiness) for details. +`buying_mode` on `get_products` is checked by storyboard testing. Sellers must handle the `brief`, `wholesale`, `refine`, and `open` buying modes. See the [v3 readiness checklist](/docs/reference/migration/v3-readiness) for details. ## New in v3 — required vs optional diff --git a/docs/reference/migration/v3-readiness.mdx b/docs/reference/migration/v3-readiness.mdx index 09400368db..669b84500a 100644 --- a/docs/reference/migration/v3-readiness.mdx +++ b/docs/reference/migration/v3-readiness.mdx @@ -107,7 +107,7 @@ Buyers pass brand identity as a reference (`{ domain, brand_id }`) instead of an ## 6. Handle `buying_mode` on `get_products` -`buying_mode` is now required on every `get_products` request. Your agent must accept and handle it. The three modes are `browse`, `brief`, and `refine`. +`buying_mode` is now required on every `get_products` request. Your agent must accept and handle it. The modes are `brief`, `wholesale`, `refine`, and `open`. Full request schema including buying_mode. diff --git a/docs/reference/release-notes.mdx b/docs/reference/release-notes.mdx index 19028539e8..3cf8703134 100644 --- a/docs/reference/release-notes.mdx +++ b/docs/reference/release-notes.mdx @@ -96,7 +96,7 @@ Product signal targeting also clarifies the broad-feed workflow. Products may se **Brand designated-task response signing.** `verify_brand_claim` and `verify_brand_claims` success responses now require `signed_response`, a payload-envelope JWS over the canonical task-body response. The envelope binds the response to the designated task, resolved `brand_domain`, responding `agent_url`, caller/request hash, and `iat`/`exp` freshness window. Verifiers resolve the `kid` to a published JWK with `adcp_use: "response-signing"` and reject mismatches between the unsigned response fields and `signed_response.payload.response`. This is ordinary JWS over the JCS-canonicalized payload, not general RFC 9421 transport response signing. See [Brand Protocol § verify_brand_claim](/docs/brand-protocol/tasks/verify_brand_claim#trust-model) and [Security § Designated-task response signing](/docs/building/by-layer/L1/security#designated-task-response-signing). PR [#5192](https://github.com/adcontextprotocol/adcp/pull/5192). -**Proposal lifecycle and action-mode cleanup.** `proposal_status` is the per-proposal source of truth: `draft` requires `get_products` refine/finalize before create, `committed` can be executed with `create_media_buy(proposal_id)` before `expires_at`, and absent status is the legacy ready-to-buy path. `finalize` is seller commitment to firm pricing, terms, and hold; `create_media_buy(proposal_id)` is buyer acceptance/execution. `supports_proposals` remains a conformance grading declaration, not buyer routing logic for an individual proposal. The pre-GA `requires_proposal` action mode is removed; sellers return `REQUOTE_REQUIRED` when an `update_media_buy` request exceeds the current quoted envelope. Buyers with cached RC metadata containing `requires_proposal` treat it as unknown: proposal executability must be re-read from `Proposal.proposal_status`, product action templates from `allowed_actions[]`, and buy runtime action state from `available_actions[]`. 3.1 does not define an amendment-quote artifact for `update_media_buy`. +**Proposal lifecycle and action-mode cleanup.** `proposal_status` is the per-proposal source of truth: `draft` requires `get_products` refine/finalize before create, `committed` can be executed with `create_media_buy(proposal_id)` before `expires_at`, and absent status is the legacy ready-to-buy path. `finalize` is seller commitment to firm pricing, terms, and hold; `create_media_buy(proposal_id)` is buyer acceptance/execution. `get_products buying_mode: "open"` reloads unresolved products/proposals already associated with the caller/account/opportunity without creating a new task or response grouping object. `supports_proposals` remains a conformance grading declaration, not buyer routing logic for an individual proposal. The pre-GA `requires_proposal` action mode is removed; sellers return `REQUOTE_REQUIRED` when an `update_media_buy` request exceeds the current quoted envelope. Buyers with cached RC metadata containing `requires_proposal` treat it as unknown: proposal executability must be re-read from `Proposal.proposal_status`, product action templates from `allowed_actions[]`, and buy runtime action state from `available_actions[]`. 3.1 does not define an amendment-quote artifact for `update_media_buy`. **Measurement catalog remains experimental.** The `measurement` capability block is marked `x-status: experimental`. Agents implementing metric-catalog discovery declare `measurement.core` in `experimental_features`; buyers should treat measurement vendor catalog discovery as experimental while the task surface and stable compliance baseline remain unfrozen. diff --git a/docs/reference/whats-new-in-3-1.mdx b/docs/reference/whats-new-in-3-1.mdx index 6b1b858cfc..fb1d6548a7 100644 --- a/docs/reference/whats-new-in-3-1.mdx +++ b/docs/reference/whats-new-in-3-1.mdx @@ -73,7 +73,7 @@ Plus a long tail of error-code clarity, auth tightening, idempotency rules, TMP | **Creative transformers** | `build_creative` built to a target format; render knobs implicit; format-attached `Format.input_format_ids` / `output_format_ids` / `pricing_options` | `list_transformers` discovers account-scoped build units (voices/models/styles) with an `expand_params` option-enumeration mode; `build_creative` selects one via `transformer_id`, takes a strictly-validated typed `config`, and fans out with `max_creatives` (per-catalog-item) + `max_variants`/`variant_axis` + advisory `keep_mode`; new `BuildCreativeVariantSuccess` member returns per-variant manifests, recommendation/`rank`, and per-leaf pricing receipts; rate lives on `transformer.pricing_options` (`per_unit`), settled via `report_usage`. The shipped `BuildCreativeSuccess` / `BuildCreativeMultiSuccess` are unchanged | | **Version negotiation** | Integer `adcp_major_version` per request | Release-precision `adcp_version` (e.g. `"3.1"` at GA or exact prereleases like `"3.1-rc.14"`) + `adcp.supported_versions` advertisement + envelope echo. Integer field remains as backwards-compatible legacy | | **Optimization goals** | `event` + `metric` kinds, vendor-agnostic | New `vendor_metric` kind — bind goals to vendor-attested metrics; `vendor_metric_optimization` per-product capability; three-precondition rejection rule | -| **Capability declarations** | Per-protocol basics | New: `media_buy.buying_modes` for wholesale products, `signals.discovery_modes` for wholesale signals, `wholesale_feed_versioning`, `wholesale_feed_webhooks`, `supported_optimization_metrics`, `supported_target_kinds`, `media_buy.frequency_capping`, `media_buy.propagation_surfaces`, `creative.bills_through_adcp`, `capabilities.idempotency.in_flight_max_seconds` | +| **Capability declarations** | Per-protocol basics | New: `media_buy.buying_modes` for the current product discovery modes including `open` and `wholesale`, `signals.discovery_modes` for wholesale signals, `wholesale_feed_versioning`, `wholesale_feed_webhooks`, `supported_optimization_metrics`, `supported_target_kinds`, `media_buy.frequency_capping`, `media_buy.propagation_surfaces`, `creative.bills_through_adcp`, `capabilities.idempotency.in_flight_max_seconds` | | **Video and audio execution discovery** | Video and audio products relied on free-text descriptions, placement names, or overloaded channels | `video_placement_types` and `audio_distribution_types` on products, placements, and `get_products.filters` using AdCP-native names for OpenRTB `video.plcmt` and `audio.feed` values | | **Currency-scoped discovery** | Buyers could filter budget currency but not the currencies they can transact in for media product pricing | `pricing_currencies` on `get_products.filters`; sellers match product-level `pricing_options`, prune returned product pricing options to requested currencies, and exclude products whose mandatory product-scoped signal charges are not satisfiable in those currencies | | **Delivery reporting** | `reach` without window semantics; viewability is rate only | `reach_window` (cumulative / period / rolling); `viewability.viewed_seconds`; windowed pull recovery via `time_granularity` + `include_window_breakdown` | @@ -146,7 +146,7 @@ Terminology: this section uses **wholesale feed** for seller-side products and s **Security posture is honest.** The advisory-payload framing makes explicit that re-verifying a feed event against `get_products` / `get_signals` defends against transport tampering only — a compromised agent operator re-confirms its own lie. Operator-compromise defense lives in the existing trust anchors that gate spend (signed `create_media_buy` response, `adagents.json` pinned signing keys for marketplace-signal provenance), with content-signing of feed events deferred to the 4.0 R-1 root-of-trust track. Treat events as cheap mirror invalidation, not as the basis for any decision that commits dollars or authority. -Capability declarations: `wholesale_feed_versioning` (conditional fetch + `pricing_version_separate` + `cache_scope_account`), `wholesale_feed_webhooks` (webhook change payloads), `media_buy.buying_modes` and `signals.discovery_modes` (wholesale support). Agents that advertise `product.*` webhook events must also advertise wholesale `get_products`; agents that advertise `signal.*` events must also advertise wholesale `get_signals`; `wholesale_feed.bulk_change` must name only a feed family backed by one of those repair paths. JSON Schema for the webhook envelope at `core/wholesale-feed-webhook.json`, wrapping `core/wholesale-feed-event.json` (discriminated on event_type with 9 branches + `appliesTo` / `removalReason` `$defs`). +Capability declarations: `wholesale_feed_versioning` (conditional fetch + `pricing_version_separate` + `cache_scope_account`), `wholesale_feed_webhooks` (webhook change payloads), `media_buy.buying_modes` (current product discovery modes including `open` and `wholesale`) and `signals.discovery_modes` (wholesale support). Agents that advertise `product.*` webhook events must also advertise wholesale `get_products`; agents that advertise `signal.*` events must also advertise wholesale `get_signals`; `wholesale_feed.bulk_change` must name only a feed family backed by one of those repair paths. JSON Schema for the webhook envelope at `core/wholesale-feed-webhook.json`, wrapping `core/wholesale-feed-event.json` (discriminated on event_type with 9 branches + `appliesTo` / `removalReason` `$defs`). → Spec: [Wholesale feed webhooks](https://github.com/adcontextprotocol/adcp/blob/main/specs/wholesale-feed-webhooks.md) · [`get_products` § Wholesale feed versioning](/docs/media-buy/task-reference/get_products#wholesale-feed-versioning) · [`get_products` § Cache layering](/docs/media-buy/task-reference/get_products#cache-layering) · [`get_signals` § Wholesale signals feed](/docs/signals/tasks/get_signals#wholesale-signals-feed) · PRs [#4761](https://github.com/adcontextprotocol/adcp/pull/4761) (conditional fetch), [#4762](https://github.com/adcontextprotocol/adcp/pull/4762) (wholesale signals), [#4763](https://github.com/adcontextprotocol/adcp/pull/4763) (feed webhooks), [#4767](https://github.com/adcontextprotocol/adcp/pull/4767) (cluster implementation) diff --git a/static/compliance/source/protocols/media-buy/scenarios/proposal_decline.yaml b/static/compliance/source/protocols/media-buy/scenarios/proposal_decline.yaml new file mode 100644 index 0000000000..2022f6efed --- /dev/null +++ b/static/compliance/source/protocols/media-buy/scenarios/proposal_decline.yaml @@ -0,0 +1,274 @@ +id: media_buy_seller/proposal_decline +version: "1.0.0" +title: "Seller handles proposal decline" +category: media_buy_seller +summary: "Validates proposal decline acknowledgement, duplicate-decline idempotency, and create_media_buy rejection for a declined proposal version." +track: media_buy +required_tools: + - get_products + - create_media_buy + +requires_capability: + path: media_buy.supports_proposals + equals: true + +narrative: | + Proposal versions can resolve negatively as well as positively. A buyer that + evaluates a proposal and decides not to pursue it reports that outcome with + get_products in refine mode using proposal-scoped action: decline. The seller + acknowledges the exact proposal_id + proposal_version in refinement_applied, + treats duplicate declines as idempotent, and rejects any later attempt to + execute that declined proposal version with INVALID_STATE. + +agent: + interaction_model: media_buy_seller + capabilities: + - sells_media + - accepts_briefs + - generates_proposals + examples: + - "Full-service publisher with proposal engine" + - "Retail media network with curated packages" + +caller: + role: buyer_agent + example: "Pinnacle Agency (buyer)" + +prerequisites: + description: | + The seller must support proposals (media_buy.supports_proposals: true) and + include proposal_version on proposals it expects buyers to resolve explicitly. + test_kit: "test-kits/acme-outdoor.yaml" + +phases: + - id: setup + title: "Account setup" + steps: + - id: sync_accounts + title: "Establish account" + task: sync_accounts + schema_ref: "account/sync-accounts-request.json" + response_schema_ref: "account/sync-accounts-response.json" + doc_ref: "/accounts/tasks/sync_accounts" + stateful: true + expected: | + Return the account with account_id and status active. + + sample_request: + accounts: + - brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + billing: "operator" + payment_terms: "net_30" + + idempotency_key: "$generate:uuid_v4#media_buy_seller_proposal_decline_setup_sync_accounts" + validations: + - check: response_schema + description: "Response matches sync-accounts-response.json schema" + - check: field_present + path: "accounts[0].account_id" + description: "Account has a platform-assigned ID" + + - id: brief_with_versioned_proposal + title: "Brief returns a versioned proposal" + narrative: | + Send a brief and capture one proposal version. The proposal_version is the + exact offer state the buyer will decline and later attempt to execute. + + steps: + - id: get_products_brief + title: "Send a brief and receive a versioned proposal" + task: get_products + schema_ref: "media-buy/get-products-request.json" + response_schema_ref: "media-buy/get-products-response.json" + doc_ref: "/media-buy/task-reference/get_products" + comply_scenario: full_sales_flow + stateful: false + expected: | + Return products and at least one proposal with proposal_id and proposal_version. + + sample_request: + buying_mode: "brief" + brief: "Premium video and display across outdoor lifestyle and sports. Q2 flight, $50K budget. Adults 25-54, US and Canada." + opportunity: + opportunity_id: "opp_01HX7W8S3Q9K2M4N6P0R5T1V" + phase: "active_sourcing" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + + context_outputs: + - path: "proposals[0].proposal_id" + key: "proposal_id" + - path: "proposals[0].proposal_version" + key: "proposal_version" + + validations: + - check: response_schema + description: "Response matches get-products-response.json schema" + - check: field_present + path: "proposals[0].proposal_id" + description: "Proposal has an ID" + - check: field_present + path: "proposals[0].proposal_version" + description: "Proposal has a version so it can be declined exactly" + + - id: decline_proposal + title: "Decline the proposal version" + narrative: | + The buyer reports that it evaluated the proposal version and is not + pursuing it. The seller must acknowledge the same proposal_id and + proposal_version in refinement_applied. + + steps: + - id: get_products_decline_proposal + title: "Decline a proposal version" + task: get_products + schema_ref: "media-buy/get-products-request.json" + response_schema_ref: "media-buy/get-products-response.json" + doc_ref: "/media-buy/task-reference/get_products" + comply_scenario: full_sales_flow + stateful: true + expected: | + Record the decline and return refinement_applied[0] with: + - scope: proposal + - proposal_id: the declined proposal + - proposal_version: the declined version + - status: applied + + sample_request: + buying_mode: "refine" + opportunity: + opportunity_id: "opp_01HX7W8S3Q9K2M4N6P0R5T1V" + phase: "active_sourcing" + refine: + - scope: "proposal" + proposal_id: "$context.proposal_id" + proposal_version: "$context.proposal_version" + action: "decline" + reason: "price" + detail: "Above target CPM range" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + + validations: + - check: response_schema + description: "Response matches get-products-response.json schema" + - check: field_value + path: "refinement_applied[0].scope" + value: "proposal" + description: "Decline acknowledgement is proposal-scoped" + - check: field_value + path: "refinement_applied[0].proposal_id" + value: "$context.proposal_id" + description: "Decline acknowledgement echoes proposal_id" + - check: field_value + path: "refinement_applied[0].proposal_version" + value: "$context.proposal_version" + description: "Decline acknowledgement echoes proposal_version" + - check: field_value + path: "refinement_applied[0].status" + value: "applied" + description: "Seller records the decline" + + - id: duplicate_decline + title: "Repeat the same decline" + narrative: | + Repeating the same decline for the same buyer, account, proposal_id, and + proposal_version must be idempotent. The seller must not double-count the + feedback and should acknowledge the duplicate as already applied. + + steps: + - id: get_products_decline_duplicate + title: "Repeat the same proposal decline" + task: get_products + schema_ref: "media-buy/get-products-request.json" + response_schema_ref: "media-buy/get-products-response.json" + doc_ref: "/media-buy/task-reference/get_products" + comply_scenario: full_sales_flow + stateful: true + expected: | + Return a successful response and acknowledge the same decline without + creating a second distinct loss event. + + sample_request: + buying_mode: "refine" + opportunity: + opportunity_id: "opp_01HX7W8S3Q9K2M4N6P0R5T1V" + phase: "active_sourcing" + refine: + - scope: "proposal" + proposal_id: "$context.proposal_id" + proposal_version: "$context.proposal_version" + action: "decline" + reason: "price" + detail: "Above target CPM range" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + + validations: + - check: response_schema + description: "Response matches get-products-response.json schema" + - check: field_value + path: "refinement_applied[0].scope" + value: "proposal" + description: "Duplicate decline acknowledgement is proposal-scoped" + - check: field_value + path: "refinement_applied[0].proposal_id" + value: "$context.proposal_id" + description: "Duplicate decline acknowledgement echoes proposal_id" + - check: field_value + path: "refinement_applied[0].proposal_version" + value: "$context.proposal_version" + description: "Duplicate decline acknowledgement echoes proposal_version" + + - id: reject_create_after_decline + title: "Reject create after decline" + narrative: | + After a proposal version is declined, the buyer must not execute it. The + seller must reject create_media_buy for that exact proposal_id + + proposal_version with INVALID_STATE. + + steps: + - id: create_media_buy_declined_proposal + title: "Reject create_media_buy for declined proposal version" + task: create_media_buy + schema_ref: "media-buy/create-media-buy-request.json" + response_schema_ref: "media-buy/create-media-buy-response.json" + doc_ref: "/media-buy/task-reference/create_media_buy" + comply_scenario: create_media_buy + expect_error: true + negative_path: payload_well_formed + stateful: true + expected: | + Reject with: + - code: INVALID_STATE + - recovery: correctable + - field: proposal_version or proposal_id + + sample_request: + brand: + domain: "acmeoutdoor.example" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + proposal_id: "$context.proposal_id" + proposal_version: "$context.proposal_version" + total_budget: + amount: 50000 + currency: "USD" + start_time: "asap" + end_time: "2027-06-30T23:59:59Z" + idempotency_key: "$generate:uuid_v4#media_buy_seller_proposal_decline_reject_create_after_decline" + + validations: + - check: error_code + value: "INVALID_STATE" + description: "Declined proposal version cannot be executed" diff --git a/static/compliance/source/specialisms/sales-guaranteed/index.yaml b/static/compliance/source/specialisms/sales-guaranteed/index.yaml index a6ccb6c7d9..eef7d40d38 100644 --- a/static/compliance/source/specialisms/sales-guaranteed/index.yaml +++ b/static/compliance/source/specialisms/sales-guaranteed/index.yaml @@ -18,6 +18,7 @@ requires_scenarios: - media_buy_seller/inventory_list_no_match - media_buy_seller/invalid_transitions - media_buy_seller/proposal_finalize + - media_buy_seller/proposal_decline - media_buy_seller/proposal_finalize_asap_timing - media_buy_seller/proposal_not_found_errors - governance_aware_seller/governance_multi_agent_rejected @@ -51,7 +52,8 @@ narrative: | Proposal-driven workflows are part of guaranteed selling: most guaranteed deals start with an RFP/brief, generate a proposal with curated bundles and rationale, refine, finalize to committed status with firm pricing and an inventory hold, and then the buyer executes via - create_media_buy. The `media_buy_seller/proposal_finalize` scenario covers that flow and is + create_media_buy. The `media_buy_seller/proposal_finalize` scenario covers acceptance, and + `media_buy_seller/proposal_decline` covers declined proposal versions. Both are capability-gated on `media_buy.supports_proposals` — a conformance declaration, not the buyer's per-proposal executable-state signal. Sellers that declare `false` (or omit the field, since the schema default is false) skip it with skip_result.reason: not_applicable; diff --git a/static/compliance/source/specialisms/sales-proposal-mode/index.yaml b/static/compliance/source/specialisms/sales-proposal-mode/index.yaml index f5a1320d38..08eaa50e59 100644 --- a/static/compliance/source/specialisms/sales-proposal-mode/index.yaml +++ b/static/compliance/source/specialisms/sales-proposal-mode/index.yaml @@ -10,6 +10,7 @@ required_tools: - create_media_buy requires_scenarios: - media_buy_seller/proposal_finalize + - media_buy_seller/proposal_decline - media_buy_seller/proposal_finalize_asap_timing - media_buy_seller/proposal_not_found_errors - media_buy_seller/delivery_reporting @@ -33,7 +34,8 @@ narrative: | `sales-proposal-mode` should drop it and declare `sales-guaranteed` plus `media_buy.supports_proposals: true` instead. This storyboard is retained for backward compat through 3.x and removed at 4.0. The `media_buy_seller/proposal_finalize` - scenario lives under `sales-guaranteed.requires_scenarios`. + scenario lives under `sales-guaranteed.requires_scenarios`; declined proposal + versions are covered by `media_buy_seller/proposal_decline`. Your seller generates curated media plan proposals. The buyer sends a brief, your platform returns products alongside proposals — curated bundles with budget allocations and rationale diff --git a/static/schemas/source/core/opportunity-context.json b/static/schemas/source/core/opportunity-context.json new file mode 100644 index 0000000000..4e421893cc --- /dev/null +++ b/static/schemas/source/core/opportunity-context.json @@ -0,0 +1,101 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/opportunity-context.json", + "title": "Opportunity Context", + "description": "Buyer-supplied context for a planning cycle or opportunity that can span multiple get_products brief/refine calls, proposals, declines, and create_media_buy execution. This is buyer-reported advisory operational metadata, not an idempotency key, purchase commitment, insertion order, inventory hold, or authorization. Sellers MAY use it for queueing, SLA expectations, async routing, human-review prioritization, CRM or pipeline labels, and aggregate win-rate analytics. Sellers MUST NOT use opportunity context to alter price, rate card, product eligibility, inventory availability, or buyability. Absence means the opportunity context is unknown and MUST NOT reduce response quality, product availability, or pricing fairness.", + "type": "object", + "properties": { + "opportunity_id": { + "type": "string", + "description": "Opaque buyer-assigned identifier for this planning cycle with this seller. Stable across related discovery, refinement, proposal, decline, and create_media_buy calls. Distinct from idempotency_key, which identifies one mutating request attempt, and from proposal_id/proposal_version, which identify a seller offer. Buyers SHOULD generate opaque values scoped per seller/account and MUST NOT embed advertiser names, campaign names, audience names, budget references, competitor names, internal authorization IDs, globally correlatable values, personal data, or confidential campaign identifiers.", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{1,255}$", + "x-entity": "opportunity" + }, + "phase": { + "type": "string", + "enum": [ + "exploratory", + "planning", + "active_sourcing" + ], + "description": "Buyer-declared stage for the opportunity. 'exploratory': early market scan or option gathering, often before budget, timing, or vendor shortlist are concrete. 'planning': a concrete opportunity is being planned, with likely campaign timing, budget, or internal planning work, but the buyer is not yet actively requesting seller proposals. 'active_sourcing': the buyer is actively requesting seller responses or proposals for a specific opportunity, but has not requested proposal finalization, an inventory hold, or a committed media buy." + }, + "status": { + "type": "string", + "enum": [ + "open", + "closed" + ], + "description": "Buyer-declared advisory status for the opportunity. 'open' means the planning cycle is still active. 'closed' means the buyer is no longer pursuing this opportunity with the seller, or is reporting that it resolved with this seller through create_media_buy. Closing an opportunity is distinct from declining a proposal version: proposal decline applies to a specific proposal_id + proposal_version; opportunity closure applies to the broader planning cycle." + }, + "close_reason": { + "type": "string", + "enum": [ + "accepted_with_seller", + "purchased_elsewhere", + "selected_alternative", + "not_pursued", + "budget_changed", + "timing_changed", + "other" + ], + "description": "Optional machine-readable reason when status is 'closed'. 'accepted_with_seller' means the opportunity resolved into a media buy with this seller; buyers normally carry the same opportunity_id on create_media_buy and use this reason only when that create call should also mark the opportunity closed. 'purchased_elsewhere' means the buyer bought outside this seller without naming the alternative seller. 'selected_alternative' means another non-purchase approach or supply path was selected. Buyers MUST NOT disclose competitor identity, clearing prices, bid strategy, confidential audience definitions, or other sensitive details." + }, + "close_detail": { + "type": "string", + "description": "Optional short, non-identifying explanation when status is 'closed'. MUST NOT include competitor identity, clearing prices, bid strategy, confidential audience definitions, budget reallocations, campaign IDs, NDA-scoped terms, or other sensitive campaign information.", + "maxLength": 500 + }, + "planning_horizon": { + "allOf": [ + { + "$ref": "/schemas/core/date-range.json" + } + ], + "description": "Advisory planning or campaign window for the opportunity, using the canonical Date Range shape with inclusive start and end dates. This is context for seller routing and planning, not a hard availability filter; use filters.start_date and filters.end_date for availability constraints." + }, + "response_deadline": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 date-time by which the buyer would like a seller response or proposal update. Sellers MAY use it for routing and SLA handling only; it does not require or authorize firm pricing, inventory availability confirmation, inventory hold, proposal finalization, or buyer/seller commitment." + } + }, + "required": [ + "opportunity_id" + ], + "allOf": [ + { + "if": { + "not": { + "properties": { + "status": { + "const": "closed" + } + }, + "required": [ + "status" + ] + } + }, + "then": { + "not": { + "anyOf": [ + { + "required": [ + "close_reason" + ] + }, + { + "required": [ + "close_detail" + ] + } + ] + } + } + } + ], + "additionalProperties": false +} diff --git a/static/schemas/source/core/proposal.json b/static/schemas/source/core/proposal.json index de758bef3c..f812d75372 100644 --- a/static/schemas/source/core/proposal.json +++ b/static/schemas/source/core/proposal.json @@ -10,6 +10,12 @@ "description": "Unique identifier for this proposal. Used to finalize a draft proposal and to execute a committed proposal via create_media_buy.", "maxLength": 255 }, + "proposal_version": { + "type": "string", + "description": "Opaque seller-assigned version for this proposal's current offer state. Changes when allocations, pricing, inventory hold terms, insertion order, or other execution-relevant proposal terms change. Sellers that expect buyers to resolve a proposal version explicitly, whether by accepting it via create_media_buy or declining it via get_products refine action 'decline', MUST include this value on the proposal. Buyers include this value when declining a proposal and MUST include it when accepting/executing a versioned proposal via create_media_buy.", + "minLength": 1, + "maxLength": 255 + }, "name": { "type": "string", "description": "Human-readable name for this media plan proposal", diff --git a/static/schemas/source/core/x-entity-types.json b/static/schemas/source/core/x-entity-types.json index 1da35b3206..a84b290dba 100644 --- a/static/schemas/source/core/x-entity-types.json +++ b/static/schemas/source/core/x-entity-types.json @@ -10,6 +10,7 @@ "rights_grant", "account", "operator", + "opportunity", "media_buy", "package", "product", @@ -48,6 +49,7 @@ "rights_grant": "A rights grant identifier covering any lifecycle state (acquired, pending_approval, rejected). `rights_id` across brand/* schemas. Named `grant` rather than `contract` because the identifier is issued at request time — a bilateral contract may or may not exist yet.", "account": "Billing/scope account in the seller's namespace. `account_id` or the natural-key `{brand, operator}` form; both resolve to the same entity.", "operator": "An operator (seller) identity, typically by domain. Distinct from `account` — one operator issues many accounts.", + "opportunity": "A buyer-supplied planning-cycle/opportunity identifier stable across related get_products, proposal outcome, and create_media_buy calls. The value is buyer-assigned and scoped to the seller/account; it is not an idempotency key, seller offer id, insertion order, or media buy id.", "media_buy": "A media buy / campaign. `media_buy_id` across media-buy/* schemas.", "package": "A line item within a media buy. `package_id` across media-buy/* schemas.", "product": "A seller inventory product. `product_id` across media-buy/* and core/product.json.", diff --git a/static/schemas/source/enums/error-code.json b/static/schemas/source/enums/error-code.json index 0a5b1a253f..ae33274e82 100644 --- a/static/schemas/source/enums/error-code.json +++ b/static/schemas/source/enums/error-code.json @@ -135,7 +135,7 @@ "IDEMPOTENCY_CONFLICT": "An earlier request with the same idempotency_key was processed with a different canonical payload within the seller's replay window. Distinct from CONFLICT (concurrent write) — this indicates the client reused a key across semantically different requests. Recovery: correctable (use a fresh UUID v4 for the new request, or resend the exact original payload to get the cached response).", "IDEMPOTENCY_EXPIRED": "The idempotency_key was seen previously but its cached response has been evicted because it is past the seller's declared replay_ttl_seconds. Distinct from IDEMPOTENCY_CONFLICT (different payload within window) — this indicates the retry arrived too late for at-most-once guarantees. Recovery: correctable (perform a natural-key reconciliation — e.g., call get_media_buys for the relevant account/status scope and match returned media_buys[].context.internal_campaign_id — to determine whether the original request succeeded, then either accept that result or generate a fresh idempotency_key for a new attempt). If the buyer has any evidence the prior call succeeded (partial response received before crash, entry in the buyer's own DB, a webhook fired), the buyer MUST do the natural-key reconciliation BEFORE minting a new key — minting a new key in that situation is exactly how double-creation happens.", "IDEMPOTENCY_IN_FLIGHT": "A prior request with the same `idempotency_key` is still being processed and has not yet produced a cached response. The second request arrived before the first completed. Sellers MAY return this code instead of blocking the second caller until the first finishes — useful when the first call invokes a slow downstream system (SSP, ad server, payment provider). Distinct from IDEMPOTENCY_CONFLICT (different canonical payload — a client bug) and from CONFLICT (concurrent modification of a different resource) — IDEMPOTENCY_IN_FLIGHT is the seller telling the buyer 'your retry was correct but your previous attempt is still running, come back shortly.' Sellers SHOULD populate `error.details.retry_after` (seconds, integer) with a wait hint based on the first request's elapsed time and expected completion. Buyers MUST treat this as transient and MUST NOT mint a fresh `idempotency_key` — minting a new key turns a safe retry into a double-execution race. Recovery: transient (wait `retry_after` seconds and retry with the same `idempotency_key`; the second attempt will either replay the now-cached response or, if still in flight, return IDEMPOTENCY_IN_FLIGHT again).", - "INVALID_STATE": "Operation is not permitted for the resource's current status (e.g., updating a completed or canceled media buy, or modifying a canceled package). Recovery: correctable (check current status via get_media_buys and adjust request).", + "INVALID_STATE": "Operation is not permitted for the resource's current status or terminal lifecycle state (e.g., updating a completed or canceled media buy, modifying a canceled package, or executing a proposal version the buyer previously declined). Recovery: correctable (check current status or re-discover/refine the resource and adjust request).", "MEDIA_BUY_NOT_FOUND": "Referenced media buy does not exist or is not accessible to the requesting agent. Recovery: correctable (verify media_buy_id; when recovering across legacy sellers or missing echoed IDs, reconcile via get_media_buys and the opaque request/response context correlation handle, such as context.internal_campaign_id, rather than deprecated top-level buyer_ref).", "NOT_CANCELLABLE": "The media buy or package cannot be canceled in its current state. The seller may have contractual or operational constraints that prevent cancellation. Recovery: correctable (check the seller's cancellation policy or contact the seller).", "PACKAGE_NOT_FOUND": "Referenced package does not exist within the specified media buy. Recovery: correctable (verify package_id within the media buy; when recovering across legacy sellers or missing echoed product_id, reconcile via get_media_buys and the package-level context correlation handle, such as context.buyer_ref, rather than deprecated top-level buyer_ref).", @@ -340,7 +340,7 @@ }, "INVALID_STATE": { "recovery": "correctable", - "suggestion": "check current status via get_media_buys and adjust request" + "suggestion": "check current status via get_media_buys, or re-discover/refine the resource if this is a proposal lifecycle state, then adjust request" }, "MEDIA_BUY_NOT_FOUND": { "recovery": "correctable", diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index 9554505361..428522da5c 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -202,6 +202,10 @@ "$ref": "/schemas/core/proposal.json", "description": "A proposed media plan with budget allocations across products - actionable via create_media_buy" }, + "opportunity-context": { + "$ref": "/schemas/core/opportunity-context.json", + "description": "Buyer-supplied planning-cycle context that can span discovery, refinement, proposal outcomes, and create_media_buy" + }, "insertion-order": { "$ref": "/schemas/core/insertion-order.json", "description": "A formal insertion order attached to a committed proposal for agreement signing" diff --git a/static/schemas/source/media-buy/create-media-buy-request.json b/static/schemas/source/media-buy/create-media-buy-request.json index 3539b07beb..9d81595202 100644 --- a/static/schemas/source/media-buy/create-media-buy-request.json +++ b/static/schemas/source/media-buy/create-media-buy-request.json @@ -7,6 +7,39 @@ "allOf": [ { "$ref": "/schemas/core/version-envelope.json" + }, + { + "description": "If create_media_buy marks an opportunity closed, the only valid close reason is accepted_with_seller because the create call is the in-protocol close-won signal.", + "if": { + "properties": { + "opportunity": { + "properties": { + "status": { + "const": "closed" + } + }, + "required": [ + "status" + ] + } + }, + "required": [ + "opportunity" + ] + }, + "then": { + "properties": { + "opportunity": { + "properties": { + "close_reason": { + "enum": [ + "accepted_with_seller" + ] + } + } + } + } + } } ], "x-mutates-state": true, @@ -31,6 +64,16 @@ "type": "string", "description": "ID of a committed proposal from get_products to execute. When provided with total_budget, the publisher converts the proposal's allocation percentages into packages automatically. Alternative to providing packages array. If the referenced proposal has proposal_status: 'draft', the seller MUST reject with PROPOSAL_NOT_COMMITTED; the buyer finalizes first via get_products refine action 'finalize'." }, + "proposal_version": { + "type": "string", + "description": "Opaque version from the proposal being accepted/executed. Buyers MUST include this when the selected proposal carried proposal_version so the seller can verify the exact offer state being accepted. Sellers MUST reject execution that omits proposal_version when the referenced executable proposal is versioned with INVALID_REQUEST. If this proposal version was previously declined via get_products refine action 'decline', the seller MUST reject the create_media_buy request with INVALID_STATE. If the supplied version is stale for a known proposal, reject with CONFLICT; if the proposal/version cannot be resolved for the caller, reject with PROPOSAL_NOT_FOUND.", + "minLength": 1, + "maxLength": 255 + }, + "opportunity": { + "$ref": "/schemas/core/opportunity-context.json", + "description": "Optional buyer-supplied planning-cycle context. When create_media_buy accepts a proposal or package plan that came from prior get_products calls, buyers SHOULD carry the same opportunity_id so the seller can connect discovery/refinement activity to the accepted media buy. This is distinct from idempotency_key and does not change create_media_buy execution semantics." + }, "total_budget": { "type": "object", "description": "Total budget for the media buy when executing a proposal. The publisher applies the proposal's allocation percentages to this amount to derive package budgets.", @@ -219,6 +262,9 @@ "dependencies": { "proposal_id": [ "total_budget" + ], + "proposal_version": [ + "proposal_id" ] }, "additionalProperties": true diff --git a/static/schemas/source/media-buy/get-products-async-response-submitted.json b/static/schemas/source/media-buy/get-products-async-response-submitted.json index db4a63fa39..0d0da04eec 100644 --- a/static/schemas/source/media-buy/get-products-async-response-submitted.json +++ b/static/schemas/source/media-buy/get-products-async-response-submitted.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/get-products-async-response-submitted.json", "title": "Get Products - Submitted", - "description": "Async task envelope returned when get_products cannot be confirmed before the response — for example, when custom or bespoke product curation is queued for processing. This envelope is valid only for `buying_mode: \"brief\"` and `buying_mode: \"refine\"`; `buying_mode: \"wholesale\"` is a synchronous wholesale product feed read and MUST NOT use the Submitted arm. The buyer can always poll `get_task_status` (legacy `tasks/get`) with `task_id`. If the originating request carried `push_notification_config`, the seller also delivers at least the terminal completion/failure notification to that webhook URL; intermediate progress notifications are MAY. The products array lands on the completion artifact, not this envelope.", + "description": "Async task envelope returned when get_products cannot be confirmed before the response — for example, when custom or bespoke product curation is queued for processing. This envelope is valid only for `buying_mode: \"brief\"` and `buying_mode: \"refine\"`; `buying_mode: \"wholesale\"` is a synchronous wholesale product feed read and `buying_mode: \"open\"` is a synchronous state read, so requests in either mode MUST NOT use the Submitted arm. The buyer can always poll `get_task_status` (legacy `tasks/get`) with `task_id`. If the originating request carried `push_notification_config`, the seller also delivers at least the terminal completion/failure notification to that webhook URL; intermediate progress notifications are MAY. The products array lands on the completion artifact, not this envelope.", "type": "object", "properties": { "status": { diff --git a/static/schemas/source/media-buy/get-products-request.json b/static/schemas/source/media-buy/get-products-request.json index cf35a903f3..d83f53ddcd 100644 --- a/static/schemas/source/media-buy/get-products-request.json +++ b/static/schemas/source/media-buy/get-products-request.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/get-products-request.json", "title": "Get Products Request", - "description": "Request parameters for discovering or refining advertising products. buying_mode declares the buyer's intent: 'brief' for curated discovery, 'wholesale' for raw wholesale product feed access, or 'refine' to iterate on known products and proposals.", + "description": "Request parameters for discovering, loading, or refining advertising products. buying_mode declares the buyer's intent: 'brief' for curated discovery, 'wholesale' for raw wholesale product feed access, 'refine' to iterate on known products and proposals, or 'open' to retrieve unresolved products/proposals already associated with the buyer/account/opportunity.", "type": "object", "allOf": [ { @@ -34,6 +34,73 @@ "buying_mode" ] } + }, + { + "description": "Brief text is required for explicit curated brief discovery.", + "if": { + "properties": { + "buying_mode": { + "const": "brief" + } + }, + "required": [ + "buying_mode" + ] + }, + "then": { + "required": [ + "brief" + ] + } + }, + { + "description": "Brief text is forbidden for non-brief v3 modes.", + "if": { + "properties": { + "buying_mode": { + "enum": [ + "wholesale", + "refine", + "open" + ] + } + }, + "required": [ + "buying_mode" + ] + }, + "then": { + "not": { + "required": [ + "brief" + ] + } + } + }, + { + "description": "Refine requests require refine[]; non-refine modes must not include refine[].", + "if": { + "properties": { + "buying_mode": { + "const": "refine" + } + }, + "required": [ + "buying_mode" + ] + }, + "then": { + "required": [ + "refine" + ] + }, + "else": { + "not": { + "required": [ + "refine" + ] + } + } } ], "properties": { @@ -42,13 +109,14 @@ "enum": [ "brief", "wholesale", - "refine" + "refine", + "open" ], - "description": "Declares buyer intent for this request. 'brief': publisher curates product recommendations from the provided brief. 'wholesale': buyer requests raw product inventory to apply their own audiences — brief must not be provided, and proposals are omitted. 'refine': iterate on products and proposals from a previous get_products response using the refine array of change requests. v3 clients MUST include buying_mode. Sellers receiving requests from pre-v3 clients without buying_mode SHOULD default to 'brief'. Timing semantics: 'wholesale' is a wholesale product feed read — sellers SHOULD return a synchronous response and MUST NOT route a 'wholesale' request through the async/Submitted arm; partial completion is signalled via the response's incomplete[] field (with optional estimated_wait), not via a task-handoff envelope. 'brief' and 'refine' MAY complete synchronously, or MAY return a Submitted envelope (see get-products-async-response-submitted.json) when curation requires upstream-system queries or HITL review the seller cannot complete inside time_budget. Buyers needing predictable fast wholesale product feed access MUST use 'wholesale'; buyers open to slower curation use 'brief' or 'refine'." + "description": "Declares buyer intent for this request. 'brief': publisher curates product recommendations from the provided brief. 'wholesale': buyer requests raw product inventory to apply their own audiences — brief must not be provided, and proposals are omitted. 'refine': iterate on products and proposals from a previous get_products response using the refine array of change requests. 'open': retrieve unresolved products and proposals already associated with the caller/account, optionally narrowed by opportunity.opportunity_id; sellers MUST NOT create new products or proposals solely to satisfy an open request. v3 clients MUST include buying_mode. Sellers receiving requests from pre-v3 clients without buying_mode SHOULD default to 'brief'. Timing semantics: 'wholesale' is a wholesale product feed read — sellers SHOULD return a synchronous response and MUST NOT route a 'wholesale' request through the async/Submitted arm; partial completion is signalled via the response's incomplete[] field (with optional estimated_wait), not via a task-handoff envelope. 'open' is a state read and SHOULD complete synchronously. 'brief' and 'refine' MAY complete synchronously, or MAY return a Submitted envelope (see get-products-async-response-submitted.json) when curation requires upstream-system queries or HITL review the seller cannot complete inside time_budget. Buyers needing predictable fast wholesale product feed access MUST use 'wholesale'; buyers open to slower curation use 'brief' or 'refine'." }, "brief": { "type": "string", - "description": "Natural language description of campaign requirements. Required when buying_mode is 'brief'. Must not be provided when buying_mode is 'wholesale' or 'refine'." + "description": "Natural language description of campaign requirements. Required when buying_mode is 'brief'. Must not be provided when buying_mode is 'wholesale', 'refine', or 'open'." }, "refine": { "type": "array", @@ -125,22 +193,147 @@ "minLength": 1, "description": "Proposal ID from a previous get_products response." }, + "proposal_version": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Opaque proposal_version from the proposal the buyer is acting on. Required when action is 'decline' so the decline applies to the exact offer state the buyer evaluated. Buyers SHOULD include it for other proposal actions when the prior proposal carried proposal_version." + }, "action": { "type": "string", "enum": [ "include", "omit", - "finalize" + "finalize", + "decline" ], "default": "include", - "description": "'include' (default): return this proposal with updated allocations and pricing. 'omit': exclude this proposal from the response. 'finalize': request firm pricing and inventory hold — transitions a draft proposal to committed with an expires_at hold window. May trigger seller-side approval (HITL). The buyer should not set a time_budget for finalize requests — they represent a commitment to wait for the result. Optional — when omitted, the seller treats the entry as action: 'include'.\n\nFinalize is exclusive within the parent `refine[]` array: see the array-level description for the finalize-exclusivity rule (mixing finalize with non-finalize entries is rejected) and multi-finalize atomicity contract." + "description": "'include' (default): return this proposal with updated allocations and pricing. 'omit': exclude this proposal from the response without marking a terminal outcome. 'finalize': request firm pricing and inventory hold — transitions a draft proposal to committed with an expires_at hold window. May trigger seller-side approval (HITL). The buyer should not set a time_budget for finalize requests — they represent a commitment to wait for the result. 'decline': terminally report that the buyer evaluated this proposal version and is not pursuing it. A declined proposal version MUST NOT be executed later via create_media_buy; sellers MUST reject such attempts with INVALID_STATE. Optional — when omitted, the seller treats the entry as action: 'include'.\n\nFinalize is exclusive within the parent `refine[]` array: see the array-level description for the finalize-exclusivity rule (mixing finalize with non-finalize entries is rejected) and multi-finalize atomicity contract." + }, + "reason": { + "type": "string", + "oneOf": [ + { + "const": "price", + "description": "The proposal's price, rate, or cost efficiency did not meet the buyer's requirement." + }, + { + "const": "inventory_fit", + "description": "The inventory, placements, format mix, availability, or forecast did not fit the brief." + }, + { + "const": "audience_fit", + "description": "The proposal did not sufficiently match the buyer's audience or targeting requirements." + }, + { + "const": "creative_unsupported", + "description": "The proposal could not support the buyer's required creative format, asset, or production workflow." + }, + { + "const": "measurement_unsupported", + "description": "The proposal could not support the buyer's required measurement, reporting, attribution, or verification needs." + }, + { + "const": "policy", + "description": "The proposal was incompatible with buyer, seller, governance, legal, or brand-suitability policy requirements." + }, + { + "const": "timing", + "description": "The schedule, expiry, approval timing, or delivery window did not fit the buyer's need." + }, + { + "const": "budget_changed", + "description": "The buyer's budget, scope, or campaign plan changed after evaluation." + }, + { + "const": "selected_alternative", + "description": "The buyer selected another approach or supply source. The detail field MUST NOT identify the competitor or disclose confidential clearing terms." + }, + { + "const": "other", + "description": "Another reason. The buyer MUST include a short, non-identifying detail." + } + ], + "description": "Machine-readable reason for action 'decline'. Required when declining a proposal. Sellers use this for aggregate proposal feedback; buyers MUST NOT include competitor identity or other sensitive detail." + }, + "detail": { + "type": "string", + "maxLength": 500, + "description": "Optional short, non-identifying explanation for action 'decline'. MUST NOT include competitor identity or sensitive campaign information." }, "ask": { "type": "string", "minLength": 1, - "description": "What the buyer is asking for on this proposal (e.g., 'shift more budget toward video', 'reduce total by 10%'). Ignored when action is 'omit'." + "description": "What the buyer is asking for on this proposal (e.g., 'shift more budget toward video', 'reduce total by 10%'). Ignored when action is 'omit' or 'decline'." } }, + "allOf": [ + { + "if": { + "properties": { + "action": { + "const": "decline" + } + }, + "required": [ + "action" + ] + }, + "then": { + "required": [ + "proposal_version", + "reason" + ] + } + }, + { + "if": { + "not": { + "properties": { + "action": { + "const": "decline" + } + }, + "required": [ + "action" + ] + } + }, + "then": { + "not": { + "anyOf": [ + { + "required": [ + "reason" + ] + }, + { + "required": [ + "detail" + ] + } + ] + } + } + }, + { + "if": { + "properties": { + "reason": { + "const": "other" + } + }, + "required": [ + "reason" + ] + }, + "then": { + "required": [ + "detail" + ] + } + } + ], "required": [ "scope", "proposal_id" @@ -158,6 +351,10 @@ "$ref": "/schemas/core/catalog.json", "description": "Catalog of items the buyer wants to promote. The seller matches catalog items against its inventory and returns products where matches exist. Supports all catalog types: a job catalog finds job ad products, a product catalog finds sponsored product slots. Reference a synced catalog by catalog_id, or provide inline items." }, + "opportunity": { + "$ref": "/schemas/core/opportunity-context.json", + "description": "Optional buyer-supplied planning-cycle context that can span multiple discovery, open-state retrieval, refinement, proposal, decline, and create_media_buy calls. Meaningful for buying_mode: 'brief', 'open', and 'refine'; sellers MUST ignore it for buying_mode: 'wholesale' feed reads. In open mode, opportunity.opportunity_id narrows the unresolved products/proposals returned." + }, "account": { "$ref": "/schemas/core/account-ref.json", "description": "Account for product lookup. Returns products with pricing specific to this account's rate card." @@ -237,7 +434,7 @@ }, "push_notification_config": { "$ref": "/schemas/core/push-notification-config.json", - "description": "Optional webhook configuration for async terminal completion/failure notifications on curated discovery. Meaningful only for `buying_mode: \"brief\"` and `buying_mode: \"refine\"` requests that enter the async lifecycle. Submitted envelopes with `task_id` remain pollable through `get_task_status` (legacy `tasks/get`) whether or not this field is present. If a brief/refine request includes this field and the seller returns a Submitted envelope, the seller MUST deliver at least the terminal completion/failure notification to the configured URL; intermediate progress notifications are MAY. If the seller cannot honor the webhook channel, it MUST reject the request with a structured error instead of silently accepting. This field does not change wholesale timing semantics: sellers MUST NOT route `buying_mode: \"wholesale\"` requests through the async/Submitted arm or emit async delivery solely because `push_notification_config` is present; partial wholesale completion is reported via `incomplete[]`." + "description": "Optional webhook configuration for async terminal completion/failure notifications on curated discovery. Meaningful only for `buying_mode: \"brief\"` and `buying_mode: \"refine\"` requests that enter the async lifecycle. Submitted envelopes with `task_id` remain pollable through `get_task_status` (legacy `tasks/get`) whether or not this field is present. If a brief/refine request includes this field and the seller returns a Submitted envelope, the seller MUST deliver at least the terminal completion/failure notification to the configured URL; intermediate progress notifications are MAY. If the seller cannot honor the webhook channel, it MUST reject the request with a structured error instead of silently accepting. This field does not change wholesale or open timing semantics: sellers MUST NOT route `buying_mode: \"wholesale\"` or `buying_mode: \"open\"` requests through the async/Submitted arm or emit async delivery solely because `push_notification_config` is present; partial wholesale completion is reported via `incomplete[]`." }, "pagination": { "$ref": "/schemas/core/pagination-request.json" diff --git a/static/schemas/source/media-buy/get-products-response.json b/static/schemas/source/media-buy/get-products-response.json index d44d9a1397..dfaecfe82e 100644 --- a/static/schemas/source/media-buy/get-products-response.json +++ b/static/schemas/source/media-buy/get-products-response.json @@ -153,6 +153,12 @@ "type": "string", "description": "Echoes proposal_id from the corresponding refine entry." }, + "proposal_version": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "MUST exactly echo proposal_version from the corresponding proposal refine entry when the buyer supplied one. Required by the protocol when acknowledging action 'decline'." + }, "status": { "type": "string", "enum": [ diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index 998cf921e7..13a669904b 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -178,10 +178,10 @@ }, "buying_modes": { "type": "array", - "description": "Buying modes this seller supports on get_products. 'brief' (semantic discovery driven by the brief) is universally supported and implicit. 'wholesale' (raw wholesale product feed enumeration — caller omits brief and the seller returns the full priced product feed, paginated) is opt-in and SHOULD be declared explicitly so buyers can probe before issuing wholesale calls. 'refine' lets buyers iterate on prior products/proposals and is also the vehicle for finalizing draft proposals when the seller returns them. Sellers MAY declare ['brief', 'wholesale'] to signal wholesale support; absent declaration is treated as ['brief'] for wholesale-feed probing purposes and sellers MAY return INVALID_REQUEST for wholesale calls they do not support. Symmetric with signals.discovery_modes.", + "description": "Buying modes this seller supports on get_products. 'brief' (semantic discovery driven by the brief) is universally supported and implicit. 'refine' lets buyers iterate on prior products/proposals and is also the vehicle for finalizing draft proposals when the seller returns them. 'open' reloads unresolved products/proposals already associated with the caller/account/opportunity without creating new products, proposals, firm prices, inventory holds, or proposal versions. 'wholesale' (raw wholesale product feed enumeration — caller omits brief and the seller returns the full priced product feed, paginated) SHOULD be declared explicitly so buyers can probe before issuing wholesale calls. Sellers SHOULD declare the full set they support, for example ['brief', 'refine', 'open', 'wholesale']. Absent declaration is treated as ['brief'] for backward-compatible wholesale-feed probing purposes. Symmetric with signals.discovery_modes.", "items": { "type": "string", - "enum": ["brief", "wholesale", "refine"] + "enum": ["brief", "wholesale", "refine", "open"] }, "minItems": 1, "uniqueItems": true, diff --git a/tests/example-validation-simple.test.cjs b/tests/example-validation-simple.test.cjs index 1b393fa4a2..dc60241951 100644 --- a/tests/example-validation-simple.test.cjs +++ b/tests/example-validation-simple.test.cjs @@ -1172,6 +1172,10 @@ async function runTests() { await validateExample( { "buying_mode": "refine", + "opportunity": { + "opportunity_id": "opp_01HX7W8S3Q9K2M4N6P0R5T1V", + "phase": "active_sourcing" + }, "refine": [ { "scope": "product", "product_id": "prod_video_premium" }, { "scope": "proposal", "proposal_id": "prop_balanced_v1", "ask": "shift 20% to video" } @@ -1181,6 +1185,293 @@ async function runTests() { 'refine[] with new prefixed ids and omitted action (defaults to include)' ); + await validateExample( + { + "buying_mode": "brief", + "brief": "Nova Soda Q3 campaign ideas for Gen Z summer moments", + "brand": { "domain": "novasoda.example" }, + "opportunity": { + "opportunity_id": "opp_01HX7W8S3Q9K2M4N6P0R5T1V", + "phase": "exploratory", + "planning_horizon": { + "start": "2026-07-01", + "end": "2026-09-30" + }, + "response_deadline": "2026-06-30T21:00:00Z" + } + }, + '/schemas/media-buy/get-products-request.json', + 'get_products accepts opportunity context' + ); + + await validateExample( + { + "buying_mode": "refine", + "opportunity": { + "opportunity_id": "opp_01HX7W8S3Q9K2M4N6P0R5T1V", + "status": "closed", + "close_reason": "not_pursued" + }, + "refine": [ + { "scope": "request", "ask": "Close this planning cycle; we are not pursuing the opportunity." } + ] + }, + '/schemas/media-buy/get-products-request.json', + 'get_products accepts closed opportunity context' + ); + + await validateExample( + { + "buying_mode": "open", + "account": { "account_id": "acct_123" }, + "opportunity": { + "opportunity_id": "opp_01HX7W8S3Q9K2M4N6P0R5T1V" + } + }, + '/schemas/media-buy/get-products-request.json', + 'get_products accepts open mode narrowed by opportunity' + ); + + await expectInvalid( + { + "buying_mode": "open", + "brief": "Load the open proposals for this opportunity", + "opportunity": { + "opportunity_id": "opp_01HX7W8S3Q9K2M4N6P0R5T1V" + } + }, + '/schemas/media-buy/get-products-request.json', + 'get_products open mode rejects brief', + ['must NOT be valid'] + ); + + await expectInvalid( + { + "buying_mode": "open", + "refine": [ + { "scope": "request", "ask": "Load unresolved proposals." } + ] + }, + '/schemas/media-buy/get-products-request.json', + 'get_products open mode rejects refine', + ['must NOT be valid'] + ); + + await expectInvalid( + { + "buying_mode": "open", + "if_wholesale_feed_version": "feed_v1" + }, + '/schemas/media-buy/get-products-request.json', + 'get_products open mode rejects wholesale conditional version', + ['wholesale'] + ); + + await validateExample( + { + "status": "completed", + "products": [], + "proposals": [], + "cache_scope": "account" + }, + '/schemas/media-buy/get-products-response.json', + 'get_products open mode empty response uses products array' + ); + + await expectInvalid( + { + "buying_mode": "brief" + }, + '/schemas/media-buy/get-products-request.json', + 'get_products brief mode requires brief', + ['brief'] + ); + + await expectInvalid( + { + "buying_mode": "refine" + }, + '/schemas/media-buy/get-products-request.json', + 'get_products refine mode requires refine', + ['refine'] + ); + + await expectInvalid( + { + "buying_mode": "brief", + "brief": "Nova Soda Q3 campaign ideas for Gen Z summer moments", + "opportunity": { + "phase": "exploratory" + } + }, + '/schemas/media-buy/get-products-request.json', + 'get_products opportunity requires opportunity_id', + ['opportunity_id'] + ); + + await expectInvalid( + { + "buying_mode": "brief", + "brief": "Nova Soda Q3 campaign ideas for Gen Z summer moments", + "opportunity": { + "opportunity_id": "opp_01HX7W8S3Q9K2M4N6P0R5T1V", + "close_reason": "not_pursued" + } + }, + '/schemas/media-buy/get-products-request.json', + 'get_products opportunity close_reason requires closed status', + ['must NOT be valid'] + ); + + await validateExample( + { + "buying_mode": "refine", + "refine": [ + { + "scope": "proposal", + "proposal_id": "prop_balanced_v1", + "proposal_version": "4", + "action": "decline", + "reason": "price", + "detail": "Above target CPM range" + } + ] + }, + '/schemas/media-buy/get-products-request.json', + 'refine[] proposal decline with version and reason' + ); + + await expectInvalid( + { + "buying_mode": "refine", + "refine": [ + { + "scope": "proposal", + "proposal_id": "prop_balanced_v1", + "action": "decline", + "reason": "price" + } + ] + }, + '/schemas/media-buy/get-products-request.json', + 'refine[] proposal decline requires proposal_version', + ['proposal_version'] + ); + + await expectInvalid( + { + "buying_mode": "refine", + "refine": [ + { + "scope": "proposal", + "proposal_id": "prop_balanced_v1", + "proposal_version": "4", + "action": "decline" + } + ] + }, + '/schemas/media-buy/get-products-request.json', + 'refine[] proposal decline requires reason', + ['reason'] + ); + + await expectInvalid( + { + "buying_mode": "refine", + "refine": [ + { + "scope": "proposal", + "proposal_id": "prop_balanced_v1", + "action": "omit", + "reason": "price" + } + ] + }, + '/schemas/media-buy/get-products-request.json', + 'refine[] non-decline proposal action rejects reason', + ['must NOT be valid'] + ); + + await expectInvalid( + { + "buying_mode": "refine", + "refine": [ + { + "scope": "proposal", + "proposal_id": "prop_balanced_v1", + "proposal_version": "4", + "action": "decline", + "reason": "other" + } + ] + }, + '/schemas/media-buy/get-products-request.json', + 'refine[] proposal decline with other reason requires detail', + ['detail'] + ); + + await validateExample( + { + "idempotency_key": "create-proposal-version-123", + "account": { "account_id": "acct_123" }, + "brand": { "domain": "acmecorp.example" }, + "proposal_id": "prop_balanced_v1", + "proposal_version": "4", + "opportunity": { + "opportunity_id": "opp_01HX7W8S3Q9K2M4N6P0R5T1V", + "status": "closed", + "close_reason": "accepted_with_seller" + }, + "total_budget": { "amount": 50000, "currency": "USD" }, + "start_time": "2026-04-01T00:00:00Z", + "end_time": "2026-04-30T23:59:59Z" + }, + '/schemas/media-buy/create-media-buy-request.json', + 'create_media_buy accepts proposal_version with proposal_id' + ); + + await expectInvalid( + { + "idempotency_key": "create-proposal-version-789", + "account": { "account_id": "acct_123" }, + "brand": { "domain": "acmecorp.example" }, + "proposal_id": "prop_balanced_v1", + "proposal_version": "4", + "opportunity": { + "opportunity_id": "opp_01HX7W8S3Q9K2M4N6P0R5T1V", + "status": "closed", + "close_reason": "not_pursued" + }, + "total_budget": { "amount": 50000, "currency": "USD" }, + "start_time": "2026-04-01T00:00:00Z", + "end_time": "2026-04-30T23:59:59Z" + }, + '/schemas/media-buy/create-media-buy-request.json', + 'create_media_buy closed opportunity rejects non-accepted close_reason', + ['accepted_with_seller'] + ); + + await expectInvalid( + { + "idempotency_key": "create-proposal-version-456", + "account": { "account_id": "acct_123" }, + "brand": { "domain": "acmecorp.example" }, + "proposal_version": "4", + "packages": [ + { + "product_id": "prod_video_premium", + "pricing_option_id": "cpm_fixed", + "budget": 50000 + } + ], + "start_time": "2026-04-01T00:00:00Z", + "end_time": "2026-04-30T23:59:59Z" + }, + '/schemas/media-buy/create-media-buy-request.json', + 'create_media_buy proposal_version requires proposal_id', + ['proposal_id'] + ); + // Measurement capability block — locks the discovery shape down // (#3612). Two metrics: one with full accreditations[] and // methodology_version, one with the minimum required field (metric_id). @@ -1228,6 +1519,26 @@ async function runTests() { 'get_adcp_capabilities response with measurement capability block (#3612)' ); + await validateExample( + { + "status": "completed", + "adcp": { + "major_versions": [3], + "supported_versions": ["3.1"], + "idempotency": { "supported": true, "replay_ttl_seconds": 86400 } + }, + "supported_protocols": ["media_buy"], + "account": { + "supported_billing": ["operator"] + }, + "media_buy": { + "buying_modes": ["brief", "refine", "open", "wholesale"] + } + }, + '/schemas/protocol/get-adcp-capabilities-response.json', + 'get_adcp_capabilities accepts open buying mode' + ); + // Negative case — duplicate metric_id within one agent's catalog is unambiguously a bug; // schema-level uniqueItems on metrics[] catches it. await expectInvalid( From 96b50a6949290d516b92fd6e525bbcc13052cf7d Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 17 Jun 2026 23:43:00 +0200 Subject: [PATCH 2/4] fix(ci): renumber duplicate migrations --- .github/workflows/check-migration-numbers.yml | 23 +++++++++++++++++-- ...rack.sql => 519_decision_makers_track.sql} | 0 ....sql => 520_escalation_public_updates.sql} | 0 3 files changed, 21 insertions(+), 2 deletions(-) rename server/src/db/migrations/{517_decision_makers_track.sql => 519_decision_makers_track.sql} (100%) rename server/src/db/migrations/{517_escalation_public_updates.sql => 520_escalation_public_updates.sql} (100%) diff --git a/.github/workflows/check-migration-numbers.yml b/.github/workflows/check-migration-numbers.yml index 594a46d5c5..6e807f7e4b 100644 --- a/.github/workflows/check-migration-numbers.yml +++ b/.github/workflows/check-migration-numbers.yml @@ -66,8 +66,27 @@ jobs: # Union of both file lists, unique by full filename. all_files=$(printf '%s\n%s\n' "$local_files" "$main_files" | sort -u | sed '/^$/d') - # Extract the leading number prefix and look for duplicates. - dupes=$(echo "$all_files" | sed 's/_.*//' | sort | uniq -d) + prefix_dupes() { + printf '%s\n' "$1" | sed '/^$/d' | sed 's/_.*//' | sort | uniq -d + } + + # Local duplicates must always fail: they are present in the checked-out + # ref (PR merge, merge-group commit, or main). Union duplicates catch the + # stale-merge case where local and current main are individually clean but + # collide with each other. If current main already has a duplicate and this + # PR fixes it, don't let the main-only duplicate make the fixing PR + # impossible to merge. + local_dupes=$(prefix_dupes "$local_files") + main_dupes=$(prefix_dupes "$main_files") + union_dupes=$(prefix_dupes "$all_files") + + dupes="$local_dupes" + for prefix in $union_dupes; do + if ! printf '%s\n' "$main_dupes" | grep -qx "$prefix"; then + dupes=$(printf '%s\n%s\n' "$dupes" "$prefix") + fi + done + dupes=$(printf '%s\n' "$dupes" | sed '/^$/d' | sort -u) if [ -n "$dupes" ]; then echo "::error::Duplicate migration number prefixes found: $dupes" diff --git a/server/src/db/migrations/517_decision_makers_track.sql b/server/src/db/migrations/519_decision_makers_track.sql similarity index 100% rename from server/src/db/migrations/517_decision_makers_track.sql rename to server/src/db/migrations/519_decision_makers_track.sql diff --git a/server/src/db/migrations/517_escalation_public_updates.sql b/server/src/db/migrations/520_escalation_public_updates.sql similarity index 100% rename from server/src/db/migrations/517_escalation_public_updates.sql rename to server/src/db/migrations/520_escalation_public_updates.sql From bbbc63dfee9060855a6b2e9f2af3b376d21d9e36 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 18 Jun 2026 00:38:11 +0200 Subject: [PATCH 3/4] docs(media-buy): clarify opportunity outcome analytics --- .../product-discovery/media-products.mdx | 2 +- .../product-discovery/refinement.mdx | 19 +++++++++++++++++++ docs/media-buy/specification.mdx | 6 ++++++ .../task-reference/create_media_buy.mdx | 2 ++ .../media-buy/task-reference/get_products.mdx | 6 ++++++ .../media-buy/scenarios/proposal_decline.yaml | 11 +++++++++-- .../source/core/opportunity-context.json | 10 ++++++++++ .../media-buy/create-media-buy-response.json | 11 +++++++++++ 8 files changed, 64 insertions(+), 3 deletions(-) diff --git a/docs/media-buy/product-discovery/media-products.mdx b/docs/media-buy/product-discovery/media-products.mdx index 77b9cc725b..9b034f4e66 100644 --- a/docs/media-buy/product-discovery/media-products.mdx +++ b/docs/media-buy/product-discovery/media-products.mdx @@ -1027,7 +1027,7 @@ To execute a committed proposal, provide the `proposal_id` and `total_budget` in The publisher converts the proposal's allocation percentages into packages: - `ch_desktop_de`: 20% × \$50,000 = \$10,000 -Finalization is the seller commitment step: it firms pricing, terms, availability, and any inventory hold. It is not buyer acceptance. `create_media_buy(proposal_id)` is the acceptance/execution step. Sellers reject attempts to execute a draft proposal with `PROPOSAL_NOT_COMMITTED`; finalize with `get_products` in `refine` mode and `action: "finalize"` before retrying create. +Finalization is the seller commitment step: it firms pricing, terms, availability, and any inventory hold. It is not buyer acceptance. `create_media_buy(proposal_id)` is the acceptance/execution step. Sellers reject attempts to execute a draft proposal with `PROPOSAL_NOT_COMMITTED`; finalize with `get_products` in `refine` mode and `action: "finalize"` before retrying create. When a versioned proposal is accepted, sellers SHOULD echo `proposal_id` and `proposal_version` on the create response so buyers can reconcile the accepted proposal with the resulting `media_buy_id`. - `ch_desktop_fr`: 30% × \$50,000 = \$15,000 - etc. diff --git a/docs/media-buy/product-discovery/refinement.mdx b/docs/media-buy/product-discovery/refinement.mdx index 933b044e40..c8eccd21b5 100644 --- a/docs/media-buy/product-discovery/refinement.mdx +++ b/docs/media-buy/product-discovery/refinement.mdx @@ -137,6 +137,21 @@ Duplicate declines for the same buyer, account, `proposal_id`, and `proposal_ver Decline feedback is aggregate-friendly. Use `reason` for seller analytics and keep `detail` short and non-identifying. Buyers MUST NOT include competing seller/platform/product names, clearing prices, bid strategy, confidential audience definitions, budget reallocations, campaign IDs, NDA-scoped terms, or other sensitive campaign information in `detail`. +### Resolving buyer outcomes + +Use the narrowest terminal signal that matches what the buyer knows: + +| Situation | Buyer behavior | +|-----------|----------------| +| Evaluated a specific proposal version and will not pursue it | Send proposal-scoped `action: "decline"` with `proposal_id`, `proposal_version`, and `reason`. This resolves that offer version, but does not necessarily close the broader opportunity. | +| No longer pursuing the planning cycle with this seller | Send `opportunity.status: "closed"` on a `buying_mode: "refine"` call, with a request-level ask such as "Close this opportunity; no further proposals requested." Use `close_reason` when known. | +| Evaluated proposal version(s) and the seller should stop working the opportunity | Do both in the same refine call: include proposal-scoped `decline` entries for each evaluated version, and set `opportunity.status: "closed"` with the appropriate `close_reason`. | +| Accepted this seller's proposal | Execute it with `create_media_buy`, carrying `proposal_id`, `proposal_version` when versioned, and the same `opportunity_id`. If the create request closes the opportunity, use `close_reason: "accepted_with_seller"`. | +| Accepted another seller or bought outside this seller | For this seller, buyers SHOULD close the opportunity with `close_reason: "purchased_elsewhere"` and SHOULD decline any evaluated proposal versions with `reason: "selected_alternative"`. Do not identify the other seller or disclose clearing terms. | +| Abandoned or no-decision outcome | If the buyer knows the seller should stop pursuing the work, close the opportunity with `close_reason: "not_pursued"` or the more specific budget/timing reason. If the buyer has no reliable terminal outcome, or the interaction was only test/speculative exploration, neither decline nor close is required. | + +`open` mode is a read surface for unresolved state; it is not itself a loss signal. Sellers SHOULD compute win/loss over closed opportunities and resolved proposal versions, not over all discovery/refinement/open calls. + ### Combining scopes All scopes work together. A single refinement call can set direction for the selection, act on specific products, and request changes to a proposal: @@ -344,6 +359,7 @@ The [Media Buy Specification](/docs/media-buy/specification#get_products) define - MUST include `proposal_version` and `reason` when sending proposal-scoped `action: "decline"` - MUST NOT execute a declined `proposal_id` + `proposal_version` with `create_media_buy` - MUST NOT send `action: "decline"` for an unversioned proposal +- SHOULD close other seller opportunities as `purchased_elsewhere` when a comparable buy is accepted with another seller, and SHOULD decline any evaluated proposal versions from those sellers as `selected_alternative` **Sales agents:** - MUST omit products with `action: "omit"` from the response @@ -351,6 +367,9 @@ The [Media Buy Specification](/docs/media-buy/specification#get_products) define - MUST treat proposal-scoped `action: "decline"` as a terminal not-pursued outcome for the referenced `proposal_id` + `proposal_version` - MUST return `refinement_applied` for requests containing proposal-scoped `action: "decline"`; `status: "applied"` is the signal that the decline is recorded - MUST reject `create_media_buy` attempts against a declined proposal version with `INVALID_STATE` +- MUST reject stale `proposal_version` execution for a known proposal with `CONFLICT` +- MUST support `buying_mode: "open"` as a read of unresolved products/proposals when the seller persists that state +- MUST treat duplicate declines for the same buyer, account, `proposal_id`, and `proposal_version` as idempotent and MUST NOT double-count them - MUST return products with `action: "include"`, with updated pricing - SHOULD fulfill the `ask` on product entries with `action: "include"` - SHOULD return additional products similar to those with `action: "more_like_this"`, plus the original product diff --git a/docs/media-buy/specification.mdx b/docs/media-buy/specification.mdx index 2b25c7cac8..f4fcde79dc 100644 --- a/docs/media-buy/specification.mdx +++ b/docs/media-buy/specification.mdx @@ -229,6 +229,7 @@ Discover advertising inventory using natural language briefs or explicit wholesa - Orchestrators MUST NOT send `action: "decline"` for an unversioned proposal - Orchestrators MAY include `opportunity` on `brief`, `open`, and `refine` requests to identify or narrow a buyer planning cycle across related discovery, proposal outcome, and `create_media_buy` calls - Orchestrators MUST treat `opportunity_id` as an opaque seller/account-scoped identifier and MUST NOT embed advertiser names, campaign names, audience names, budget references, competitor names, internal authorization IDs, personal data, or confidential campaign identifiers +- Orchestrators SHOULD include `opportunity.intent` when the buyer can distinguish test/speculative exploration from a real planning cycle or live RFP; sales agents SHOULD NOT infer that distinction from `opportunity.phase` alone - Orchestrators MUST NOT include multiple entries for the same product ID or proposal ID in a single `refine` array - Sales agents MUST return products matching the brief criteria when a brief is provided - Sales agents MUST include `product_id` and `pricing_options` for each product @@ -236,6 +237,7 @@ Discover advertising inventory using natural language briefs or explicit wholesa - Sales agents MUST handle `buying_mode: "open"` by returning unresolved products and proposals already associated with the caller/account, optionally narrowed by `opportunity.opportunity_id` - Sales agents MUST NOT create new products, proposals, firm prices, inventory holds, or proposal versions solely to satisfy an `open` request - Sales agents SHOULD complete `open` requests synchronously and MUST NOT route them through the async Submitted arm solely because `push_notification_config` is present +- For win/loss analytics, sales agents SHOULD compute denominators from buyer-resolved outcomes: closed opportunities and declined or accepted proposal versions. Sales agents SHOULD NOT count every `get_products` call, `open` read, omitted proposal, or exploratory discovery response as a loss. **Refinement requirements:** @@ -248,6 +250,7 @@ Each `get_products` request with `buying_mode: "refine"` is self-contained — s - Sales agents MUST return `refinement_applied` for any request containing proposal-scoped `action: "decline"`; `status: "applied"` means the decline was recorded, while `status: "partial"` or `"unable"` means the buyer MUST NOT treat the proposal as declined - Sales agents MUST treat duplicate declines for the same buyer, account, `proposal_id`, and `proposal_version` as idempotent and MUST NOT double-count feedback - Orchestrators MUST NOT execute a declined `proposal_id` + `proposal_version` with `create_media_buy` +- Sales agents MUST reject stale `proposal_version` execution for a known proposal with `CONFLICT` - Sales agents MUST return products with `action: "include"`, with updated pricing - Sales agents SHOULD fulfill the `ask` on product entries with `action: "include"` - Sales agents SHOULD return additional products similar to those with `action: "more_like_this"`, plus the original product @@ -257,8 +260,10 @@ Each `get_products` request with `buying_mode: "refine"` is self-contained — s - Sales agents that return `refinement_applied` MUST echo `scope` on each entry and MUST echo `product_id` / `proposal_id` for product and proposal scopes, so orchestrators can cross-validate alignment. For proposal entries where the buyer supplied `proposal_version`, sales agents MUST echo `proposal_version`. - Sales agents MAY return proposals alongside products in refine mode, even when the orchestrator did not include proposal entries - To close an opportunity without buying, orchestrators SHOULD send `buying_mode: "refine"` with the same `opportunity_id`, `opportunity.status: "closed"`, optional `close_reason` / `close_detail`, and a request-level refine entry such as "Close this opportunity; no further proposals requested." +- When a comparable buy is accepted with another seller, orchestrators SHOULD close this seller's related opportunity as `purchased_elsewhere` and SHOULD decline any evaluated proposal versions with `reason: "selected_alternative"` without identifying the other seller or disclosing clearing terms - Sales agents that persist opportunity state SHOULD record buyer-reported opportunity closure, but opportunity closure has no dedicated protocol acknowledgement surface beyond the ordinary task response in this version - Opportunity closure does not replace proposal decline; orchestrators that need acknowledgement for an evaluated proposal version MUST send proposal-scoped `action: "decline"` for that `proposal_id` + `proposal_version` +- Stateful conformance requires runtime state, not only schema validation: sales agents that support versioned proposals MUST persist proposal-version state, duplicate decline idempotency, stale-version rejection, declined-version create rejection, and unresolved-state retrieval through `buying_mode: "open"` ### list_creative_formats @@ -288,6 +293,7 @@ Create a media buy from selected packages or execute a proposal. - Orchestrators SHOULD carry the same `opportunity_id` into `create_media_buy` when the buy came from prior `get_products` calls - Orchestrators MUST use `close_reason: "accepted_with_seller"` when `create_media_buy` also sends `opportunity.status: "closed"` with a `close_reason` - Sales agents MUST return `media_buy_id` on successful creation +- Sales agents SHOULD echo `proposal_id` and, when supplied, `proposal_version` on successful proposal execution so buyers can reconcile the accepted proposal with the resulting `media_buy_id` - Sales agents MUST return `confirmed_at` on successful creation. The value is the seller commitment timestamp, or `null` when the response creates a provisional buy that is not yet committed. - Sales agents MUST return `revision` on successful creation - Sales agents MUST return `creative_deadline` indicating when creatives must be uploaded diff --git a/docs/media-buy/task-reference/create_media_buy.mdx b/docs/media-buy/task-reference/create_media_buy.mdx index bb3a4777cd..8102db3bef 100644 --- a/docs/media-buy/task-reference/create_media_buy.mdx +++ b/docs/media-buy/task-reference/create_media_buy.mdx @@ -181,6 +181,8 @@ If the proposal carried `proposal_version`, buyers MUST include that version in When `proposal_version` is provided, sellers MUST verify it matches the executable offer state. If the proposal is known but no longer at that version, reject with `CONFLICT` so the buyer re-reads or refines before retrying. If the proposal/version cannot be resolved for the caller, reject with `PROPOSAL_NOT_FOUND`. +On successful proposal execution, sellers SHOULD echo `proposal_id` and, when supplied, `proposal_version` on the `create_media_buy` success response or task completion artifact. This is additive reconciliation metadata: the authoritative created resource remains `media_buy_id`. + If the buyer used `opportunity` during discovery or refinement, it SHOULD carry the same `opportunity_id` into `create_media_buy`. `opportunity_id` connects the accepted buy back to the planning cycle; it is not an idempotency key and does not change execution semantics. `create_media_buy` is the in-protocol close-won signal for an opportunity. If the buyer sends `opportunity.status: "closed"` on `create_media_buy`, the only valid `close_reason` is `accepted_with_seller`; loss-style reasons such as `not_pursued`, `selected_alternative`, or `purchased_elsewhere` belong on a `get_products` refine call that closes the opportunity without creating a buy. diff --git a/docs/media-buy/task-reference/get_products.mdx b/docs/media-buy/task-reference/get_products.mdx index fc18f53952..c4d33c578e 100644 --- a/docs/media-buy/task-reference/get_products.mdx +++ b/docs/media-buy/task-reference/get_products.mdx @@ -177,6 +177,7 @@ Use `opportunity` for curated discovery (`buying_mode: "brief"`), unresolved-sta "opportunity": { "opportunity_id": "opp_01HX7W8S3Q9K2M4N6P0R5T1V", "phase": "exploratory", + "intent": "speculative", "planning_horizon": { "start": "2026-07-01", "end": "2026-09-30" @@ -190,6 +191,7 @@ Use `opportunity` for curated discovery (`buying_mode: "brief"`), unresolved-sta |-------|------|----------|-------------| | `opportunity_id` | string | Yes | Opaque buyer-assigned identifier for this planning cycle with this seller. Stable across related `get_products`, proposal decline, and `create_media_buy` calls. Do not embed advertiser names, campaign names, audience names, budget references, competitor names, internal authorization IDs, globally correlatable values, personal data, or confidential campaign identifiers. | | `phase` | string | No | `"exploratory"`: early market scan or option gathering. `"planning"`: a concrete opportunity is being planned, but the buyer is not yet actively requesting seller proposals. `"active_sourcing"`: the buyer is actively requesting seller responses or proposals, but has not requested proposal finalization, an inventory hold, or a committed media buy. | +| `intent` | string | No | `"test"`: sandbox, QA, demo, certification, or buyer-agent exploration that should not be counted as a real sales opportunity. `"speculative"`: possible campaign exploration without a client-approved brief, budget, or RFP. `"planning"`: real planning cycle, not yet a live RFP. `"live_rfp"`: active client brief, RFP, or equivalent buying process. `intent` is advisory analytics context; `phase` still describes workflow maturity. | | `status` | string | No | `"open"` or `"closed"`. Use `"closed"` when the buyer is no longer pursuing this planning cycle with the seller. | | `close_reason` | string | Conditional | Optional when `status` is `"closed"`: `accepted_with_seller`, `purchased_elsewhere`, `selected_alternative`, `not_pursued`, `budget_changed`, `timing_changed`, or `other`. Not valid unless `status` is `"closed"`. | | `close_detail` | string | Conditional | Optional short, non-identifying explanation when `status` is `"closed"`. Not valid unless `status` is `"closed"`. | @@ -198,12 +200,16 @@ Use `opportunity` for curated discovery (`buying_mode: "brief"`), unresolved-sta `opportunity_id` is not an idempotency key. It spans many calls. `idempotency_key` identifies one mutating request attempt. `proposal_id` + `proposal_version` identifies one seller offer state. Opportunity closure is broader than proposal decline: `opportunity.status: "closed"` says the planning cycle is done, while proposal-scoped `action: "decline"` says the buyer is not pursuing one exact proposal version. +`phase` and `intent` answer different questions. `phase` says where the buyer is in the workflow; `intent` says whether the work represents a real buying motion. For example, a buyer-agent can send `phase: "exploratory"` with `intent: "test"` for sandbox exploration, or `phase: "exploratory"` with `intent: "planning"` for early work on a real planning cycle. Sellers SHOULD use `intent` rather than inferring real vs. speculative status from `phase` alone. + To close an opportunity without buying, send `buying_mode: "refine"` with the same `opportunity_id`, `opportunity.status: "closed"`, an optional `close_reason` / `close_detail`, and a request-level refine entry such as `{ "scope": "request", "ask": "Close this opportunity; no further proposals requested." }`. Sellers that persist opportunity state SHOULD record this buyer-reported closure. This does not replace proposal-level acknowledgement: decline each evaluated proposal version separately when the buyer needs the seller to acknowledge that exact proposal outcome. When closing because the buyer accepted this seller's offer, carry the same `opportunity_id` into `create_media_buy`. If that create request marks the opportunity closed, `close_reason` MUST be `accepted_with_seller`. Sellers MAY use `opportunity` for queueing, SLA expectations, async routing, human-review prioritization, CRM or pipeline labels, and aggregate win-rate analytics. Sellers MUST NOT use `opportunity` to alter price, rate card, product eligibility, inventory availability, or buyability. Opportunity closure is buyer-reported advisory state; it has no dedicated protocol acknowledgement surface beyond the ordinary task response in this version. `response_deadline` does not require or authorize firm pricing, inventory availability confirmation, inventory hold, proposal finalization, or buyer/seller commitment. +For win/loss analytics, sellers SHOULD compute denominators from buyer-resolved outcomes: closed opportunities and declined or accepted proposal versions. Sellers SHOULD NOT count every `get_products` call, `open` read, omitted proposal, or exploratory discovery response as a loss. When `intent` is present, sellers SHOULD exclude `intent: "test"` from sales win/loss metrics and SHOULD segment or down-weight `intent: "speculative"` according to their analytics policy. + If `opportunity` is absent, sellers MUST treat the opportunity context as unknown. Absence MUST NOT reduce response quality, product availability, or pricing fairness. ### Open mode diff --git a/static/compliance/source/protocols/media-buy/scenarios/proposal_decline.yaml b/static/compliance/source/protocols/media-buy/scenarios/proposal_decline.yaml index 2022f6efed..2419116d57 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/proposal_decline.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/proposal_decline.yaml @@ -2,7 +2,7 @@ id: media_buy_seller/proposal_decline version: "1.0.0" title: "Seller handles proposal decline" category: media_buy_seller -summary: "Validates proposal decline acknowledgement, duplicate-decline idempotency, and create_media_buy rejection for a declined proposal version." +summary: "Validates stateful proposal decline acknowledgement, duplicate-decline idempotency, and create_media_buy rejection for a declined proposal version." track: media_buy required_tools: - get_products @@ -18,7 +18,11 @@ narrative: | get_products in refine mode using proposal-scoped action: decline. The seller acknowledges the exact proposal_id + proposal_version in refinement_applied, treats duplicate declines as idempotent, and rejects any later attempt to - execute that declined proposal version with INVALID_STATE. + execute that declined proposal version with INVALID_STATE. This is a + stateful conformance scenario: schema-valid payloads are not sufficient. + The seller runtime must persist proposal version state, record terminal + decline outcomes once, and enforce that declined versions cannot later be + accepted. agent: interaction_model: media_buy_seller @@ -38,6 +42,9 @@ prerequisites: description: | The seller must support proposals (media_buy.supports_proposals: true) and include proposal_version on proposals it expects buyers to resolve explicitly. + The seller must persist proposal-version state across phases; otherwise it + cannot prove duplicate decline idempotency or declined-version create + rejection. test_kit: "test-kits/acme-outdoor.yaml" phases: diff --git a/static/schemas/source/core/opportunity-context.json b/static/schemas/source/core/opportunity-context.json index 4e421893cc..62dced7216 100644 --- a/static/schemas/source/core/opportunity-context.json +++ b/static/schemas/source/core/opportunity-context.json @@ -22,6 +22,16 @@ ], "description": "Buyer-declared stage for the opportunity. 'exploratory': early market scan or option gathering, often before budget, timing, or vendor shortlist are concrete. 'planning': a concrete opportunity is being planned, with likely campaign timing, budget, or internal planning work, but the buyer is not yet actively requesting seller proposals. 'active_sourcing': the buyer is actively requesting seller responses or proposals for a specific opportunity, but has not requested proposal finalization, an inventory hold, or a committed media buy." }, + "intent": { + "type": "string", + "enum": [ + "test", + "speculative", + "planning", + "live_rfp" + ], + "description": "Advisory buyer-declared intent for the opportunity. This is distinct from phase: phase describes workflow maturity; intent describes whether the opportunity represents a real buying motion. 'test' means sandbox, QA, demo, certification, or buyer-agent exploration that should not be counted as a real sales opportunity. 'speculative' means the buyer is exploring a possible campaign without a client-approved brief, budget, or RFP. 'planning' means a real planning cycle exists but is not yet a live RFP. 'live_rfp' means the buyer is sourcing against an active client brief, RFP, or equivalent buying process. Sellers MAY use intent for queueing, CRM labeling, and aggregate analytics, but MUST NOT use it to alter price, rate card, product eligibility, inventory availability, or buyability." + }, "status": { "type": "string", "enum": [ diff --git a/static/schemas/source/media-buy/create-media-buy-response.json b/static/schemas/source/media-buy/create-media-buy-response.json index eca8680556..1599de6170 100644 --- a/static/schemas/source/media-buy/create-media-buy-response.json +++ b/static/schemas/source/media-buy/create-media-buy-response.json @@ -23,6 +23,17 @@ "description": "Seller's unique identifier for the created media buy", "x-entity": "media_buy" }, + "proposal_id": { + "type": "string", + "description": "When the media buy was created by accepting a proposal, echoes the proposal_id from the create_media_buy request so buyers can reconcile the accepted seller offer with the resulting media_buy_id.", + "x-entity": "proposal" + }, + "proposal_version": { + "type": "string", + "description": "When the media buy was created by accepting a versioned proposal, echoes the proposal_version from the create_media_buy request. Sellers SHOULD echo this whenever the request carried proposal_version, including task completion artifacts for submitted create flows.", + "minLength": 1, + "maxLength": 255 + }, "account": { "$ref": "/schemas/core/account.json", "description": "Account billed for this media buy. Includes advertiser, billing proxy (if any), and rate card applied." From 5fde06a0353918a0bc0d982b52f94624b6e9f63e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 18 Jun 2026 00:49:37 +0200 Subject: [PATCH 4/4] fix: register proposal entity annotation --- docs/contributing/x-entity-annotation.md | 2 +- scripts/x-entity-field-map.json | 1 + static/schemas/source/core/proposal.json | 3 ++- static/schemas/source/core/x-entity-types.json | 2 ++ static/schemas/source/media-buy/create-media-buy-request.json | 3 ++- static/schemas/source/media-buy/get-products-request.json | 3 ++- static/schemas/source/media-buy/get-products-response.json | 3 ++- 7 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/contributing/x-entity-annotation.md b/docs/contributing/x-entity-annotation.md index 9c4d52aa4f..5ccb6bcc8a 100644 --- a/docs/contributing/x-entity-annotation.md +++ b/docs/contributing/x-entity-annotation.md @@ -105,7 +105,7 @@ High-level groupings (see the registry for full descriptions). *Categories below |---|---| | Brand & rights | `advertiser_brand`, `rights_holder_brand`, `rights_grant` | | Account & party | `account`, `operator` | -| Media buy | `media_buy`, `package`, `product`, `product_pricing_option` | +| Media buy | `opportunity`, `proposal`, `media_buy`, `package`, `product`, `product_pricing_option` | | Creative | `creative`, `creative_format` | | Data & targeting | `audience`, `signal`, `signal_activation_id`, `event_source` | | Lists & catalogs | `collection_list`, `property_list`, `catalog`, `property` | diff --git a/scripts/x-entity-field-map.json b/scripts/x-entity-field-map.json index d7eb433096..43b40296a9 100644 --- a/scripts/x-entity-field-map.json +++ b/scripts/x-entity-field-map.json @@ -2,6 +2,7 @@ "$comment": "Field-name → x-entity map for scripts/add-x-entity-annotations.mjs. Lists the unambiguous id-shaped fields and the registered entity type each one resolves to. Extend this file for the next domain sweep instead of hand-editing schemas. See docs/contributing/x-entity-annotation.md for placement rules (root-level on composite types, inline on leaves, shared types preferred over per-site).", "fields": { "media_buy_id": "media_buy", + "proposal_id": "proposal", "package_id": "package", "product_id": "product", "creative_id": "creative", diff --git a/static/schemas/source/core/proposal.json b/static/schemas/source/core/proposal.json index f812d75372..6022c93de5 100644 --- a/static/schemas/source/core/proposal.json +++ b/static/schemas/source/core/proposal.json @@ -8,7 +8,8 @@ "proposal_id": { "type": "string", "description": "Unique identifier for this proposal. Used to finalize a draft proposal and to execute a committed proposal via create_media_buy.", - "maxLength": 255 + "maxLength": 255, + "x-entity": "proposal" }, "proposal_version": { "type": "string", diff --git a/static/schemas/source/core/x-entity-types.json b/static/schemas/source/core/x-entity-types.json index a84b290dba..0f4405822d 100644 --- a/static/schemas/source/core/x-entity-types.json +++ b/static/schemas/source/core/x-entity-types.json @@ -11,6 +11,7 @@ "account", "operator", "opportunity", + "proposal", "media_buy", "package", "product", @@ -50,6 +51,7 @@ "account": "Billing/scope account in the seller's namespace. `account_id` or the natural-key `{brand, operator}` form; both resolve to the same entity.", "operator": "An operator (seller) identity, typically by domain. Distinct from `account` — one operator issues many accounts.", "opportunity": "A buyer-supplied planning-cycle/opportunity identifier stable across related get_products, proposal outcome, and create_media_buy calls. The value is buyer-assigned and scoped to the seller/account; it is not an idempotency key, seller offer id, insertion order, or media buy id.", + "proposal": "A seller-issued proposal or offer returned by get_products. `proposal_id` identifies the offer across refine/finalize/decline actions and proposal-mode create_media_buy execution. Scoped to the issuing seller/account and distinct from `opportunity`, which is the buyer's planning-cycle identifier, and from `proposal_version`, which names a specific offer state for concurrency and outcome reporting.", "media_buy": "A media buy / campaign. `media_buy_id` across media-buy/* schemas.", "package": "A line item within a media buy. `package_id` across media-buy/* schemas.", "product": "A seller inventory product. `product_id` across media-buy/* and core/product.json.", diff --git a/static/schemas/source/media-buy/create-media-buy-request.json b/static/schemas/source/media-buy/create-media-buy-request.json index 9d81595202..1c1c1fcbfc 100644 --- a/static/schemas/source/media-buy/create-media-buy-request.json +++ b/static/schemas/source/media-buy/create-media-buy-request.json @@ -62,7 +62,8 @@ }, "proposal_id": { "type": "string", - "description": "ID of a committed proposal from get_products to execute. When provided with total_budget, the publisher converts the proposal's allocation percentages into packages automatically. Alternative to providing packages array. If the referenced proposal has proposal_status: 'draft', the seller MUST reject with PROPOSAL_NOT_COMMITTED; the buyer finalizes first via get_products refine action 'finalize'." + "description": "ID of a committed proposal from get_products to execute. When provided with total_budget, the publisher converts the proposal's allocation percentages into packages automatically. Alternative to providing packages array. If the referenced proposal has proposal_status: 'draft', the seller MUST reject with PROPOSAL_NOT_COMMITTED; the buyer finalizes first via get_products refine action 'finalize'.", + "x-entity": "proposal" }, "proposal_version": { "type": "string", diff --git a/static/schemas/source/media-buy/get-products-request.json b/static/schemas/source/media-buy/get-products-request.json index d83f53ddcd..4b6152f7f1 100644 --- a/static/schemas/source/media-buy/get-products-request.json +++ b/static/schemas/source/media-buy/get-products-request.json @@ -191,7 +191,8 @@ "proposal_id": { "type": "string", "minLength": 1, - "description": "Proposal ID from a previous get_products response." + "description": "Proposal ID from a previous get_products response.", + "x-entity": "proposal" }, "proposal_version": { "type": "string", diff --git a/static/schemas/source/media-buy/get-products-response.json b/static/schemas/source/media-buy/get-products-response.json index dfaecfe82e..cc674f2401 100644 --- a/static/schemas/source/media-buy/get-products-response.json +++ b/static/schemas/source/media-buy/get-products-response.json @@ -151,7 +151,8 @@ }, "proposal_id": { "type": "string", - "description": "Echoes proposal_id from the corresponding refine entry." + "description": "Echoes proposal_id from the corresponding refine entry.", + "x-entity": "proposal" }, "proposal_version": { "type": "string",