diff --git a/.changeset/add-brief-stage-context.md b/.changeset/add-brief-stage-context.md new file mode 100644 index 0000000000..58d619f1b2 --- /dev/null +++ b/.changeset/add-brief-stage-context.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Add optional `brief_stage` context to `get_products` requests so buyers can declare pre-buy stage, planning horizon, and response deadline without affecting pricing or product eligibility. diff --git a/docs/media-buy/task-reference/get_products.mdx b/docs/media-buy/task-reference/get_products.mdx index a1e0f98070..91ffaa6a26 100644 --- a/docs/media-buy/task-reference/get_products.mdx +++ b/docs/media-buy/task-reference/get_products.mdx @@ -133,6 +133,7 @@ asyncio.run(discover_with_filters()) |-----------|------|----------|-------------| | `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"`. | +| `brief_stage` | BriefStage | No | Buyer-supplied pre-buy lifecycle context for the brief. See [Brief stage](#brief-stage) below. Absence means unknown, not `exploratory`, `planning`, or `active_sourcing`. Meaningful for `"brief"` and `"refine"` requests; sellers ignore it for `"wholesale"` feed reads. | | `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. | | `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. | @@ -272,6 +273,46 @@ If a product has both USD and EUR media pricing and the buyer sends `pricing_cur *At least one of `min` or `max` must be specified. +### Brief stage + +`brief_stage` lets a buyer tell the seller where the brief sits in the buyer's pre-buy lifecycle. It is advisory operational metadata, not a purchase commitment, insertion order, inventory hold, or authorization. Seller commitment to firm pricing or an inventory hold remains expressed through proposal `finalize`; buyer acceptance and execution remain expressed through `create_media_buy`. + +Use `brief_stage` for curated discovery (`buying_mode: "brief"`) 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": "Q4 launch campaign for a new electric bicycle line", + "brand": { + "domain": "acmecorp.com" + }, + "brief_stage": { + "phase": "active_sourcing", + "planning_horizon": { + "start": "2026-10-01", + "end": "2026-12-31" + }, + "response_deadline": "2026-08-15T21:00:00Z" + } +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `phase` | string | Yes | `"exploratory"`: early market scan or option gathering with no active proposal process. `"planning"`: concrete pre-buy planning with likely campaign timing or budget, but not yet active seller sourcing. `"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. | +| `planning_horizon.start` | string | No* | Inclusive ISO 8601 date (`YYYY-MM-DD`) for the advisory planning or campaign window. | +| `planning_horizon.end` | string | No* | Inclusive ISO 8601 date (`YYYY-MM-DD`) for the advisory planning or campaign window. | +| `response_deadline` | string | No | ISO 8601 date-time by which the buyer would like a seller response or proposal update. | + +*If `planning_horizon` is present, at least one of `start` or `end` is required. When both are present, `start` SHOULD be on or before `end`. + +`planning_horizon` is not a hard availability filter. Use `filters.start_date` and `filters.end_date` when the buyer needs products available for specific flight dates. + +If `brief_stage` is absent, the seller MUST treat the pre-buy stage as unknown. The seller MUST NOT infer `exploratory`, `planning`, or `active_sourcing` from absence alone, and absence MUST NOT reduce response quality, product availability, or pricing fairness. + +Sellers MAY use `brief_stage` for queueing, SLA expectations, async routing, human-review prioritization, and internal CRM or pipeline labels. Sellers MUST NOT use `brief_stage` to alter price, rate card, product eligibility, inventory availability, or buyability. `response_deadline` does not require or authorize firm pricing, inventory availability confirmation, inventory hold, proposal finalization, or buyer/seller commitment. + ### Refine array The `refine` array is a list of change requests. Each entry declares a `scope` and what the buyer is asking for. At least one entry is required. The seller considers all entries together when composing the response, and replies to each via `refinement_applied`. diff --git a/static/schemas/source/media-buy/get-products-request.json b/static/schemas/source/media-buy/get-products-request.json index cf35a903f3..a04035060f 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 or refining advertising products. buying_mode declares the product discovery mode: 'brief' for curated discovery, 'wholesale' for raw wholesale product feed access, or 'refine' to iterate on known products and proposals.", "type": "object", "allOf": [ { @@ -44,7 +44,7 @@ "wholesale", "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. 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 the product discovery mode 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'." }, "brief": { "type": "string", @@ -52,7 +52,7 @@ }, "refine": { "type": "array", - "description": "Array of change requests for iterating on products and proposals from a previous get_products response. Each entry declares a scope (request, product, or proposal) and what the buyer is asking for. Only valid when buying_mode is 'refine'. The seller responds to each entry via refinement_applied in the response, matched by position.\n\nFinalize-exclusivity rule: if any entry has `action: 'finalize'`, ALL entries in the array MUST be proposal-scoped with `action: 'finalize'` — mixing finalize entries with `include`/`omit` entries or with request- / product-scoped entries MUST be rejected by the seller with `INVALID_REQUEST`. Finalize is a commit, not a refinement; the buyer expressing intent to commit means refinements have already converged. 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).\n\nMulti-finalize semantics: multiple finalize entries against different `proposal_id` values in a single call are allowed and MUST be **atomic at the observation point** — sellers MUST NOT return a success response unless every named proposal has both completed and been persisted as committed. Pre-commit validation runs before any side-effects (inventory pull, terms lock, governance attestation); if any proposal fails validation, the seller MUST reject the entire call without committing any of the named proposals. There is no rollback operation in the spec — an `unfinalize` would itself be a new mutation surface; the atomicity guarantee runs entirely on the seller's pre-commit validation gate, not on post-commit reversal. Sellers that cannot guarantee atomic pre-commit validation MUST reject multi-finalize arrays with `MULTI_FINALIZE_UNSUPPORTED` (preferred — distinguishes seller-side capability gap from a malformed request) or `INVALID_REQUEST` (acceptable fallback for sellers on a pre-3.1 error catalog). If a mid-commit failure occurs *after* validation passed but before all proposals persist (e.g., a downstream ad server fails between commits one and two), the seller MUST return `INTERNAL_ERROR` with `refinement_applied[]` carrying per-position outcomes — the spec does NOT define a recovery path for this case, and buyers SHOULD treat the resulting state as undefined and re-read via `get_media_buys` / equivalent before retrying. Buyers MUST NOT assume multi-finalize support without a successful first attempt — there is no capability flag for this; the failure response is the discovery surface. Buyers whose intent specifically requires atomic commit (e.g., budget-shared proposals where one finalizing without the other is incoherent) MUST be prepared to abandon the intent if the seller returns `MULTI_FINALIZE_UNSUPPORTED` — there is no recovery for that loss of buyer intent beyond sequencing single-finalize calls and accepting the looser commit guarantee.", + "description": "Array of change requests for iterating on products and proposals from a previous get_products response. Each entry declares a scope (request, product, or proposal) and what the buyer is asking for. Only valid when buying_mode is 'refine'. The seller responds to each entry via refinement_applied in the response, matched by position.\n\nFinalize-exclusivity rule: if any entry has `action: 'finalize'`, ALL entries in the array MUST be proposal-scoped with `action: 'finalize'` — mixing finalize entries with `include`/`omit` entries or with request- / product-scoped entries MUST be rejected by the seller with `INVALID_REQUEST`. Finalize is a commit, not a refinement; a buyer asking to commit means refinements have already converged. 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).\n\nMulti-finalize semantics: multiple finalize entries against different `proposal_id` values in a single call are allowed and MUST be **atomic at the observation point** — sellers MUST NOT return a success response unless every named proposal has both completed and been persisted as committed. Pre-commit validation runs before any side-effects (inventory pull, terms lock, governance attestation); if any proposal fails validation, the seller MUST reject the entire call without committing any of the named proposals. There is no rollback operation in the spec — an `unfinalize` would itself be a new mutation surface; the atomicity guarantee runs entirely on the seller's pre-commit validation gate, not on post-commit reversal. Sellers that cannot guarantee atomic pre-commit validation MUST reject multi-finalize arrays with `MULTI_FINALIZE_UNSUPPORTED` (preferred — distinguishes seller-side capability gap from a malformed request) or `INVALID_REQUEST` (acceptable fallback for sellers on a pre-3.1 error catalog). If a mid-commit failure occurs *after* validation passed but before all proposals persist (e.g., a downstream ad server fails between commits one and two), the seller MUST return `INTERNAL_ERROR` with `refinement_applied[]` carrying per-position outcomes — the spec does NOT define a recovery path for this case, and buyers SHOULD treat the resulting state as undefined and re-read via `get_media_buys` / equivalent before retrying. Buyers MUST NOT assume multi-finalize support without a successful first attempt — there is no capability flag for this; the failure response is the discovery surface. Buyers whose campaign requires atomic commit (e.g., budget-shared proposals where one finalizing without the other is incoherent) MUST be prepared to abandon that requirement if the seller returns `MULTI_FINALIZE_UNSUPPORTED` — there is no recovery beyond sequencing single-finalize calls and accepting the looser commit guarantee.", "minItems": 1, "items": { "type": "object", @@ -158,6 +158,59 @@ "$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." }, + "brief_stage": { + "type": "object", + "description": "Optional buyer-supplied context describing where this brief sits in the buyer's pre-buy lifecycle. Meaningful only for buying_mode: 'brief' and buying_mode: 'refine'; sellers MUST ignore it for buying_mode: 'wholesale' feed reads. Absence means the stage is unknown: sellers MUST NOT infer exploratory, planning, or active_sourcing from absence alone, and absence MUST NOT reduce response quality, product availability, or pricing fairness. Sellers MAY use the declared phase, planning_horizon, and response_deadline for queueing, SLA expectations, async routing, human-review prioritization, and internal CRM or pipeline labels. Sellers MUST NOT use brief_stage to alter price, rate card, product eligibility, inventory availability, or buyability. The field is advisory operational metadata, not a purchase commitment, insertion order, inventory hold, or authorization. Seller commitment to firm pricing or an inventory hold remains expressed through proposal finalization; buyer acceptance and execution remain expressed through create_media_buy.", + "properties": { + "phase": { + "type": "string", + "enum": [ + "exploratory", + "planning", + "active_sourcing" + ], + "description": "Buyer-declared stage for this brief. 'exploratory': early market scan or option gathering with no active proposal process. 'planning': concrete pre-buy planning with likely campaign timing or budget, but the buyer is not yet actively sourcing 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." + }, + "planning_horizon": { + "type": "object", + "description": "Advisory planning or campaign window for the opportunity. This is context for seller routing and planning, not a hard availability filter; use filters.start_date and filters.end_date for availability constraints. If both start and end are present, start SHOULD be on or before end.", + "properties": { + "start": { + "type": "string", + "format": "date", + "description": "Inclusive start date for the advisory planning or campaign window." + }, + "end": { + "type": "string", + "format": "date", + "description": "Inclusive end date for the advisory planning or campaign window." + } + }, + "anyOf": [ + { + "required": [ + "start" + ] + }, + { + "required": [ + "end" + ] + } + ], + "additionalProperties": false + }, + "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": [ + "phase" + ], + "additionalProperties": false + }, "account": { "$ref": "/schemas/core/account-ref.json", "description": "Account for product lookup. Returns products with pricing specific to this account's rate card." diff --git a/tests/example-validation-simple.test.cjs b/tests/example-validation-simple.test.cjs index 1b393fa4a2..77ae01a1c9 100644 --- a/tests/example-validation-simple.test.cjs +++ b/tests/example-validation-simple.test.cjs @@ -408,6 +408,76 @@ async function runTests() { 'get_products request' ); + await validateExample( + { + "buying_mode": "brief", + "brief": "Q4 launch campaign for a new electric bicycle line", + "brand": { + "domain": "acmecorp.com" + }, + "brief_stage": { + "phase": "active_sourcing", + "planning_horizon": { + "start": "2026-10-01", + "end": "2026-12-31" + }, + "response_deadline": "2026-08-15T21:00:00Z" + } + }, + '/schemas/media-buy/get-products-request.json', + 'get_products request with brief_stage context' + ); + + await validateExample( + { + "buying_mode": "brief", + "brief": "Explore CTV options for a future launch", + "brand": { + "domain": "acmecorp.com" + }, + "brief_stage": { + "phase": "exploratory" + } + }, + '/schemas/media-buy/get-products-request.json', + 'get_products request with minimal brief_stage' + ); + + await expectInvalid( + { + "buying_mode": "brief", + "brief": "Q4 launch campaign for a new electric bicycle line", + "brand": { + "domain": "acmecorp.com" + }, + "brief_stage": { + "planning_horizon": { + "start": "2026-10-01" + } + } + }, + '/schemas/media-buy/get-products-request.json', + 'get_products brief_stage without phase is rejected', + ['phase'] + ); + + await expectInvalid( + { + "buying_mode": "brief", + "brief": "Q4 launch campaign for a new electric bicycle line", + "brand": { + "domain": "acmecorp.com" + }, + "brief_stage": { + "phase": "planning", + "planning_horizon": {} + } + }, + '/schemas/media-buy/get-products-request.json', + 'get_products empty brief_stage planning_horizon is rejected', + ['must match a schema in anyOf'] + ); + await validateExample( { "signal_spec": "High-income households",