diff --git a/.changeset/add-seller-optimized-media-buy-budgets.md b/.changeset/add-seller-optimized-media-buy-budgets.md new file mode 100644 index 0000000000..9346ed95d8 --- /dev/null +++ b/.changeset/add-seller-optimized-media-buy-budgets.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Add seller-optimized shared budgets and explicit bidding policy placement across media-buy packages. Media buys and proposals can delegate cross-package allocation to the seller under an aggregate budget, optimization goals, pacing, package caps, and soft minimum-spend targets, while preserving fixed package budgets as the default. A new media-buy/package `bidding` block separates objective functions from automatic bidding, manual bids, auction ceilings, average-cost controls, and ROAS controls, with complete-block inheritance and authored-scope-preserving readback. Media-buy outcome controls bind to allocation goals in seller-optimized mode and compatible package goals in fixed mode; package controls bind to package goals. All canonical monetary fields use one media-buy currency, ROAS event sources declare supported value currencies, and structured capabilities advertise support by scope, allocation context, mode, strength, and combination. diff --git a/docs/media-buy/advanced-topics/pricing-models.mdx b/docs/media-buy/advanced-topics/pricing-models.mdx index 9065676ee5..26783c0e68 100644 --- a/docs/media-buy/advanced-topics/pricing-models.mdx +++ b/docs/media-buy/advanced-topics/pricing-models.mdx @@ -317,7 +317,6 @@ Buyers should verify the measurement provider meets their campaign requirements "pricing_model": "cpc", "currency": "USD", "floor_price": 0.50, - "max_bid": true, "price_guidance": { "p50": 1.20, "p75": 2.00 @@ -582,7 +581,7 @@ The value can be a sellers.json domain, an AdCP account ID, or a human-readable The invariant is: `list_price` with all `fee` and `discount` adjustments applied sequentially equals `fixed_price`. Commission and settlement adjustments don't participate — they're disclosed for transparency. If no fee or discount adjustments are present, `fixed_price` must equal `list_price`. -This invariant applies only when `fixed_price` is present on the parent object. For auction-based packages (where only `bid_price` exists), `price_breakdown` is informational — see [On Confirmed Packages](#on-confirmed-packages). +This invariant applies only when `fixed_price` is present on the parent object. For auction-based packages, `price_breakdown` is informational — see [On Confirmed Packages](#on-confirmed-packages). Fee and discount adjustments compound in array order using these formulas: @@ -655,7 +654,7 @@ The `price_breakdown` field also appears on the package response (confirmed line "package_id": "pkg_67890", "product_id": "premium_display", "pricing_option_id": "cpm_eur_auction", - "bid_price": 12.50, + "bidding": { "bid_amount": 12.50 }, "budget": 10000, "price_breakdown": { "list_price": 12.50, @@ -732,9 +731,10 @@ Publishers can offer the same product in multiple currencies: ### Auction Pricing (`fixed_price` absent) - Final price determined through auction - Publisher provides `floor_price` (hard minimum) and optional `price_guidance` (percentile hints) -- Bid-based auction models (`cpm`, `vcpm`, `cpc`, `cpcv`, `cpv`) may also include optional `max_bid: true` to explicitly signal `bid_price` is treated as buyer maximum willingness to pay (ceiling mode) - Common for non-guaranteed inventory -- Buyer submits `bid_price` in media buy request +- Buyer expresses execution intent explicitly with `bidding.bid_amount` or `bidding.max_bid` + +The pricing option is the seller's commercial offer; `bidding` is the buyer's execution instruction. The pricing option supplies the auction unit and must use the media-buy currency. Every package budget, minimum-spend target, and canonical bidding amount uses that single denomination; split packages requiring another currency into a separate media buy. In 3.2, pricing-option `max_bid` and package `bid_price` remain only as deprecated migration fields. A seller-offered boolean no longer determines whether buyer input is a manual bid, auction ceiling, or average outcome-cost control. **Auction Example**: ```json @@ -743,7 +743,6 @@ Publishers can offer the same product in multiple currencies: "pricing_model": "cpcv", "currency": "USD", "floor_price": 0.08, - "max_bid": true, "price_guidance": { "p25": 0.10, "p50": 0.12, @@ -755,7 +754,7 @@ Publishers can offer the same product in multiple currencies: ## Buyer Selection Process -Each package specifies its own pricing option, which determines currency and pricing model: +Each package specifies its own pricing option, which determines pricing model and auction unit. Its declared currency must match the single media-buy currency: ```json { @@ -771,17 +770,17 @@ Each package specifies its own pricing option, which determines currency and pri "pricing_option_id": "cpcv_usd_auction", "budget": 50000, "pacing": "even", - "bid_price": 0.16 + "bidding": { "max_bid": 0.16 } }] } ``` **How it works:** 1. Package selects `pricing_option_id` from product (e.g., "cpcv_usd_auction") -2. Pricing option determines currency, pricing model, and fixed vs auction -3. Package `budget` is in the pricing option's currency -4. Auction-based pricing requires `bid_price`; it is the exact honored price unless `max_bid: true`, which switches it to a maximum-willingness-to-pay ceiling -5. Sellers validate currency compatibility across packages +2. Pricing option determines pricing model, auction unit, and fixed vs auction; its currency must match the media buy +3. Package `budget` and all canonical `bidding` amounts use the media-buy currency +4. Auction-based pricing uses explicit `bidding.bid_amount` for a manual auction bid or `bidding.max_bid` for a hard per-auction ceiling, expressed per the pricing option's unit +5. Sellers reject currency-mismatched packages rather than converting amounts; buyers split them into separate media buys ## Reporting Metrics by Pricing Model diff --git a/docs/media-buy/conversion-tracking/index.mdx b/docs/media-buy/conversion-tracking/index.mdx index c2336ada5b..41c5399c28 100644 --- a/docs/media-buy/conversion-tracking/index.mdx +++ b/docs/media-buy/conversion-tracking/index.mdx @@ -45,13 +45,14 @@ This shows the recommended order. In practice, media buys can be created before An event source represents a channel through which conversion or marketing events are collected — a website pixel, mobile SDK, server-to-server integration, CRM import, or seller- or platform-native owned property such as a channel, profile, feed, podcast, or newsletter list. -Configure event sources with [`sync_event_sources`](/docs/media-buy/task-reference/sync_event_sources). You provide an `event_source_id`, optional `name`, `event_types`, `action_source`, `surface`, and `allowed_domains`. The response includes additional fields for each source: +Configure event sources with [`sync_event_sources`](/docs/media-buy/task-reference/sync_event_sources). You provide an `event_source_id`, optional `name`, `event_types`, `value_currencies`, `action_source`, `surface`, and `allowed_domains`. `value_currencies` is required when the source will feed canonical ROAS. A multi-currency source may list several currencies; each buy consumes only records matching its media-buy currency, with no conversion. The response includes additional fields for each source: | Field | Type | Description | |-------|------|-------------| | `seller_id` | string | Seller-assigned identifier in their ad platform | | `action` | string | What happened: `created`, `updated`, `unchanged`, `deleted`, `failed` | | `managed_by` | string | `buyer` (you configured it) or `seller` (always-on, seller-managed) | +| `value_currencies` | string[] | Currencies accepted for monetary event values and eligible for canonical ROAS | | `action_source` | [ActionSource](#action-sources) | Flat source type for legacy and broad compatibility (website pixel, app SDK, in-store, etc.) | | `surface` | [EventSurface](#event-surfaces) | Optional structured surface, such as an owned channel, profile, feed, podcast, newsletter list, website, app, or store | | `setup` | object | Implementation details — snippet code, snippet type, instructions | @@ -148,7 +149,7 @@ Send multiple identifier types when available. The seller uses the best availabl ## Custom data -Event-specific data for attribution and reporting. For purchase events, always include `value` and `currency` to enable ROAS reporting. +Event-specific data for attribution and reporting. Sources used for canonical ROAS declare `value_currencies`; monetary records from those sources require `currency` from that list. Legacy sources without the contract remain valid for non-ROAS use. ```json { @@ -444,7 +445,9 @@ A `maximize_value` target steers spend toward higher-value conversions without c | `target.kind` | `"cost_per"` \| `"per_ad_spend"` \| `"maximize_value"` | No | Target type. When omitted, the seller maximizes conversion count within budget (see [default behavior](#default-behavior-for-event-goals)). | | `target.value` | number | Yes (if target set) | Cost per event in buy currency, or return ratio (e.g., 4.0 = \$4 per \$1 spent) | | `attribution_window` | object | No | Click-through and view-through windows. When omitted, the seller uses their default. | -| `priority` | integer | No | 1 = highest priority. When omitted, sellers use array position. | +| `priority` | integer | No | Lower values rank first. Ties use array order; unprioritized goals follow explicitly prioritized goals. | + +For canonical `bidding.roas`, every value-bearing source must declare the media-buy currency in `value_currencies` before buy creation. Monetary `log_event` records require `custom_data.currency` from the source's declared list. A USD buy uses only USD records; EUR records from the same source remain available to EUR buys. Sellers never convert value currencies. ### kind: metric diff --git a/docs/media-buy/media-buys/index.mdx b/docs/media-buy/media-buys/index.mdx index 928cea6162..5c5cd956c2 100644 --- a/docs/media-buy/media-buys/index.mdx +++ b/docs/media-buy/media-buys/index.mdx @@ -24,6 +24,7 @@ Transform discovered products into active advertising campaigns using [`create_m - **Package Configuration**: Combine products with formats, targeting, and budget - **Campaign Setup**: Define timing, overall budget, and brand context +- **Execution Policy**: Define media-buy bidding defaults and explicit package overrides separately from optimization goals - **Validation & Approval**: Automated checks with optional human approval - **Platform Deployment**: Campaign creation across advertising platforms @@ -75,6 +76,7 @@ For complete details on optimization strategies, performance monitoring, standar A media buy contains: - **Campaign metadata** (buyer reference, brand, timing) - **Overall budget** with currency and pacing preferences +- **Bidding policy** at media-buy scope, with optional complete package overrides and authored-scope-preserving readback - **Multiple packages** representing different targeting/creative combinations - **Status tracking** through creation, approval, and execution phases @@ -96,7 +98,7 @@ Packages are the building blocks of media buys: - **Single product** selection from discovery results - when you buy a product, you buy the entire product (unless using property targeting) - **Creative formats** to be provided for this package - **Targeting overlays** for refinements including geo restrictions, frequency caps, and property targeting -- **Budget allocation** as portion of overall media buy budget +- **Budget controls** as an independent fixed budget or a cap/soft minimum within seller-optimized allocation - **Pricing option** selection from product's available pricing models - **Pacing strategy** for budget delivery (even, asap, or front_loaded) - **Bid price** for auction-based pricing models (when applicable) @@ -109,7 +111,7 @@ Packages can have independent flight dates within a media buy. This enables week - **Inheritance**: When `start_time` or `end_time` is omitted on a package, the package inherits the media buy's dates. Each field inherits independently — a package may specify `start_time` while inheriting the media buy's `end_time`, or vice versa. - **Validation**: Package dates must fall within the parent media buy's date range. Sellers SHOULD reject packages where `start_time` is equal to or after `end_time`. -- **Overlapping flights**: Multiple packages for the same product may have overlapping date ranges. Each package maintains its own independent budget. +- **Overlapping flights**: Multiple packages for the same product may have overlapping date ranges. In fixed mode each package maintains an independent budget; in seller-optimized mode the packages draw from the shared total within their constraints. - **Format**: Plain ISO 8601 date-time — packages do not support `"asap"` **Weekly flights example:** diff --git a/docs/media-buy/media-buys/optimization-reporting.mdx b/docs/media-buy/media-buys/optimization-reporting.mdx index b3e660cf73..7341fcb63d 100644 --- a/docs/media-buy/media-buys/optimization-reporting.mdx +++ b/docs/media-buy/media-buys/optimization-reporting.mdx @@ -74,7 +74,17 @@ The two flows aren't a duplication — they encode the difference between *stand ### Required coherence between layers -Three rules connect the layers and prevent orphaned goals: +`optimization_goals` define the objective function: what the seller should optimize, its units, and priority. They do not define 3.2 bidding policy. Currency-bearing execution controls live in `bidding` at media-buy or package scope. All monetary amounts use the media-buy currency; a selected pricing option contributes the auction unit but MUST declare that same currency: + +- seller-optimized media-buy `cost_per`/`roas` bind to the primary `budget_allocation.optimization_goals` goal; +- package-authored `cost_per`/`roas` bind to that package's primary goal; +- fixed media-buy `cost_per` binds to every inheriting package and requires compatible primary-goal result units; fixed inherited `roas` requires value-bearing primary goals; +- `bid_amount` and `max_bid` are expressed per the selected pricing option's auction unit; and +- `{automatic: true}` explicitly overrides inherited policy with provider automatic bidding. + +The separation matters when the units differ—for example, a seller can optimize toward conversions using a target CPA while also enforcing a max-CPC auction ceiling, with both amounts denominated in the media-buy currency. Legacy goal targets `cost_per` and `per_ad_spend` remain accepted during 3.2 migration but cannot be combined with canonical `bidding` on the same effective package. Their conservative normalization remains `cost_per` → `strength: "target"` and `per_ad_spend` → `strength: "target"`. + +Four rules connect the layers and prevent orphaned goals: 1. **Optimization requires capability.** A package's `optimization_goals[]` entry MUST match a product capability — `metric_optimization.supported_metrics` for `kind: "metric"`, or `vendor_metric_optimization.supported_metrics` for `kind: "vendor_metric"`. Sellers reject mismatches with `TERMS_REJECTED`. @@ -85,7 +95,7 @@ Three rules connect the layers and prevent orphaned goals: - Standard only — "you owe me ≥ X," seller decides how to hit it - Both — optimization steers; standard backstops -4. **Priority is ordinal, lowest-value-first.** When `optimization_goals[]` carry explicit `priority` values, the seller treats the goal with the **lowest** priority value as primary — even when no goal is `priority: 1` (priorities of `2` and `3` mean `2` is primary). When `priority` is omitted, sellers may use array position. Duplicate priority values are undefined. +4. **Priority is ordinal, lowest-value-first.** When `optimization_goals[]` carry explicit `priority` values, the seller treats the goal with the **lowest** priority value as primary — even when no goal is `priority: 1` (priorities of `2` and `3` mean `2` is primary). When all priorities are omitted, the first array entry is primary. Duplicate priority values are undefined. ### Worked example: Adelaide attention end-to-end diff --git a/docs/media-buy/product-discovery/media-products.mdx b/docs/media-buy/product-discovery/media-products.mdx index 0c741a0e03..1c4c91e832 100644 --- a/docs/media-buy/product-discovery/media-products.mdx +++ b/docs/media-buy/product-discovery/media-products.mdx @@ -125,7 +125,7 @@ Each pricing option includes: } ``` -For auction-based pricing (no `fixed_price`), use `floor_price` for minimum bid constraints and optional `price_guidance` for percentile hints. Bid-based auction models (`cpm`, `vcpm`, `cpc`, `cpcv`, `cpv`) may also include `max_bid` as a boolean signal that `bid_price` switches from exact honored price to buyer ceiling mode: +For auction-based pricing (no `fixed_price`), use `floor_price` for the seller's minimum and optional `price_guidance` for non-binding percentile hints. The buyer expresses execution intent separately through `bidding.bid_amount`, `bidding.max_bid`, `bidding.cost_per`, or `bidding.roas` on `create_media_buy`. Pricing-option `max_bid` and package `bid_price` are deprecated legacy migration fields in 3.2: ```json { "$schema": "https://adcontextprotocol.org/schemas/v3/pricing-options/cpm-option.json", @@ -133,7 +133,6 @@ For auction-based pricing (no `fixed_price`), use `floor_price` for minimum bid "pricing_model": "cpm", "currency": "USD", "floor_price": 10.00, - "max_bid": true, "price_guidance": { "p25": 12.50, "p50": 15.00, @@ -899,7 +898,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`. -- **Budget-agnostic**: Allocations use percentages, allowing the same proposal to scale to any budget +- **Budget-agnostic**: Fixed allocations and seller-optimized constraints use percentages, allowing the same proposal to scale to any budget +- **Allocation-aware**: Fixed proposals prescribe the split; seller-optimized proposals delegate continuous cross-package allocation to the seller within explicit goals and constraints - **Forecast-equipped**: Proposals and allocations can include delivery forecasts to help buyers evaluate expected performance before purchase ### Proposal Structure @@ -971,6 +971,46 @@ The `tags` field enables grouping allocations by dimension: - **By channel**: desktop (50%) + mobile (20%) + in-app (30%) = 100% - **By language**: German (40%) + French (60%) = 100% +#### Seller-optimized proposals + +A seller-optimized proposal names the eligible products and cross-package goal without locking an exact split. Optional minimum-spend targets are soft; maximum-spend percentages are hard caps: + +```json +{ + "proposal_id": "performance_shared_v1", + "name": "Seller-optimized performance plan", + "budget_allocation": { + "mode": "seller_optimized", + "optimization_goals": [ + { + "kind": "event", + "event_sources": [ + { + "event_source_id": "checkout_events", + "event_type": "purchase" + } + ] + } + ] + }, + "pacing": "even", + "allocations": [ + { + "product_id": "prospecting", + "min_spend_target_percentage": 20, + "max_spend_percentage": 70 + }, + { + "product_id": "retargeting", + "max_spend_percentage": 60, + "pacing": "front_loaded" + } + ] +} +``` + +Seller-optimized allocations MUST omit `allocation_percentage`. Minimum targets across allocations must sum to no more than 100, and maximums must collectively permit 100 percent of the budget to be spent. Optional allocation `pacing` becomes subordinate package pacing on execution; aggregate proposal pacing remains the media-buy pacing ceiling. When executed, the seller converts percentage constraints into absolute package constraints and continuously allocates the shared total budget. The seller's internal allocation changes are delivery decisions, not `update_media_buy` mutations. + ### Iterating on Proposals Proposals can be refined using `buying_mode: "refine"` with the `refine` array. Reference proposals by ID — the seller returns an updated proposal with revised allocations, forecasts, and pricing: @@ -1020,14 +1060,14 @@ To execute a committed proposal, provide the `proposal_id` and `total_budget` in } ``` -The publisher converts the proposal's allocation percentages into packages: +For a fixed proposal, the publisher converts 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. - `ch_desktop_fr`: 30% × \$50,000 = \$15,000 - etc. -This approach simplifies complex multi-line-item campaigns to a single proposal execution. +For a seller-optimized proposal, the publisher instead converts minimum and maximum percentage constraints into absolute package constraints, activates the proposal's cross-package optimization goals and aggregate pacing, and copies allocation pacing onto the corresponding derived package. In either mode, proposal execution simplifies a complex multi-package campaign to one acceptance call. ### When Publishers Return Proposals diff --git a/docs/media-buy/specification.mdx b/docs/media-buy/specification.mdx index 7c6ce67052..d6d4b0534a 100644 --- a/docs/media-buy/specification.mdx +++ b/docs/media-buy/specification.mdx @@ -370,7 +370,7 @@ When `pending_creatives` or `pending_start` is masking a create-time hold from ` For creative changes, `sync_creatives` in `valid_actions` is a legacy creative-change action label, not proof that the `sync_creatives` task exists. Buyers use the creative path the seller advertises: `sync_creatives` and `creative_assignments` for sellers with `creative.has_creative_library: true`, or `packages[].creatives` on `update_media_buy` for inline-only sellers that advertise `media_buy.features.inline_creative_management: true`. - Orchestrators MAY request revision history by setting `include_history` to the number of most-recent entries desired. Sales agents SHOULD return a `history` array per media buy when `include_history > 0`, containing revision number, timestamp, server-derived actor identity, action type, and optional summary. History entries MUST be ordered most-recent-first. -- Sales agents MUST include a media-buy currency and MUST denominate monetary fields consistently (`snapshot.currency` -> `package.currency` -> `media_buy.currency`) +- Sales agents MUST include one media-buy currency for every AdCP-authored buy. `total_budget`, package budget/minimum constraints, and canonical BiddingPolicy monetary fields use that denomination, and every selected pricing option MUST declare it. Packages requiring another currency belong in a separate media buy. `package.currency` remains a legacy/external-buy readback field; `snapshot.currency` may identify an upstream reporting denomination but MUST NOT reinterpret authored budget or bidding values. - Sales agents SHOULD include creative approval outcomes and pending format requirements when available - If `include_snapshot` is true and snapshot data is omitted for a package, sales agents MUST return `snapshot_unavailable_reason` - When `include_snapshot` is true and snapshots are returned, each snapshot MUST include `as_of` and `staleness_seconds` @@ -470,6 +470,8 @@ Common error codes: - `NOT_CANCELLABLE`: Media buy or package cannot be canceled in its current state - `GOVERNANCE_DENIED`: A registered governance agent denied the transaction. Buyer may restructure the buy, escalate to human spending authority, or contact the governance agent. - `TERMS_REJECTED`: Buyer-proposed `measurement_terms` were rejected by the seller. Error details SHOULD identify which term failed and the seller's acceptable range or supported vendors. Recovery: adjust the proposed terms and retry, or omit `measurement_terms` to accept the product's defaults. +- `BIDDING_PLACEMENT_CONFLICT`: Media-buy/package bidding scopes cannot be represented by the provider's native campaign, package, or shared-strategy placement. Sellers MUST reject before mutation and identify conflicting scopes where safe. +- `AMBIGUOUS_BIDDING_POLICY`: Canonical `bidding` and legacy bidding fields are both present on the same effective package. Sellers reject rather than choosing precedence. - `REQUOTE_REQUIRED`: An `update_media_buy` request changes the parameter envelope (budget, flight dates, volume, targeting) the original quote was priced against. The `pricing_option` remains locked; the seller is declining the requested shape at that price. Distinct from `TERMS_REJECTED` (measurement) and `POLICY_VIOLATION` (content). Recovery in 3.1 is to adjust the update to fit the current quote, rediscover products/terms, add packages when `add_packages` is available, or create a separate media buy. AdCP 3.1 does not define an amendment-quote artifact that can be attached to `update_media_buy`. Sellers SHOULD populate `error.details.envelope_field` with the field path(s) that breached the envelope (e.g., `packages[0].budget`, `end_time`) so the buyer's agent can autonomously re-discover. - `VALIDATION_ERROR`: Request format or parameter errors - `AUTH_MISSING`: No credentials were presented. Recovery: correctable. diff --git a/docs/media-buy/task-reference/create_media_buy.mdx b/docs/media-buy/task-reference/create_media_buy.mdx index 1297f9525f..010a29be91 100644 --- a/docs/media-buy/task-reference/create_media_buy.mdx +++ b/docs/media-buy/task-reference/create_media_buy.mdx @@ -8,9 +8,10 @@ testable: true Create a media buy from selected packages or execute a proposal. Handles validation, approval if needed, and campaign creation. -Supports two modes: -- **Manual Mode**: Provide `packages` array with explicit line item configurations -- **Proposal Mode**: Provide `proposal_id` and `total_budget` to execute a proposal from `get_products` +Supports three execution shapes: +- **Fixed packages**: Provide `packages` with independent package budgets (legacy default) +- **Seller-optimized packages**: Provide `packages`, `total_budget`, and `budget_allocation.mode: "seller_optimized"` so the seller allocates a shared budget across packages +- **Proposal execution**: Provide `proposal_id` and `total_budget`; the committed proposal determines whether allocation is fixed or seller-optimized **Response Time**: Instant to days (returns `completed`, `working` < 120s, or `submitted` for hours/days) @@ -49,7 +50,7 @@ const result = await testAgent.createMediaBuy({ } ], budget: 2500, - bid_price: 5.00 + bidding: { bid_amount: 5.00 } }, { product_id: 'prod_e8fd6012', @@ -61,7 +62,7 @@ const result = await testAgent.createMediaBuy({ } ], budget: 2500, - bid_price: 4.50 + bidding: { bid_amount: 4.50 } } ], start_time: tomorrow.toISOString(), @@ -113,7 +114,7 @@ async def create_campaign(): } ], 'budget': 2500, - 'bid_price': 5.00 + 'bidding': {'bid_amount': 5.00} }, { 'product_id': 'prod_e8fd6012', @@ -125,7 +126,7 @@ async def create_campaign(): } ], 'budget': 2500, - 'bid_price': 4.50 + 'bidding': {'bid_amount': 4.50} } ], start_time=tomorrow.isoformat().replace('+00:00', 'Z'), @@ -147,7 +148,7 @@ asyncio.run(create_campaign()) npx @adcp/sdk@latest \ https://test-agent.adcontextprotocol.org/sales/mcp \ create_media_buy \ - '{"brand":{"domain":"acmecorp.com"},"packages":[{"product_id":"prod_d979b543","pricing_option_id":"cpm_usd_auction","format_ids":[{"agent_url":"https://creative.adcontextprotocol.org","id":"display_300x250_image"}],"budget":30000,"bid_price":5.00},{"product_id":"prod_e8fd6012","pricing_option_id":"cpm_usd_auction","format_ids":[{"agent_url":"https://creative.adcontextprotocol.org","id":"display_300x250_html"}],"budget":20000,"bid_price":4.50}],"start_time":"2025-06-01T00:00:00Z","end_time":"2025-08-31T23:59:59Z"}' \ + '{"brand":{"domain":"acmecorp.com"},"packages":[{"product_id":"prod_d979b543","pricing_option_id":"cpm_usd_auction","format_ids":[{"agent_url":"https://creative.adcontextprotocol.org","id":"display_300x250_image"}],"budget":30000,"bidding":{"bid_amount":5.00}},{"product_id":"prod_e8fd6012","pricing_option_id":"cpm_usd_auction","format_ids":[{"agent_url":"https://creative.adcontextprotocol.org","id":"display_300x250_html"}],"budget":20000,"bidding":{"bid_amount":4.50}}],"start_time":"2025-06-01T00:00:00Z","end_time":"2025-08-31T23:59:59Z"}' \ --auth $ADCP_AUTH_TOKEN ``` @@ -159,11 +160,14 @@ npx @adcp/sdk@latest \ |-----------|------|----------|-------------| | `account` | [account-ref](/docs/building/by-layer/L2/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`. | -| `total_budget` | TotalBudget | No* | Total budget when executing a proposal. Publisher applies allocation percentages. | +| `total_budget` | TotalBudget | No* | Hard aggregate lifetime budget. Its currency is the single media-buy denomination for package constraints and canonical bidding. Required for proposals and seller-optimized explicit packages. Optional in fixed explicit-package mode; when supplied there, it must equal the sum of package budgets. | +| `budget_allocation` | BudgetAllocation | No | Cross-package allocation mode. Omit for fixed allocation. Use `seller_optimized` with media-buy optimization goals to delegate allocation to the seller. Must be omitted when executing a proposal because the committed proposal supplies it. | | `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) | | `start_time` | string | Yes | `"asap"` or ISO 8601 date-time. For new media buys, concrete date-times must not be in the past. | | `end_time` | string | Yes | ISO 8601 date-time (UTC unless timezone specified) | +| `pacing` | string | No | Aggregate pacing for the media-buy budget: `even`, `asap`, or `front_loaded`. Defaults to `even` when `total_budget` is present. For proposal execution, omit it or match the committed proposal's pacing; conflicting overrides are rejected. | +| `bidding` | BiddingPolicy | No | Complete media-buy bidding default inherited by packages that omit `packages[].bidding`. In shared-budget mode its goal-bound controls bind to the allocation primary goal; in fixed mode inherited cost controls require compatible package result units. | | `paused` | boolean | No | Create the media buy with delivery held. When true and the buy would otherwise be active, `media_buy_status` is `paused`. Setup blockers still take precedence: missing creatives yield `pending_creatives`, and future flights yield `pending_start`; the hold becomes visible as `paused` after those blockers clear. | | `invoice_recipient` | [BusinessEntity](/docs/building/by-layer/L2/accounts-and-agents#billing-entity-and-invoice-recipient) | No | Override the account's default billing entity for this buy. The seller MUST validate the recipient is authorized and include it in `check_governance` when governance agents are configured. | | `po_number` | string | No | Purchase order number | @@ -192,12 +196,14 @@ When executing a proposal, `proposal_status` on the returned proposal determines | `format_option_refs` | FormatOptionRef[] | No | 3.1+ format-option selector. Structured references to entries in the package's target product `format_options[]`: `{scope: "publisher", publisher_domain, format_option_id}` for publisher-catalog-backed options, or `{scope: "product", format_option_id}` for product-local options (see [canonical formats](/docs/creative/canonical-formats)). When present, `format_option_refs` wins over both `format_ids` and direct `format_kind`/`params`. Sellers MUST reject with `UNSUPPORTED_FEATURE` (field path `packages[i].format_option_refs[j]`) when an entry doesn't match a `format_options[]` entry, when the product is legacy-format-only, or when the product's `format_options[]` entries don't publish selectable `format_option_id` values. | | `format_kind` | CanonicalFormatKind | No | 3.1+ direct canonical selector. Names the canonical format shape this package targets when the buyer is authoring by canonical kind rather than by `format_option_refs[]` or `format_ids[]`. Pair with `params` when the product declaration requires dimensions, duration, sizes, codecs, or other canonical parameters. If `format_ids[]` is also present and `format_option_refs[]` is absent, sellers validate `format_ids[]`; `format_kind` is only an informational echo of the same normalized shape. | | `params` | object | No | Parameters for the direct canonical selector in `format_kind`. Follows the selected canonical's parameter vocabulary. Requires `format_kind`; `params` alone is schema-invalid. A broad selector such as `{format_kind: "image"}` does not satisfy a product whose `format_options[]` fixes `params.width` and `params.height`; sellers reject under-specified direct selectors with `UNSUPPORTED_FEATURE` or an equivalent format-selector error. | -| `budget` | number | Yes | Budget in currency specified by pricing option | +| `budget` | number | Conditional | Hard lifetime package spend cap in the media-buy currency. Required in fixed mode. Optional in seller-optimized mode; it remains a ceiling, not a current or reserved allocation. | +| `min_spend_target` | number | No | Soft lifetime package spend target in the media-buy currency for seller-optimized allocation. The seller should attempt to reach it, but it is not a billing or delivery guarantee. | | `impressions` | number | No | Impression goal for this package | | `paused` | boolean | No | Create package in paused state (default: `false`) | | `pacing` | string | No | `"even"` (default), `"asap"`, or `"front_loaded"` | -| `bid_price` | number | No | Bid price for auction pricing. This is the exact bid/price to honor unless the selected pricing option has `max_bid: true`, in which case it is treated as the buyer's maximum willingness to pay (ceiling). | -| `optimization_goals` | [OptimizationGoal[]](/docs/media-buy/conversion-tracking/#optimization-goals) | No | Optimization targets for this package. Each goal is either `kind: "event"` (conversion events with `event_sources` array, optional `cost_per`, `per_ad_spend`, or `maximize_value` target) or `kind: "metric"` (seller-native metric with optional `cost_per` or `threshold_rate` target). Event goals require `conversion_tracking.supported_targets` on the product; metric goals require `metric_optimization.supported_metrics`. | +| `bidding` | BiddingPolicy | No | Complete package-authored bidding override. Use `{automatic: true}` to explicitly override a media-buy policy with provider automatic bidding. Omit to inherit. This is replacement inheritance, not a field-by-field merge. | +| `bid_price` | number | No | **Deprecated in 3.2.** Use `bidding.bid_amount` or `bidding.max_bid`. A package cannot supply both representations. | +| `optimization_goals` | [OptimizationGoal[]](/docs/media-buy/conversion-tracking/#optimization-goals) | No | Objective functions for this package: what to optimize and in what priority. Currency controls belong in `bidding`; legacy `target.cost_per` and `target.per_ad_spend` remain accepted only for migration. | | `targeting_overlay` | TargetingOverlay | No | Additional targeting criteria (see [Targeting](/docs/media-buy/advanced-topics/targeting)). For `demographics`, inspect the selected product's `demographic_targeting` declaration first; sellers accept only an exact compilation or reject it. A buyer choosing a suggested supported predicate submits a new request; sellers never silently broaden or narrow the original. | | `start_time` | string | No | ISO 8601 date-time for this package's flight start. When omitted, inherits the media buy's `start_time`. Must fall within the media buy's date range. Does not support `"asap"`. | | `end_time` | string | No | ISO 8601 date-time for this package's flight end. When omitted, inherits the media buy's `end_time`. Must fall within the media buy's date range. | @@ -208,6 +214,65 @@ When executing a proposal, `proposal_status` on the returned proposal determines | `performance_standards` | [PerformanceStandard[]](/docs/media-buy/advanced-topics/pricing-models#measurement-terms-and-performance-standards) | No | Buyer's proposed performance standards (viewability, IVT, completion rate, brand safety, attention score). Overrides product defaults. Seller accepts, rejects with `TERMS_REJECTED`, or adjusts. When omitted, product's `performance_standards` apply. | | `committed_metrics` | object[] | No | Buyer's proposed reporting contract — metrics the buyer wants the seller to commit to populating in delivery reports. Same negotiation pattern as `measurement_terms`/`performance_standards`: each entry tags `scope: "standard"` (with `metric_id` from the closed enum) or `scope: "vendor"` (with `vendor` BrandRef + vendor's `metric_id`). Request-side entries do NOT carry `committed_at` — that timestamp is stamped by the seller on accept. Seller accepts (echoes on response with `committed_at`), rejects with `TERMS_REJECTED`, or normalizes (echoes a different but compatible list). When omitted, the seller decides what to commit based on the product's `available_metrics` plus any `required_metrics` filter the buyer passed at discovery. | +### BiddingPolicy Object + +`optimization_goals` select the objective; `bidding` constrains how the seller executes against it. Every monetary amount is in the media-buy currency, which is not repeated in the block. The pricing option contributes only the auction unit, never a second currency. Every package's selected pricing option must declare the media-buy currency; split packages requiring another currency into a separate media buy. + +| Field | Meaning | +|-------|---------| +| `automatic: true` | Explicit provider automatic bidding. At package scope this overrides, rather than inherits, media-buy bidding. | +| `bid_amount` | Manual auction bid in media-buy currency per the selected pricing option's auction unit. It is not a promise that the clearing price equals the bid. | +| `max_bid` | Hard per-auction ceiling in media-buy currency per the selected pricing option's auction unit. It is the only canonical hard auction ceiling. | +| `cost_per: { amount, strength }` | Average cost in media-buy currency per scope-bound primary-goal result. `strength` is `cap` or `target`; neither is a per-result guarantee. | +| `roas: { value, strength }` | Dimensionless return per unit of ad spend. `strength` is `floor` or `target`; value-bearing event sources must declare `value_currencies` containing the media-buy currency. Each buy consumes only exact-currency value records; no conversion is permitted. | + +`automatic`, `bid_amount`, `cost_per`, and `roas` are mutually exclusive primary modes. `max_bid` can stand alone or supplement `cost_per`/`roas` only where the relevant scope capability advertises the combination; `bid_amount` and `max_bid` cannot coexist. + +Goal binding is determined by authored scope: + +1. A seller-optimized media-buy `cost_per` or `roas` binds to the primary goal in `budget_allocation.optimization_goals`. +2. A package-authored override binds to that package's primary `optimization_goals` goal. +3. A fixed-allocation media-buy `cost_per` binds separately to every inheriting package and is valid only when their primary-goal result units are compatible. Metric goals must identify the same metric and result-defining qualifier; vendor metrics must identify the same vendor and metric; event goals must identify the same event-type/custom-name set and resolved attribution window. A fixed media-buy `roas` may span different event identities, but every inheriting primary goal must be value-bearing. + +The primary goal is the earliest array entry among goals tied for the lowest explicit numeric priority. Unprioritized goals follow explicitly prioritized goals; when every priority is omitted, the first entry is primary. `bid_amount` and `max_bid` use the selected pricing option's auction unit, which permits a package conversion-cost target plus a max-CPC ceiling while keeping both monetary amounts in one currency. Auction-unit identity is the pricing model plus all billing-event qualifiers after defaults: for example CPV view threshold, CPP demographic, time unit, or flat-rate/DOOH parameters. A media-buy `bid_amount` or `max_bid` may be inherited only by packages with the same auction-unit identity; other packages need package overrides. + +Precedence is complete-block inheritance: + +1. Package `bidding` replaces media-buy `bidding` for that package. +2. An omitted package block inherits the complete media-buy block. +3. `{automatic: true}` is an explicit package override that disables inherited buyer controls for that package. +4. If both scopes are absent, provider automatic delivery applies. +5. Sellers preserve authored scope on readback and never copy an inherited media-buy policy into every package. + +In seller-optimized/shared-budget mode, media-buy bidding is authoritative for any strategy the provider requires at campaign level. Package overrides are permitted only for controls that provider supports under the shared strategy. Sellers validate all effective policies before mutation and reject incompatible placement with `BIDDING_PLACEMENT_CONFLICT`; they never choose one conflicting package policy based on package order. + +In independent-package budget mode, sellers normally write each effective policy to the package-native object. When a provider requires one campaign strategy and all effective package policies are identical, a seller may lift that policy to the native campaign, but readback still preserves whether the buyer authored it at media-buy or package scope. + +Capability discovery is structured. Sellers advertise only the scopes, modes, strengths, and supplements they preserve, for example a package-only cost-cap implementation: + +```json +{ + "media_buy": { + "features": { + "bidding_policy": { + "package": { + "fixed": { + "modes": ["cost_per", "automatic"], + "cost_per_strengths": ["cap"] + } + } + } + } + } +} +``` + +Each scope is partitioned into `fixed` and `seller_optimized` profiles so support in one allocation context makes no claim about the other. `supported_combinations` entries use `kind: "max_bid_with_cost_per"` or `kind: "max_bid_with_roas"` and list the exact cost/ROAS strengths supported in that combination. Presence of `bidding_policy` alone is not a blanket claim, and the former boolean form is invalid in 3.2. + +During 3.2, sellers accept either canonical `bidding` or one legacy representation on an effective package, never both. Mixed input is rejected with `AMBIGUOUS_BIDDING_POLICY`. Legacy normalization is deterministic: `bid_price` plus pricing-option `max_bid: true` becomes `bidding.max_bid`; other `bid_price` becomes `bidding.bid_amount`; goal `target.cost_per` becomes `bidding.cost_per` with `strength: "target"`; and goal `target.per_ad_spend` becomes `bidding.roas` with `strength: "target"`. + +A provider portfolio bidding strategy illustrates the current abstraction boundary. This contract represents the numeric policy but does not model a provider portfolio resource or shared identity above a media buy. A seller may create and own a single-buy provider strategy to honor a portfolio-only ceiling. Selecting an existing strategy, sharing learning across media buys, or updating several buys atomically requires a future provider-neutral bidding-policy resource/reference; provider identity may be returned under `ext` until then. + ## Response ### Success Response @@ -328,7 +393,7 @@ const result = await testAgent.createMediaBuy({ id: 'display_300x250_image' }], budget: 2500, - bid_price: 5.00, + bidding: { bid_amount: 5.00 }, targeting_overlay: { geo_countries: ['US'], geo_regions: ['US-CA', 'US-NY'], @@ -377,7 +442,7 @@ async def create_targeted_campaign(): 'id': 'display_300x250_image' }], 'budget': 2500, - 'bid_price': 5.00, + 'bidding': {'bid_amount': 5.00}, 'targeting_overlay': { 'geo_countries': ['US'], 'geo_regions': ['US-CA', 'US-NY'], @@ -402,7 +467,7 @@ asyncio.run(create_targeted_campaign()) ### Campaign with Conversion Optimization -Set a per_ad_spend target for conversion-optimized delivery. The product must declare support in `conversion_tracking.supported_targets`, and you must have an event source configured via `sync_event_sources`: +Keep the purchase event as the objective, then express target ROAS and the max-CPC auction ceiling separately in `bidding`. You must have an event source configured via `sync_event_sources`: @@ -421,13 +486,15 @@ const result = await testAgent.createMediaBuy({ product_id: 'prod_retail_sp', pricing_option_id: 'cpc_usd_auction', budget: 10000, - bid_price: 1.20, + bidding: { + roas: { value: 4.0, strength: 'target' }, + max_bid: 1.20 + }, optimization_goals: [{ kind: 'event', event_sources: [ { event_source_id: 'retailer_sales', event_type: 'purchase', value_field: 'value' } ], - target: { kind: 'per_ad_spend', value: 4.0 }, priority: 1 }] }], @@ -445,7 +512,7 @@ if ('errors' in validated && validated.errors) { } if ('media_buy_id' in validated) { - console.log(`Campaign ${validated.media_buy_id} created with per_ad_spend target`); + console.log(`Campaign ${validated.media_buy_id} created with target ROAS`); } ``` @@ -466,13 +533,15 @@ async def create_optimized_campaign(): 'product_id': 'prod_retail_sp', 'pricing_option_id': 'cpc_usd_auction', 'budget': 10000, - 'bid_price': 1.20, + 'bidding': { + 'roas': {'value': 4.0, 'strength': 'target'}, + 'max_bid': 1.20 + }, 'optimization_goals': [{ 'kind': 'event', 'event_sources': [ { 'event_source_id': 'retailer_sales', 'event_type': 'purchase', 'value_field': 'value' } ], - 'target': { 'kind': 'per_ad_spend', 'value': 4.0 }, 'priority': 1 }] }], @@ -483,7 +552,7 @@ async def create_optimized_campaign(): if hasattr(result, 'errors') and result.errors: raise Exception(f"Creation failed: {result.errors}") - print(f"Campaign {result.media_buy_id} created with per_ad_spend target") + print(f"Campaign {result.media_buy_id} created with target ROAS") asyncio.run(create_optimized_campaign()) ``` @@ -505,7 +574,7 @@ Include the `catalogs` field in a package to make it catalog-driven. Each catalo "product_id": "prod_job_board", "pricing_option_id": "cpc_eur_auction", "budget": 5000, - "bid_price": 2.50, + "bidding": { "bid_amount": 2.50 }, "catalogs": [{ "catalog_id": "chef-vacancies", "type": "job" @@ -525,7 +594,7 @@ Include the `catalogs` field in a package to make it catalog-driven. Each catalo "product_id": "prod_retail_sp", "pricing_option_id": "cpc_usd_auction", "budget": 10000, - "bid_price": 1.20, + "bidding": { "bid_amount": 1.20 }, "catalogs": [{ "catalog_id": "gmc-primary", "type": "product", @@ -656,7 +725,7 @@ const result = await testAgent.createMediaBuy({ id: 'display_300x250_image' }], budget: 2500, - bid_price: 5.00, + bidding: { bid_amount: 5.00 }, creatives: [{ creative_id: 'hero_video_30s', name: 'Hero Video', @@ -713,7 +782,7 @@ async def create_with_creatives(): 'id': 'display_300x250_image' }], 'budget': 2500, - 'bid_price': 5.00, + 'bidding': {'bid_amount': 5.00}, 'creatives': [{ 'creative_id': 'hero_video_30s', 'name': 'Hero Video', @@ -770,7 +839,7 @@ const result = await testAgent.createMediaBuy({ id: 'display_300x250_image' }], budget: 2500, - bid_price: 5.00 + bidding: { bid_amount: 5.00 } }], start_time: 'asap', end_time: endDate.toISOString(), @@ -821,7 +890,7 @@ async def create_with_reporting(): 'id': 'display_300x250_image' }], 'budget': 2500, - 'bid_price': 5.00 + 'bidding': {'bid_amount': 5.00} }], start_time='asap', end_time=end_date.isoformat().replace('+00:00', 'Z'), @@ -925,7 +994,9 @@ asyncio.run(execute_proposal()) When executing a proposal: -- The publisher converts allocation percentages to actual budgets using `total_budget` +- For fixed allocation, the publisher converts exact allocation percentages to package budgets using `total_budget` +- For seller-optimized allocation, the publisher preserves the shared allocation goals and converts minimum/maximum percentage constraints to package targets/caps +- Allocation-level pacing becomes subordinate package pacing; aggregate proposal pacing becomes media-buy pacing - Packages are created automatically based on the proposal's allocations - All other fields (brand, start_time, end_time, etc.) work the same as manual mode @@ -956,7 +1027,7 @@ When targeting mixed seller populations, include package context such as `contex "product_id": "prod_d979b543", "pricing_option_id": "cpm_usd_auction", "budget": 15000, - "bid_price": 5.00, + "bidding": { "bid_amount": 5.00 }, "context": { "line_item_id": "li-001", "flight": "june-awareness" @@ -966,7 +1037,7 @@ When targeting mixed seller populations, include package context such as `contex "product_id": "prod_e8fd6012", "pricing_option_id": "cpm_usd_auction", "budget": 10000, - "bid_price": 4.50, + "bidding": { "bid_amount": 4.50 }, "context": { "line_item_id": "li-002", "flight": "june-retargeting" @@ -1067,7 +1138,7 @@ Selector precedence is deterministic: `format_option_refs[]` wins when present; } ], "budget": 2500, - "bid_price": 5.00 + "bidding": { "bid_amount": 5.00 } } ] } @@ -1092,23 +1163,26 @@ Full brand identity data (colors, fonts, product catalog) is resolved from brand ### Pricing & Currency Each package specifies its `pricing_option_id`, which determines: -- Currency (USD, EUR, etc.) - Pricing model (CPM, CPCV, CPP, etc.) - Rate and whether it's fixed or auction-based -Packages can use different currencies when sellers support it. See [Pricing Models](/docs/media-buy/advanced-topics/pricing-models). +The media buy has one currency. Every selected pricing option MUST declare that currency; the option does not introduce a package-specific denomination. Split products requiring another currency into a separate media buy. See [Pricing Models](/docs/media-buy/advanced-topics/pricing-models). ### Budget & Pacing Controls -Budget constraints are set **per package**, not at the media-buy level. The one exception is `total_budget`, which applies only when [executing a proposal](#executing-a-proposal) — the seller then derives per-package budgets from the proposal's allocation percentages. +AdCP supports fixed package budgets and media-buy-level shared budgets. `budget_allocation.mode` makes the interpretation explicit; omission preserves the legacy fixed behavior. | Field | Level | Type | Controls | |-------|-------|------|----------| -| `total_budget.amount` + `total_budget.currency` | Media buy | TotalBudget | Total spend when executing a proposal. Not used when supplying explicit `packages`. | -| `budget` | Package | number | Total spend cap for the package, in the currency the package's `pricing_option_id` specifies. Delivery stops when the package budget is exhausted. | +| `total_budget.amount` + `total_budget.currency` | Media buy | TotalBudget | Hard aggregate lifetime spend cap. Required for proposals and seller-optimized allocation. | +| `budget_allocation` | Media buy | BudgetAllocation | `fixed` keeps package budgets independent; `seller_optimized` delegates cross-package allocation against shared goals. | +| `budget` | Package | number | Hard package spend cap. Required in fixed mode; optional ceiling in seller-optimized mode. | +| `min_spend_target` | Package | number | Soft minimum-spend target in seller-optimized mode. Not a guarantee. | | `impressions` | Package | number | Impression goal for the package. An alternative volume target to `budget`; a seller may pace to whichever binds first. | -| `pacing` | Package | enum | How the seller spreads the package budget across the flight: `even` (default), `asap`, or `front_loaded`. | -| `bid_price` | Package | number | For auction pricing options — the exact bid, or the ceiling when the pricing option has `max_bid: true`. | +| `pacing` | Media buy | enum | How the seller spreads aggregate spend across the media-buy flight. | +| `packages[].pacing` | Package | enum | Subordinate temporal preference affecting which package receives the aggregate spend. | +| `bidding` | Media buy or package | BiddingPolicy | Buyer execution policy. Media-buy is the inherited default; package is a complete override. | +| `bid_price` | Package | number | Deprecated legacy representation normalized to `bidding.bid_amount` or `bidding.max_bid`. | | `start_time` / `end_time` | Package | string | The flight window the budget is spread across. When omitted, the package inherits the media buy's flight dates. | **Pacing modes** (from [`/schemas/v3/enums/pacing.json`](https://adcontextprotocol.org/schemas/v3/enums/pacing.json)): @@ -1127,16 +1201,75 @@ Example package with an even-paced budget over a fixed flight: "pricing_option_id": "po_cpm_fixed", "budget": 50000, "pacing": "even", - "start_time": "2026-08-01T00:00:00Z", - "end_time": "2026-08-31T23:59:59Z" + "start_time": "2099-08-01T00:00:00Z", + "end_time": "2099-08-31T23:59:59Z" } ``` With `even` pacing over the 31-day flight above, the seller spreads the $50,000 budget across the month — roughly $1,613/day — subject to the seller's own pacing engine and available inventory. +#### Seller-optimized allocation + +Seller-optimized allocation is a nested optimization: + +1. Hard aggregate and package caps, flight windows, pauses, and policy restrictions always apply. +2. Media-buy pacing controls how much the buy spends over time. +3. Package pacing influences which eligible package receives that spend and MUST NOT cause aggregate delivery to exceed the media-buy pacing plan. +4. `budget_allocation.optimization_goals` choose allocation across packages; `packages[].optimization_goals` optimize delivery within one package. +5. Media-buy `bidding.cost_per`/`roas` bind to the primary cross-package allocation goal; package overrides bind to package goals and are allowed only when the provider can preserve their authored semantics under the shared strategy. + +All package constraints and canonical bidding amounts in a seller-optimized buy use `total_budget.currency`, and every selected pricing option MUST declare that currency. This one-currency rule also applies to fixed AdCP-authored buys; packages requiring another denomination belong in a separate media buy. Every participating product MUST support the primary cross-package optimization goal. Sellers reject incompatible product combinations, currencies, pricing terms, or delivery constraints with `TERMS_REJECTED` or `BIDDING_PLACEMENT_CONFLICT` and identify the incompatible package. + +Package caps may sum above the media-buy total; that headroom is what permits dynamic allocation. Minimum-spend targets must sum to no more than the total. If package caps cannot collectively spend the total, the seller rejects the request as infeasible. Sellers advertising this behavior declare `media_buy.features.seller_optimized_budget: true` in `get_adcp_capabilities`. + +Continuous allocation decisions inside an accepted seller-optimized buy are delivery behavior. They do not mutate package caps, increment the media-buy revision, create history entries, or constitute separate `reallocate_budget` actions. + +```json +{ + "idempotency_key": "0c491eb7-fdf7-47de-b3c3-7284f2c56a41", + "account": { "account_id": "acc_performance_001" }, + "brand": { "domain": "acmecorp.com" }, + "total_budget": { "amount": 100000, "currency": "USD" }, + "budget_allocation": { + "mode": "seller_optimized", + "optimization_goals": [ + { + "kind": "event", + "event_sources": [ + { "event_source_id": "checkout_events", "event_type": "purchase" } + ] + } + ] + }, + "pacing": "even", + "bidding": { + "cost_per": { "amount": 25, "strength": "cap" }, + "max_bid": 8 + }, + "packages": [ + { + "product_id": "prospecting", + "pricing_option_id": "cpm_auction", + "budget": 70000, + "min_spend_target": 20000 + }, + { + "product_id": "retargeting", + "pricing_option_id": "cpm_auction", + "budget": 60000, + "pacing": "front_loaded" + } + ], + "start_time": "2099-08-01T00:00:00Z", + "end_time": "2099-08-31T23:59:59Z" +} +``` + +The package caps total $130,000, creating room for the seller to allocate the $100,000 shared total toward the better-performing package. The first package has a soft $20,000 minimum-spend target; neither package has a reserved allocation. + #### Known gap: no explicit daily cap -AdCP 3.1 has **no per-day (or per-hour) budget cap field.** Delivery rate is governed by the combination of `budget`, `pacing`, and the flight window (`start_time` / `end_time`) — not a hard daily ceiling the buyer can pin. To constrain daily spend today: +AdCP 3.2 has **no per-day (or per-hour) budget cap field.** Delivery rate is governed by the combination of `budget`, `pacing`, and the flight window (`start_time` / `end_time`) — not a hard daily ceiling the buyer can pin. To constrain daily spend today: - **Approximate a daily rate** by setting `pacing: "even"` and choosing `budget` and flight length so `budget ÷ flight_days` lands near the target daily spend. Enforcement is seller-side and best-effort, not a guaranteed hard cap. - **Tighten the window** by splitting a long flight into shorter packages (e.g. weekly packages), each with its own `budget`, when finer control is needed. diff --git a/docs/media-buy/task-reference/get_media_buys.mdx b/docs/media-buy/task-reference/get_media_buys.mdx index 5915a8e4d0..fb8c99e8d4 100644 --- a/docs/media-buy/task-reference/get_media_buys.mdx +++ b/docs/media-buy/task-reference/get_media_buys.mdx @@ -61,7 +61,10 @@ Returns an array of media buys with current status, creative approval state, and | `status` | Current status (`pending_creatives`, `pending_start`, `active`, `paused`, `completed`, `rejected`, `canceled`). Maps to `media_buy_status` on `create_media_buy` / `update_media_buy` success responses (3.1 vocabulary note above). | | `status_as_of` | ISO 8601 timestamp when the seller last refreshed the returned media-buy-level `status` from its source of truth. For rolled-up statuses, this MUST NOT be later than the oldest upstream status observation that could affect the returned roll-up. Optional; omitted or `null` makes no freshness assertion, and buyers MUST NOT infer live status from absence. Use this to interpret cached or rolled-up statuses separately from `updated_at`, which means the media buy's last modification time. | | `currency` | ISO 4217 currency for media-buy-level monetary values | -| `total_budget` | Total campaign budget (in `currency`) | +| `total_budget` | Hard aggregate lifetime budget (in `currency`) | +| `budget_allocation` | Fixed or seller-optimized cross-package allocation configuration. Omission means fixed for legacy buys. | +| `pacing` | Aggregate media-buy pacing strategy | +| `bidding` | Media-buy-authored bidding policy. Packages that inherit it omit their own `bidding` block. | | `creative_deadline` | Creative upload deadline (ISO 8601) | | `confirmed_at` | ISO 8601 timestamp when the seller committed to this media buy. May be `null` until seller commitment occurs in deferred/manual approval flows; stable after it is set. | | `cancellation` | Cancellation metadata (present only when `status` is `canceled`). Object with `canceled_at` (ISO 8601), `canceled_by` (`"buyer"` or `"seller"`), and optional `reason`. | @@ -78,8 +81,13 @@ Returns an array of media buys with current status, creative approval state, and |-------|-------------| | `package_id` | Seller's package identifier | | `product_id` | Product identifier this package is purchased from. For packages created from an explicit `create_media_buy` package request, sellers MUST echo the request package's `product_id` on every response package object that represents that requested package. | -| `currency` | Optional package-level currency override (defaults to media buy `currency`) | -| `bid_price` | Current bid price for auction-based packages (in package `currency` if present, otherwise media buy `currency`) | +| `budget` | Hard lifetime package spend cap in `media_buy.currency`. In seller-optimized mode this is not a current allocation and may be absent. | +| `min_spend_target` | Soft lifetime package spend target in `media_buy.currency` accepted for seller-optimized allocation. | +| `pacing` | Package pacing preference. It is subordinate to media-buy aggregate `pacing` and cannot cause aggregate delivery to exceed that strategy. | +| `currency` | Legacy/external-buy package denomination. For AdCP-authored buys this equals `media_buy.currency`; it does not reinterpret canonical budget or bidding amounts. | +| `bidding` | Package-authored bidding override. `{automatic: true}` explicitly overrides media-buy controls; omission inherits media-buy `bidding`. | +| `bid_price` | Deprecated legacy bid representation; 3.2 sellers should normalize and echo `bidding`. | +| `optimization_goals` | Current package objective functions, separate from currency-bearing `bidding` policy. | | `format_ids` | Legacy named-format IDs supplied on `create_media_buy`, echoed whenever the original request included them, including dual-emission cases where another selector won precedence. | | `format_option_refs` | Structured 3.1+ format option references supplied on `create_media_buy`, echoed whenever the original request included them. | | `format_kind` | Direct canonical selector supplied on `create_media_buy`, echoed whenever the original request included it, including informational-echo cases where another selector won precedence. | @@ -143,7 +151,7 @@ History entries are **append-only** — sellers MUST NOT modify or delete previo **`not_delivering`** means the package is within its scheduled flight but has delivered zero impressions for at least one full staleness cycle. Implementers must not return `not_delivering` until `staleness_seconds` have elapsed since package activation — a new package with no impressions in its first minutes is expected, not a problem. Check `start_time` to confirm the package is within its flight before acting on this status. -Money fields use this currency precedence: `snapshot.currency` -> `package.currency` -> `media_buy.currency`. +For AdCP-authored buys, budget constraints and canonical bidding always use `media_buy.currency`. `snapshot.currency` identifies the reporting denomination for snapshot spend, and legacy/external buys may still carry `package.currency`; neither changes the denomination of buyer-authored 3.2 controls. ### Webhook Activity diff --git a/docs/media-buy/task-reference/get_products.mdx b/docs/media-buy/task-reference/get_products.mdx index 452289dcbf..2e85385d86 100644 --- a/docs/media-buy/task-reference/get_products.mdx +++ b/docs/media-buy/task-reference/get_products.mdx @@ -395,7 +395,9 @@ Publishers may return proposals alongside products - structured media plans with | `proposal_id` | string | Unique identifier for finalizing this proposal and, once committed, executing it via `create_media_buy` | | `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. | +| `budget_allocation` | BudgetAllocation | Optional cross-product allocation configuration. Omission means a fixed proposal; `seller_optimized` delegates continuous allocation to the seller. | +| `pacing` | string | Optional aggregate media-buy pacing recommendation. On a committed proposal this is part of the firm delivery terms. | +| `allocations` | ProductAllocation[] | Fixed proposals use exact `allocation_percentage` values summing to 100. Seller-optimized proposals omit exact percentages and may use `min_spend_target_percentage` and `max_spend_percentage`. Each allocation may include optional flight dates and subordinate `pacing`, which becomes package pacing on execution. | | `forecast` | DeliveryForecast | Aggregate delivery forecast for the proposal. Contains forecast points with metric ranges. See [Delivery Forecasts](/docs/media-buy/product-discovery/media-products#delivery-forecasts) | | `total_budget_guidance` | object | Optional min/recommended/max budget guidance | | `brief_alignment` | string | How this proposal addresses the campaign brief | diff --git a/docs/media-buy/task-reference/log_event.mdx b/docs/media-buy/task-reference/log_event.mdx index 81624ad152..2185c0f0e8 100644 --- a/docs/media-buy/task-reference/log_event.mdx +++ b/docs/media-buy/task-reference/log_event.mdx @@ -147,8 +147,8 @@ At least one of `uids`, `hashed_email`, `hashed_phone`, `click_id`, or `client_i | Field | Type | Description | |-------|------|-------------| -| `value` | number | Monetary value of the event | -| `currency` | string | ISO 4217 currency code (e.g. `USD`, `EUR`, `GBP`) | +| `value` | number | Monetary value of the event. Requires `currency` when the source declares `value_currencies`; legacy sources remain compatible but cannot feed canonical ROAS. | +| `currency` | string | ISO 4217 code declared in the event source's `value_currencies` when that contract is present (e.g. `USD`, `EUR`, `GBP`) | | `order_id` | string | Unique order or transaction identifier | | `content_ids` | string[] | Product or content identifiers. For catalog-driven campaigns, these match the catalog's `content_id_type` (e.g., SKUs, GTINs, job IDs). See [catalog-item attribution](/docs/media-buy/conversion-tracking#catalog-item-attribution). | | `content_type` | string | Category of content (product, service, etc.) | @@ -554,7 +554,7 @@ Choose `event_id` values that are stable across retries: 4. **Batch when possible** - Send up to 10,000 events per request to reduce API calls. Events within a batch are processed independently. -5. **Include value and currency** - For purchase events, always include `custom_data.value` and `custom_data.currency` to enable ROAS reporting and optimization. +5. **Include contracted value and currency for ROAS** - A source used for canonical ROAS declares `value_currencies` through `sync_event_sources`; its monetary records require `custom_data.currency` from that list. Each ROAS buy consumes only records matching its media-buy currency. Legacy sources without this contract remain valid for non-ROAS events. 6. **Stable event IDs** - Use deterministic event IDs (order numbers, transaction IDs) rather than random UUIDs. This ensures safe retries without duplicate counting. diff --git a/docs/media-buy/task-reference/sync_event_sources.mdx b/docs/media-buy/task-reference/sync_event_sources.mdx index 77b992fde4..9cd3c56e9e 100644 --- a/docs/media-buy/task-reference/sync_event_sources.mdx +++ b/docs/media-buy/task-reference/sync_event_sources.mdx @@ -101,6 +101,7 @@ asyncio.run(main()) | `event_source_id` | string | Yes | Unique identifier for this event source | | `name` | string | No | Human-readable name | | `event_types` | [EventType](/docs/media-buy/conversion-tracking/#event-types)[] | No | Event types this source handles. If omitted, accepts all event types. | +| `value_currencies` | string[] | No | ISO 4217 currencies accepted for monetary event values. Required before using this source for canonical ROAS. One source may support multiple currencies; each buy uses only records matching its media-buy currency. | | `action_source` | [ActionSource](/docs/media-buy/conversion-tracking/#action-sources) | No | Flat source category for compatibility (`website`, `app`, `system_generated`, etc.). Pair with `surface` when the flat value is too coarse. | | `allowed_domains` | string[] | No | Domains authorized to send events for this source | | `surface` | [EventSurface](/docs/media-buy/conversion-tracking/#event-surfaces) | No | Structured surface this source represents, such as an owned channel, profile, feed, podcast, newsletter list, website, app, or store | diff --git a/docs/media-buy/task-reference/update_media_buy.mdx b/docs/media-buy/task-reference/update_media_buy.mdx index 77fa8f9a87..82d7dc2a21 100644 --- a/docs/media-buy/task-reference/update_media_buy.mdx +++ b/docs/media-buy/task-reference/update_media_buy.mdx @@ -30,14 +30,13 @@ The mapping is normative — sellers and SDKs MUST use this table to translate b | `extend_flight` | `end_time`, `packages[].end_time` (later than current) | End-time push only | | `shorten_flight` | `end_time`, `packages[].end_time` (earlier than current) | End-time pull only | | `update_flight_dates` | `start_time`, `end_time`, `packages[].start_time`, `packages[].end_time` (shift) | Start-shift distinct from extend/shorten; covers both buy-level and package-level dates | -| `increase_budget` | `packages[].budget` (raise) | | -| `decrease_budget` | `packages[].budget` (lower) | Sellers typically bound by already-spent | +| `increase_budget` | `total_budget`, `packages[].budget` (raise or `null`) | `packages[].budget: null` removes a seller-optimized package cap and therefore relaxes, rather than lowers, its ceiling. | +| `decrease_budget` | `total_budget`, `packages[].budget` (lower) | Sellers typically bound by already-spent | | `reallocate_budget` | `packages[].budget` (redistribute, aggregate unchanged) | | - -The direction-of-change actions (`extend_flight` / `shorten_flight`, `increase_budget` / `decrease_budget` / `reallocate_budget`) share their `update_fields` paths; the action is determined by comparing the requested value against the buy's current state, not by which field is set. Server-side dispatch enforcement MUST diff request-vs-current to pick the right action and reject with `ACTION_NOT_ALLOWED` if the resolved action is not in the buy's `available_actions[]`. - +| `update_budget_allocation` | `budget_allocation`, `packages[].min_spend_target` | Changes fixed/shared mode, cross-package goals, or soft minimum targets, including `null` to clear a target. Automatic allocation inside an accepted seller-optimized buy is not an update action. | | `update_targeting` | `packages[].targeting_overlay`, `packages[].keyword_targets_add`, `packages[].keyword_targets_remove`, `packages[].negative_keywords_add`, `packages[].negative_keywords_remove` | | -| `update_pacing` | `packages[].pacing` | | +| `update_pacing` | `pacing`, `packages[].pacing` | Media-buy pacing controls aggregate spend; package pacing is subordinate. | +| `update_bidding` | `bidding`, `packages[].bidding` | Replaces or clears complete authored bidding blocks. The seller validates every resulting effective package policy before mutation. | | `update_frequency_caps` | `packages[].targeting_overlay.frequency_cap` | Renegotiated mid-flight more often than other targeting. Field is singular `frequency_cap` (single rule), not plural. | | `replace_creative` | `packages[].creatives[]` swap (assignments unchanged) | Distinct AM workflow from changing assignment set | | `update_creative_assignments` | `packages[].creative_assignments` | Which creatives go where | @@ -45,6 +44,10 @@ The direction-of-change actions (`extend_flight` / `shorten_flight`, `increase_b | `add_packages` | `new_packages[]` | | | `remove_packages` | `packages[].canceled: true` | | +The direction-of-change actions (`extend_flight` / `shorten_flight`, `increase_budget` / `decrease_budget` / `reallocate_budget`) share their `update_fields` paths; the action is determined by comparing the requested value against the buy's current state, not by which field is set. Server-side dispatch enforcement MUST diff request-vs-current to pick the right action and reject with `ACTION_NOT_ALLOWED` if the resolved action is not in the buy's `available_actions[]`. + +For seller-optimized constraints, clearing `packages[].budget` with `null` resolves to `increase_budget` because it removes a hard ceiling. Clearing `packages[].min_spend_target` resolves to `update_budget_allocation`. A request clearing both requires both actions when the seller enforces field-level actions conjunctively. Sellers that do not advertise the required action reject the update without mutation; `seller_optimized_budget: true` alone does not promise mid-flight constraint changes. + The coarse legacy actions (`update_budget`, `update_dates`, `update_packages`, `sync_creatives`) cover the finer-grained vocabulary in a single value for sellers still emitting the 3.0 enum surface. Sellers SHOULD migrate to the finer set; the legacy values are removed in 4.0. ### Action modes @@ -93,7 +96,7 @@ const createResult = await testAgent.createMediaBuy({ id: 'display_300x250_image' }], budget: 800, - bid_price: 5.00 + bidding: { bid_amount: 5.00 } }], start_time: startDate.toISOString(), end_time: endDate.toISOString() @@ -154,7 +157,7 @@ async def create_and_pause_campaign(): 'id': 'display_300x250_image' }], 'budget': 800, - 'bid_price': 5.00 + 'bidding': {'bid_amount': 5.00} }], start_time=start_date.strftime('%Y-%m-%dT%H:%M:%SZ'), end_time=end_date.strftime('%Y-%m-%dT%H:%M:%SZ') @@ -199,7 +202,11 @@ asyncio.run(create_and_pause_campaign()) | `reporting_webhook` | object | No | Update reporting webhook configuration (see below) | | `idempotency_key` | string | No | Unique key for safe retries. If an update with the same key has already been processed, the seller returns the original response. MUST be unique per (seller, request) pair. Min 16 chars. | | `invoice_recipient` | [BusinessEntity](/docs/building/by-layer/L2/accounts-and-agents#billing-entity-and-invoice-recipient) | No | Override who receives the invoice for this buy. The seller MUST validate authorization and include in `check_governance` when governance agents are configured. | -| `new_packages` | PackageRequest[] | No | New packages to add to this media buy. Same shape as `create_media_buy` packages. Only supported by sellers that advertise `add_packages` in `valid_actions`. | +| `new_packages` | PackageRequest[] | No | New packages to add to this media buy. Same shape as `create_media_buy` packages. When `budget_allocation` is omitted or fixed, each new package requires `budget` and cannot use `min_spend_target`. To add an uncapped package to an existing seller-optimized buy, include the resulting seller-optimized `budget_allocation` block so the allocation context is explicit. Only supported by sellers that advertise `add_packages` in `valid_actions`. | +| `total_budget` | TotalBudget | No | Updated hard aggregate lifetime budget. Already-spent amounts continue to count against the new total. `currency` must equal the existing media-buy currency; updates do not redenominate a buy. | +| `budget_allocation` | BudgetAllocation | No | Updated fixed or seller-optimized allocation configuration. Mode changes require `update_budget_allocation` in `available_actions`. | +| `pacing` | string | No | Updated aggregate media-buy pacing strategy. | +| `bidding` | BiddingPolicy \| null | No | Replace the complete media-buy bidding default, use `{automatic: true}` for an explicit automatic policy, or clear it with `null`. Goal binding follows the resulting allocation mode; monetary amounts remain in the media-buy currency. Package-authored overrides remain explicit. | | `push_notification_config` | object | No | Webhook for async operation notifications | `account` and `media_buy_id` are always required. @@ -232,13 +239,15 @@ Configure automated delivery reporting for this media buy: | `paused` | boolean | Pause/resume specific package (`true` = paused, `false` = active) | | `canceled` | boolean | Cancel this package (irreversible). Must be `true` when present. Seller may reject with `NOT_CANCELLABLE`. | | `cancellation_reason` | string | Reason for canceling this package | -| `budget` | number | Updated budget allocation | +| `budget` | number \| null | Updated hard package spend cap in the media-buy currency. In seller-optimized mode this is not a current allocation; `null` removes the cap. A resulting fixed allocation still requires a budget on every package. | +| `min_spend_target` | number \| null | Updated soft package minimum-spend target in the media-buy currency for seller-optimized allocation; `null` removes the target. | | `impressions` | number | Updated impression goal for this package | | `start_time` | string | Updated flight start date/time in ISO 8601 format. Must fall within the media buy's date range. | | `end_time` | string | Updated flight end date/time in ISO 8601 format. Must fall within the media buy's date range. | | `pacing` | string | Updated pacing strategy | -| `bid_price` | number | Updated bid price (auction products only). This is the exact bid/price to honor unless the selected pricing option has `max_bid: true`, in which case it is treated as the buyer's maximum willingness to pay (ceiling). | -| `optimization_goals` | OptimizationGoal[] | Replace all optimization goals for this package. Uses replacement semantics — omit to leave goals unchanged. | +| `bidding` | BiddingPolicy \| null | Replace the complete package-authored override, use `{automatic: true}` to explicitly disable inherited buyer controls, or clear it with `null` so the package inherits media-buy bidding. During legacy migration, `null` may accompany `bid_price` or a monetary optimization-goal target; only a non-null canonical block conflicts with those representations. | +| `bid_price` | number | **Deprecated in 3.2.** Legacy package bid representation; cannot be combined with a non-null `bidding` object. | +| `optimization_goals` | OptimizationGoal[] | Replace objective functions for this package. Currency-bearing policy belongs in `bidding`; legacy monetary targets cannot be combined with canonical bidding. | | `targeting_overlay` | TargetingOverlay | Updated targeting restrictions. Uses replacement semantics; changing `signal_targeting_groups` can alter priced signal selection, so sellers MAY reject with `REQUOTE_REQUIRED` when the selected signal, group expression, or signal `pricing_option_id` falls outside the original quote. Sellers SHOULD reject attempts to change fixed signal selections and MUST echo applied fixed/default signal groups on the resulting package state. A demographic change is compiled against the product's current `demographic_targeting` declaration and refreshes `demographic_targeting_resolution` atomically; unsupported or inexact changes are rejected rather than broadened. | | `catalogs` | Catalog[] | Replace the catalogs this package promotes. Uses replacement semantics — omit to leave unchanged. | | `keyword_targets_add` | KeywordTarget[] | Keyword targets to add or upsert by (keyword, match_type) identity. On create, these are set as `keyword_targets` inside `targeting_overlay`. | @@ -250,7 +259,7 @@ Configure automated delivery reporting for this media buy: `package_id` is required to identify the package to update. -For how `budget`, `pacing`, `impressions`, and flight dates interact — and the current lack of an explicit daily-cap field — see [Budget & Pacing Controls](/docs/media-buy/task-reference/create_media_buy#budget--pacing-controls) on the `create_media_buy` reference. +For budget/pacing interaction and bidding inheritance, see [Budget & Pacing Controls](/docs/media-buy/task-reference/create_media_buy#budget--pacing-controls) and [BiddingPolicy Object](/docs/media-buy/task-reference/create_media_buy#biddingpolicy-object) on the `create_media_buy` reference. ## Response @@ -780,7 +789,7 @@ When approval is needed, `implementation_date` will be `null` and `affected_pack "product_id": "sports_display_auction", "pricing_option_id": "cpm_auction", "budget": 50000, - "bid_price": 7.25, + "bidding": { "bid_amount": 7.25 }, "pacing": "even", "creative_assignments": [ { "creative_id": "creative_video_v2" } @@ -847,7 +856,7 @@ Updates may be asynchronous, especially with seller approval. "product_id": "sports_display_auction", "pricing_option_id": "cpm_auction", "budget": 50000, - "bid_price": 7.25, + "bidding": { "bid_amount": 7.25 }, "pacing": "even", "creative_assignments": [ { "creative_id": "creative_video_v2" } diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx index ecf8de5734..49b5bffd76 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -278,13 +278,44 @@ Sellers with mixed approval policies SHOULD declare `require_human` unless every #### features -Optional media-buy features. **If declared true, seller MUST honor requests using that feature.** +Optional media-buy features. Boolean declarations are commitments when true. Structured declarations commit only to the explicitly advertised sub-capabilities. | Feature | Description | |---------|-------------| | `inline_creative_management` | Accepts creatives inline in `create_media_buy` and `update_media_buy` package payloads | | `property_list_filtering` | Honors `property_list` parameter in `get_products` | | `catalog_management` | Supports `sync_catalogs` for catalog feed management | +| `seller_optimized_budget` | Supports shared media-buy budgets and cross-package allocation goals | +| `bidding_policy` | Structured canonical bidding support by authored scope, allocation context, modes, strengths, and supported combinations | + +`bidding_policy` is intentionally not a boolean. Each `media_buy` or `package` scope contains independent `fixed` and/or `seller_optimized` profiles. Every profile's `modes` lists standalone support from `automatic`, `bid_amount`, `max_bid`, `cost_per`, and `roas`. A profile advertising standalone `cost_per` also declares `cost_per_strengths`; `roas` similarly declares `roas_strengths`. Optional `supported_combinations` entries independently identify `max_bid_with_cost_per` or `max_bid_with_roas` and list the strengths supported in that combination; a combination-only component need not appear in `modes`. Presence of one scope, allocation context, mode, strength, or combination makes no claim about another. + +```json +{ + "features": { + "bidding_policy": { + "media_buy": { + "fixed": { + "modes": ["max_bid", "cost_per"], + "cost_per_strengths": ["cap", "target"], + "supported_combinations": [ + { + "kind": "max_bid_with_cost_per", + "cost_per_strengths": ["cap"] + } + ] + } + }, + "package": { + "seller_optimized": { + "modes": ["automatic", "cost_per"], + "cost_per_strengths": ["cap"] + } + } + } + } +} +``` #### content_standards @@ -991,7 +1022,35 @@ const buy = await client.createMediaBuy({ "creative_approval_mode": "auto_approve", "features": { "inline_creative_management": true, - "property_list_filtering": true + "property_list_filtering": true, + "seller_optimized_budget": true, + "bidding_policy": { + "media_buy": { + "fixed": { + "modes": ["max_bid", "cost_per"], + "cost_per_strengths": ["cap", "target"], + "supported_combinations": [ + { + "kind": "max_bid_with_cost_per", + "cost_per_strengths": ["cap"] + } + ] + }, + "seller_optimized": { + "modes": ["cost_per"], + "cost_per_strengths": ["cap"] + } + }, + "package": { + "fixed": { + "modes": ["automatic", "bid_amount", "cost_per"], + "cost_per_strengths": ["cap"] + }, + "seller_optimized": { + "modes": ["automatic"] + } + } + } }, "execution": { "axe_integrations": ["https://axe.example.com"], diff --git a/scripts/error-code-drift-dispositions.json b/scripts/error-code-drift-dispositions.json index f3f92291b3..42f5cbcc42 100644 --- a/scripts/error-code-drift-dispositions.json +++ b/scripts/error-code-drift-dispositions.json @@ -1,6 +1,16 @@ { "$comment": "Disposition registry for error codes that are present in the current source enum but absent from the 3.0.x maintenance branch. Read by scripts/lint-error-code-drift.cjs. Every code in the AHEAD set (main \\ 3.0.x) MUST have an entry here; codes missing from this file fail the lint. Policy: 3.0.x is wire-stable. Adding a new enum value is a wire change (a 3.0.x receiver decoding a 3.1 sender's error.code cannot match against an enum it doesn't carry, and JSON Schema enum is closed by default), so the default disposition for any new code is 'held-for-next-minor' with target_version='3.1'. 'held-for-next-major' (target_version='4.0') is for codes that should defer past 3.x \u2014 typically because the surrounding subsystem is being reworked. 'backport-pending' is reserved for prose-only or doc-comment-only changes to a code that already exists on 3.0.x; the lint rejects backport-pending entries whose code is not on 3.0.x. Valid `disposition` values: 'backport-pending', 'held-for-next-minor', 'held-for-next-major', 'unclassified'. `target_version` (required for held-*) is a 'MAJOR.MINOR' string. When a code lands on 3.0.x its entry SHOULD be removed; the lint flags stale entries.", "dispositions": { + "AMBIGUOUS_BIDDING_POLICY": { + "disposition": "held-for-next-minor", + "target_version": "3.2", + "note": "Canonical 3.2 bidding-policy migration guard. Returned when a package combines the new bidding block with legacy bid_price or legacy monetary optimization targets, leaving execution policy ambiguous. New wire code — held for 3.2." + }, + "BIDDING_PLACEMENT_CONFLICT": { + "disposition": "held-for-next-minor", + "target_version": "3.2", + "note": "Canonical 3.2 bidding-policy placement validation. Returned when media-buy and package policies cannot be represented together by the provider's campaign/ad-set hierarchy. New wire code — held for 3.2." + }, "SIGNAL_TARGETING_INCOMPATIBLE": { "disposition": "held-for-next-minor", "target_version": "3.1", diff --git a/static/compliance/source/protocols/media-buy/index.yaml b/static/compliance/source/protocols/media-buy/index.yaml index 942abb4a1a..8fac4ac954 100644 --- a/static/compliance/source/protocols/media-buy/index.yaml +++ b/static/compliance/source/protocols/media-buy/index.yaml @@ -23,6 +23,10 @@ requires_scenarios: - media_buy_seller/create_media_buy_async - media_buy_seller/dependency_impairment - media_buy_seller/dependency_impairment_cardinality + - media_buy_seller/seller_optimized_budget + - media_buy_seller/bidding_policy_placement + - media_buy_seller/bidding_cost_binding + - media_buy_seller/package_automatic_bidding narrative: | You run a sell-side platform — a publisher, SSP, retail media network, or any system that diff --git a/static/compliance/source/protocols/media-buy/scenarios/bidding_cost_binding.yaml b/static/compliance/source/protocols/media-buy/scenarios/bidding_cost_binding.yaml new file mode 100644 index 0000000000..74a2784496 --- /dev/null +++ b/static/compliance/source/protocols/media-buy/scenarios/bidding_cost_binding.yaml @@ -0,0 +1,152 @@ +id: media_buy_seller/bidding_cost_binding +version: "1.0.0" +title: "Seller rejects ambiguous fixed cost-control binding" +category: media_buy_seller +summary: "Verifies that fixed media-buy cost caps bind deterministically and cannot mean different result units across inheriting packages." +track: media_buy + +requires_capability: + path: media_buy.features.bidding_policy.media_buy.fixed.cost_per_strengths + contains: cap + +required_tools: + - create_media_buy + +invariants: + - status.monotonic + +narrative: | + A fixed media-buy cost control binds separately to each inheriting package's + primary goal. One amount cannot mean cost per click for one package and cost + per view for another. Sellers advertising fixed media-buy cost-cap support + reject that ambiguity before creating provider state, even when they do not + support media-buy max_bid. + +agent: + interaction_model: media_buy_seller + capabilities: + - sells_media + - supports_non_guaranteed + - bidding_policy + examples: + - "Auction seller with campaign-level outcome-cost controls" + +caller: + role: buyer_agent + example: "Pinnacle Agency (buyer)" + +prerequisites: + description: | + The seller supports fixed media-buy cost caps across two USD auction + products whose package goals can use different result units. + test_kit: "test-kits/acme-outdoor.yaml" + controller_seeding: true + +fixtures: + products: + - product_id: "cost_binding_clicks" + delivery_type: "non_guaranteed" + channels: ["display"] + format_ids: + - id: "display_300x250" + - product_id: "cost_binding_views" + delivery_type: "non_guaranteed" + channels: ["display"] + format_ids: + - id: "display_300x250" + pricing_options: + - product_id: "cost_binding_clicks" + pricing_option_id: "clicks_cpm" + pricing_model: "cpm" + currency: "USD" + floor_price: 1.50 + - product_id: "cost_binding_views" + pricing_option_id: "views_cpm" + pricing_model: "cpm" + currency: "USD" + floor_price: 2.00 + +phases: + - id: setup + title: "Establish account" + 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 an active account." + sample_request: + accounts: + - brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + billing: "operator" + idempotency_key: "$generate:uuid_v4#bidding_cost_binding_sync_accounts" + validations: + - check: response_schema + description: "Response matches sync-accounts-response.json" + - check: field_present + path: "accounts[0].account_id" + description: "Account has a seller-assigned identifier" + + - id: reject_incompatible_fixed_cost_binding + title: "Reject one fixed cost control across incompatible goal units" + steps: + - id: create_media_buy_with_ambiguous_fixed_cost_binding + title: "Reject a cost cap that would mean different results" + 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" + stateful: false + expect_error: true + negative_path: payload_well_formed + expected: | + Reject before mutation with BIDDING_PLACEMENT_CONFLICT. The cost cap + would bind to clicks for the first package and views for the second, + so the single amount has no common result unit. + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + brand: + domain: "acmeoutdoor.example" + total_budget: + amount: 100000 + currency: "USD" + budget_allocation: + mode: "fixed" + bidding: + cost_per: + amount: 4 + strength: "cap" + packages: + - product_id: "cost_binding_clicks" + pricing_option_id: "clicks_cpm" + budget: 60000 + optimization_goals: + - kind: "metric" + metric: "clicks" + priority: 1 + - product_id: "cost_binding_views" + pricing_option_id: "views_cpm" + budget: 40000 + optimization_goals: + - kind: "metric" + metric: "views" + priority: 1 + start_time: "asap" + end_time: "2099-06-30T23:59:59Z" + idempotency_key: "$generate:uuid_v4#bidding_cost_binding_incompatible" + validations: + - check: error_code + value: "BIDDING_PLACEMENT_CONFLICT" + description: "Seller rejects a fixed cost control whose inheriting result units differ" + - check: field_value + path: "errors[0].field" + value: "bidding.cost_per" + description: "Error identifies the ambiguous media-buy cost control" diff --git a/static/compliance/source/protocols/media-buy/scenarios/bidding_policy_placement.yaml b/static/compliance/source/protocols/media-buy/scenarios/bidding_policy_placement.yaml new file mode 100644 index 0000000000..7f275a5608 --- /dev/null +++ b/static/compliance/source/protocols/media-buy/scenarios/bidding_policy_placement.yaml @@ -0,0 +1,298 @@ +id: media_buy_seller/bidding_policy_placement +version: "1.0.0" +title: "Seller preserves bidding scope and explicit automatic overrides" +category: media_buy_seller +summary: "Verifies that a seller advertising fixed media-buy max_bid support accepts an inherited default, echoes it once, and does not materialize package copies. When package automatic support is also advertised, it verifies an explicit automatic override." +track: media_buy + +requires_capability: + path: media_buy.features.bidding_policy.media_buy.fixed.modes + contains: max_bid + +required_tools: + - get_products + - create_media_buy + - get_media_buys + +invariants: + - status.monotonic + +narrative: | + Media-buy bidding is a complete default inherited by packages that omit a + package block. Readback preserves authored scope: the seller echoes media-buy + bidding once and MUST NOT materialize that inherited policy on each package. + + This scenario uses fixed package budgets so it does not require the separate + seller_optimized_budget capability. The same placement rules apply under a + shared budget, where media-buy policy is authoritative for provider-native + campaign strategy and unsupported package overrides fail before mutation. + + When the seller independently advertises fixed package automatic support, a + capability-gated phase verifies that `{automatic:true}` is preserved as an + explicit package override while its sibling continues to inherit max_bid. + +agent: + interaction_model: media_buy_seller + capabilities: + - sells_media + - supports_non_guaranteed + - bidding_policy + examples: + - "Campaign-level auction ceiling inherited by ad sets" + +caller: + role: buyer_agent + example: "Pinnacle Agency (buyer)" + +prerequisites: + description: | + The seller supports two non-guaranteed CPM products in USD and canonical + max_bid policy at media-buy scope. + test_kit: "test-kits/acme-outdoor.yaml" + controller_seeding: true + +fixtures: + products: + - product_id: "bidding_default_display" + delivery_type: "non_guaranteed" + channels: ["display"] + format_ids: + - id: "display_300x250" + - product_id: "bidding_override_display" + delivery_type: "non_guaranteed" + channels: ["display"] + format_ids: + - id: "display_300x250" + pricing_options: + - product_id: "bidding_default_display" + pricing_option_id: "default_cpm" + pricing_model: "cpm" + currency: "USD" + floor_price: 1.50 + - product_id: "bidding_override_display" + pricing_option_id: "override_cpm" + pricing_model: "cpm" + currency: "USD" + floor_price: 2.00 + +phases: + - id: setup + title: "Establish account and discover auction products" + 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 an active account." + sample_request: + accounts: + - brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + billing: "operator" + idempotency_key: "$generate:uuid_v4#bidding_policy_placement_sync_accounts" + validations: + - check: response_schema + description: "Response matches sync-accounts-response.json" + - check: field_present + path: "accounts[0].account_id" + description: "Account has a seller-assigned identifier" + + - id: get_products_for_bidding + title: "Discover eligible auction products" + 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" + stateful: false + expected: "Return the seeded USD auction products." + sample_request: + buying_mode: "brief" + brief: "Buy two display packages with one inherited CPM ceiling." + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + validations: + - check: response_schema + description: "Response matches get-products-response.json" + - check: field_present + path: "products[1]" + description: "At least two compatible products are available" + + - id: create_bidding_buy + title: "Create a buy with inherited policy" + steps: + - id: create_media_buy_with_bidding + title: "Author a media-buy default" + 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" + stateful: true + expected: | + Accept the media-buy max bid. Echo the media-buy block once and omit + bidding from both inheriting packages. + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + brand: + domain: "acmeoutdoor.example" + total_budget: + amount: 100000 + currency: "USD" + bidding: + max_bid: 7 + packages: + - product_id: "bidding_default_display" + pricing_option_id: "default_cpm" + budget: 60000 + - product_id: "bidding_override_display" + pricing_option_id: "override_cpm" + budget: 40000 + start_time: "asap" + end_time: "2099-06-30T23:59:59Z" + idempotency_key: "$generate:uuid_v4#bidding_policy_placement_create" + context_outputs: + - name: media_buy_id + path: "media_buy_id" + validations: + - check: response_schema + description: "Response matches create-media-buy-response.json" + - check: field_present + path: "media_buy_id" + description: "Seller creates the media buy" + - check: field_value + path: "bidding.max_bid" + value: 7 + description: "Response preserves media-buy-authored auction ceiling" + - check: field_absent + path: "packages[0].bidding" + description: "Inherited media-buy bidding is not copied into the package" + - check: field_absent + path: "packages[1].bidding" + description: "The second inheriting package also omits a copied policy" + + - id: read_bidding_buy + title: "Verify durable authored-scope readback" + steps: + - id: get_media_buy_with_bidding + title: "Read back the media buy" + task: get_media_buys + schema_ref: "media-buy/get-media-buys-request.json" + response_schema_ref: "media-buy/get-media-buys-response.json" + doc_ref: "/media-buy/task-reference/get_media_buys" + stateful: true + expected: "Return the media-buy default once and keep inherited package policy implicit." + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + media_buy_ids: + - "$context.media_buy_id" + validations: + - check: response_schema + description: "Response matches get-media-buys-response.json" + - check: field_value + path: "media_buys[0].bidding.max_bid" + value: 7 + description: "Read preserves the media-buy-authored policy" + - check: field_absent + path: "media_buys[0].packages[0].bidding" + description: "Read preserves inheritance rather than materializing a package copy" + - check: field_absent + path: "media_buys[0].packages[1].bidding" + description: "Read keeps the second inherited package implicit" + + - id: explicit_package_automatic_override + title: "Preserve an explicit package automatic override" + requires_capability: + path: media_buy.features.bidding_policy.package.fixed.modes + contains: automatic + steps: + - id: create_media_buy_with_automatic_override + title: "Override inherited max bid with package automatic bidding" + 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" + stateful: true + expected: | + Preserve the media-buy max bid once. Keep the first package's + inheritance implicit and echo `{automatic:true}` only on the second + package as an explicit authored override. + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + brand: + domain: "acmeoutdoor.example" + total_budget: + amount: 100000 + currency: "USD" + budget_allocation: + mode: "fixed" + bidding: + max_bid: 7 + packages: + - product_id: "bidding_default_display" + pricing_option_id: "default_cpm" + budget: 60000 + - product_id: "bidding_override_display" + pricing_option_id: "override_cpm" + budget: 40000 + bidding: + automatic: true + start_time: "asap" + end_time: "2099-06-30T23:59:59Z" + idempotency_key: "$generate:uuid_v4#bidding_policy_placement_automatic_override" + context_outputs: + - name: automatic_media_buy_id + path: "media_buy_id" + validations: + - check: response_schema + description: "Response matches create-media-buy-response.json" + - check: field_value + path: "bidding.max_bid" + value: 7 + description: "Response preserves the media-buy default" + - check: field_absent + path: "packages[0].bidding" + description: "The inheriting package remains implicit" + - check: field_value + path: "packages[1].bidding.automatic" + value: true + description: "The explicit automatic override is echoed at package scope" + + - id: get_media_buy_with_automatic_override + title: "Read back the explicit automatic override" + task: get_media_buys + schema_ref: "media-buy/get-media-buys-request.json" + response_schema_ref: "media-buy/get-media-buys-response.json" + doc_ref: "/media-buy/task-reference/get_media_buys" + stateful: true + expected: "Keep the inherited package implicit and the explicit automatic override durable." + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + media_buy_ids: + - "$context.automatic_media_buy_id" + validations: + - check: response_schema + description: "Response matches get-media-buys-response.json" + - check: field_absent + path: "media_buys[0].packages[0].bidding" + description: "Readback retains inheritance" + - check: field_value + path: "media_buys[0].packages[1].bidding.automatic" + value: true + description: "Readback preserves the authored automatic override" diff --git a/static/compliance/source/protocols/media-buy/scenarios/package_automatic_bidding.yaml b/static/compliance/source/protocols/media-buy/scenarios/package_automatic_bidding.yaml new file mode 100644 index 0000000000..73c5d1e489 --- /dev/null +++ b/static/compliance/source/protocols/media-buy/scenarios/package_automatic_bidding.yaml @@ -0,0 +1,152 @@ +id: media_buy_seller/package_automatic_bidding +version: "1.0.0" +title: "Seller preserves explicit package automatic bidding" +category: media_buy_seller +summary: "Verifies package-scoped automatic bidding independently of media-buy bidding support." +track: media_buy + +requires_capability: + path: media_buy.features.bidding_policy.package.fixed.modes + contains: automatic + +required_tools: + - create_media_buy + - get_media_buys + +invariants: + - status.monotonic + +narrative: | + `{automatic:true}` is an authored package policy, not an omitted default. + Sellers advertising fixed package automatic support preserve it on create + and durable readback even when they advertise no media-buy bidding mode. + The separate bidding_policy_placement scenario tests the stronger case where + this package block overrides an inherited media-buy max bid. + +agent: + interaction_model: media_buy_seller + capabilities: + - sells_media + - supports_non_guaranteed + - bidding_policy + examples: + - "Auction seller with package-native automatic delivery" + +caller: + role: buyer_agent + example: "Pinnacle Agency (buyer)" + +prerequisites: + description: "The seller supports explicit automatic bidding on a fixed USD auction package." + test_kit: "test-kits/acme-outdoor.yaml" + controller_seeding: true + +fixtures: + products: + - product_id: "package_automatic_display" + delivery_type: "non_guaranteed" + channels: ["display"] + format_ids: + - id: "display_300x250" + pricing_options: + - product_id: "package_automatic_display" + pricing_option_id: "automatic_cpm" + pricing_model: "cpm" + currency: "USD" + floor_price: 1.50 + +phases: + - id: setup + title: "Establish account" + 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 an active account." + sample_request: + accounts: + - brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + billing: "operator" + idempotency_key: "$generate:uuid_v4#package_automatic_bidding_sync_accounts" + validations: + - check: response_schema + description: "Response matches sync-accounts-response.json" + - check: field_present + path: "accounts[0].account_id" + description: "Account has a seller-assigned identifier" + + - id: create_explicit_automatic_package + title: "Create an explicit package automatic policy" + steps: + - id: create_media_buy_with_package_automatic + title: "Author automatic bidding at package scope" + 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" + stateful: true + expected: "Echo `{automatic:true}` on the package without materializing a media-buy policy." + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + brand: + domain: "acmeoutdoor.example" + packages: + - product_id: "package_automatic_display" + pricing_option_id: "automatic_cpm" + budget: 20000 + bidding: + automatic: true + start_time: "asap" + end_time: "2099-06-30T23:59:59Z" + idempotency_key: "$generate:uuid_v4#package_automatic_bidding_create" + context_outputs: + - name: automatic_package_buy_id + path: "media_buy_id" + validations: + - check: response_schema + description: "Response matches create-media-buy-response.json" + - check: field_absent + path: "bidding" + description: "No media-buy policy is materialized" + - check: field_value + path: "packages[0].bidding.automatic" + value: true + description: "Response preserves the explicit package policy" + + - id: read_explicit_automatic_package + title: "Read back explicit package automatic bidding" + steps: + - id: get_media_buy_with_package_automatic + title: "Verify durable authored scope" + task: get_media_buys + schema_ref: "media-buy/get-media-buys-request.json" + response_schema_ref: "media-buy/get-media-buys-response.json" + doc_ref: "/media-buy/task-reference/get_media_buys" + stateful: true + expected: "Keep automatic bidding explicit on the package and absent at media-buy scope." + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + media_buy_ids: + - "$context.automatic_package_buy_id" + validations: + - check: response_schema + description: "Response matches get-media-buys-response.json" + - check: field_absent + path: "media_buys[0].bidding" + description: "Readback keeps media-buy policy absent" + - check: field_value + path: "media_buys[0].packages[0].bidding.automatic" + value: true + description: "Readback preserves explicit package automatic bidding" diff --git a/static/compliance/source/protocols/media-buy/scenarios/seller_optimized_budget.yaml b/static/compliance/source/protocols/media-buy/scenarios/seller_optimized_budget.yaml new file mode 100644 index 0000000000..d469d72ce9 --- /dev/null +++ b/static/compliance/source/protocols/media-buy/scenarios/seller_optimized_budget.yaml @@ -0,0 +1,588 @@ +id: media_buy_seller/seller_optimized_budget +version: "1.0.0" +title: "Seller accepts and persists a seller-optimized budget across packages" +category: media_buy_seller +summary: "Verifies that a seller advertising seller_optimized_budget accepts and persists a shared total with cross-package goals, package caps, a soft minimum-spend target, and two-level pacing. When media-buy cost caps are also advertised, verifies that the control binds to the allocation goal." +track: media_buy + +requires_capability: + path: media_buy.features.seller_optimized_budget + equals: true + +required_tools: + - get_products + - create_media_buy + - get_media_buys + +invariants: + - status.monotonic + +narrative: | + A seller-optimized buy has one hard media-buy total and multiple eligible + packages. Package budgets are optional hard caps rather than reserved + allocations; min_spend_target is a soft preference. The seller allocates + the shared total continuously against budget_allocation.optimization_goals. + + The scenario verifies the configuration boundary rather than an internal + optimizer algorithm: acceptance, aggregate budget and pacing echo, package + constraint echo, and durable readback. Continuous internal allocation is + delivery behavior and does not mutate package caps or the buy revision. + An optional phase for sellers advertising media-buy cost caps verifies that + the outcome-cost control binds to the allocation goal, not independently to + unrelated package-local goals. + +agent: + interaction_model: media_buy_seller + capabilities: + - sells_media + - supports_non_guaranteed + - seller_optimized_budget + examples: + - "Social platforms allocating a campaign budget across ad sets" + - "Retail-media platforms allocating a shared budget across tactics" + - "DSPs optimizing one insertion-order budget across line items" + +caller: + role: buyer_agent + example: "Pinnacle Agency (buyer)" + +prerequisites: + description: | + The seller supports a common click optimization goal across two + non-guaranteed products in one currency. The test controller seeds both + products so the sample request does not rely on undeclared fixture IDs. + test_kit: "test-kits/acme-outdoor.yaml" + controller_seeding: true + +fixtures: + products: + - product_id: "shared_budget_prospecting" + delivery_type: "non_guaranteed" + channels: ["display"] + format_ids: + - id: "display_300x250" + - product_id: "shared_budget_retargeting" + delivery_type: "non_guaranteed" + channels: ["display"] + format_ids: + - id: "display_300x250" + pricing_options: + - product_id: "shared_budget_prospecting" + pricing_option_id: "prospecting_cpm" + pricing_model: "cpm" + currency: "USD" + floor_price: 1.50 + - product_id: "shared_budget_retargeting" + pricing_option_id: "retargeting_cpm" + pricing_model: "cpm" + currency: "USD" + floor_price: 2.00 + - product_id: "shared_budget_retargeting" + pricing_option_id: "retargeting_cpm_eur" + pricing_model: "cpm" + currency: "EUR" + floor_price: 2.00 + +phases: + - id: setup + title: "Establish account and discover eligible products" + 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 an active account." + sample_request: + accounts: + - brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + billing: "operator" + idempotency_key: "$generate:uuid_v4#seller_optimized_budget_setup_sync_accounts" + validations: + - check: response_schema + description: "Response matches sync-accounts-response.json" + - check: field_present + path: "accounts[0].account_id" + description: "Account has a seller-assigned identifier" + + - id: get_products_for_shared_budget + title: "Discover products for shared allocation" + 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" + stateful: false + expected: "Return the seeded products with compatible USD pricing and click optimization." + sample_request: + buying_mode: "brief" + brief: "Allocate a $100,000 performance budget across prospecting and retargeting display packages, optimizing for clicks." + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + validations: + - check: response_schema + description: "Response matches get-products-response.json" + - check: field_present + path: "products[1]" + description: "At least two products are available for cross-package allocation" + + - id: create_shared_budget_buy + title: "Create a seller-optimized buy" + steps: + - id: create_media_buy_with_shared_budget + title: "Delegate allocation across two packages" + 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" + stateful: true + expected: | + Accept the shared $100,000 total. Echo seller_optimized allocation, + even aggregate pacing, and the package constraints without replacing + them with seller-generated fixed allocations. + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + brand: + domain: "acmeoutdoor.example" + total_budget: + amount: 100000 + currency: "USD" + budget_allocation: + mode: "seller_optimized" + optimization_goals: + - kind: "metric" + metric: "clicks" + priority: 1 + pacing: "even" + packages: + - product_id: "shared_budget_prospecting" + pricing_option_id: "prospecting_cpm" + budget: 70000 + min_spend_target: 20000 + - product_id: "shared_budget_retargeting" + pricing_option_id: "retargeting_cpm" + budget: 60000 + pacing: "front_loaded" + start_time: "asap" + end_time: "2099-06-30T23:59:59Z" + idempotency_key: "$generate:uuid_v4#seller_optimized_budget_create_shared_budget_buy" + context_outputs: + - name: media_buy_id + path: "media_buy_id" + validations: + - check: response_schema + description: "Response matches create-media-buy-response.json" + - check: field_present + path: "media_buy_id" + description: "Seller creates the media buy" + - check: field_value + path: "total_budget" + value: 100000 + description: "Response echoes the hard aggregate budget" + - check: field_value + path: "budget_allocation.mode" + value: "seller_optimized" + description: "Response preserves delegated allocation mode" + - check: field_value + path: "pacing" + value: "even" + description: "Response preserves aggregate pacing" + - check: field_value + path: "packages[0].min_spend_target" + value: 20000 + description: "Response preserves the soft package minimum-spend target" + + - id: read_shared_budget_buy + title: "Verify durable allocation configuration" + steps: + - id: get_media_buy_with_shared_budget + title: "Read back the media buy" + task: get_media_buys + schema_ref: "media-buy/get-media-buys-request.json" + response_schema_ref: "media-buy/get-media-buys-response.json" + doc_ref: "/media-buy/task-reference/get_media_buys" + stateful: true + expected: "Return the accepted shared total, allocation mode, pacing, and package constraints unchanged." + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + media_buy_ids: + - "$context.media_buy_id" + validations: + - check: response_schema + description: "Response matches get-media-buys-response.json" + - check: field_value + path: "media_buys[0].media_buy_id" + value: "$context.media_buy_id" + description: "Read returns the created buy" + - check: field_value + path: "media_buys[0].total_budget" + value: 100000 + description: "Read preserves the aggregate budget" + - check: field_value + path: "media_buys[0].budget_allocation.mode" + value: "seller_optimized" + description: "Read preserves seller-optimized allocation" + - check: field_value + path: "media_buys[0].pacing" + value: "even" + description: "Read preserves aggregate pacing" + - check: field_value + path: "media_buys[0].packages[0].min_spend_target" + value: 20000 + description: "Read preserves the package minimum-spend target" + + - id: reject_mixed_currency_shared_buy + title: "Reject a pricing option outside the media-buy currency" + steps: + - id: create_mixed_currency_shared_buy + title: "Reject a mixed-currency shared allocation" + 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" + stateful: false + expect_error: true + negative_path: payload_well_formed + expected: | + Reject before mutation because the second package's EUR pricing + option does not match the media buy's USD denomination. + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + brand: + domain: "acmeoutdoor.example" + total_budget: + amount: 100000 + currency: "USD" + budget_allocation: + mode: "seller_optimized" + optimization_goals: + - kind: "metric" + metric: "clicks" + priority: 1 + packages: + - product_id: "shared_budget_prospecting" + pricing_option_id: "prospecting_cpm" + - product_id: "shared_budget_retargeting" + pricing_option_id: "retargeting_cpm_eur" + start_time: "asap" + end_time: "2099-06-30T23:59:59Z" + idempotency_key: "$generate:uuid_v4#seller_optimized_budget_currency_mismatch" + validations: + - check: error_code + value: "TERMS_REJECTED" + description: "Seller enforces the single media-buy currency before mutation" + + - id: bind_media_buy_cost_to_allocation_goal + title: "Bind a shared cost control to the allocation goal" + requires_capability: + path: media_buy.features.bidding_policy.media_buy.seller_optimized.cost_per_strengths + contains: cap + steps: + - id: create_shared_budget_buy_with_cost_cap + title: "Create a shared buy with a cross-package click cost cap" + 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" + stateful: true + expected: | + Accept the cost cap as a control on the primary allocation goal + (clicks), independent of any package-local optimization goals. + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + brand: + domain: "acmeoutdoor.example" + total_budget: + amount: 100000 + currency: "USD" + budget_allocation: + mode: "seller_optimized" + optimization_goals: + - kind: "metric" + metric: "clicks" + priority: 1 + bidding: + cost_per: + amount: 4 + strength: "cap" + packages: + - product_id: "shared_budget_prospecting" + pricing_option_id: "prospecting_cpm" + budget: 70000 + - product_id: "shared_budget_retargeting" + pricing_option_id: "retargeting_cpm" + budget: 60000 + start_time: "asap" + end_time: "2099-06-30T23:59:59Z" + idempotency_key: "$generate:uuid_v4#seller_optimized_budget_cost_cap" + validations: + - check: response_schema + description: "Response matches create-media-buy-response.json" + - check: field_present + path: "media_buy_id" + description: "Seller accepts the allocation-goal cost cap" + - check: field_value + path: "bidding.cost_per.amount" + value: 4 + description: "Response preserves the media-buy-authored allocation cost cap" + + - id: preserve_shared_package_automatic + title: "Preserve package automatic bidding under shared allocation" + depends_on: [setup] + requires_capability: + path: media_buy.features.bidding_policy.package.seller_optimized.modes + contains: automatic + steps: + - id: create_shared_buy_with_package_automatic + title: "Author package automatic bidding in a shared buy" + 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" + stateful: true + expected: "Preserve the explicit automatic policy only on the retargeting package." + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + brand: + domain: "acmeoutdoor.example" + total_budget: + amount: 100000 + currency: "USD" + budget_allocation: + mode: "seller_optimized" + optimization_goals: + - kind: "metric" + metric: "clicks" + packages: + - product_id: "shared_budget_prospecting" + pricing_option_id: "prospecting_cpm" + - product_id: "shared_budget_retargeting" + pricing_option_id: "retargeting_cpm" + bidding: + automatic: true + start_time: "asap" + end_time: "2099-06-30T23:59:59Z" + idempotency_key: "$generate:uuid_v4#seller_optimized_budget_package_automatic" + context_outputs: + - name: shared_automatic_buy_id + path: "media_buy_id" + validations: + - check: response_schema + description: "Response matches create-media-buy-response.json" + - check: field_contains + path: "packages[*]" + value: + product_id: "shared_budget_retargeting" + bidding: + automatic: true + description: "Response preserves the explicit package automatic policy" + + - id: read_shared_buy_with_package_automatic + title: "Read back shared package automatic bidding" + task: get_media_buys + schema_ref: "media-buy/get-media-buys-request.json" + response_schema_ref: "media-buy/get-media-buys-response.json" + doc_ref: "/media-buy/task-reference/get_media_buys" + stateful: true + expected: "Return the explicit automatic block on the same package." + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + media_buy_ids: + - "$context.shared_automatic_buy_id" + validations: + - check: response_schema + description: "Response matches get-media-buys-response.json" + - check: field_contains + path: "media_buys[0].packages[*]" + value: + product_id: "shared_budget_retargeting" + bidding: + automatic: true + description: "Readback preserves the package-authored automatic policy" + + - id: execute_seller_optimized_proposal + title: "Execute a committed seller-optimized proposal" + depends_on: [setup] + requires_capability: + path: media_buy.supports_proposals + equals: true + steps: + - id: get_seller_optimized_proposal + title: "Request a shared-budget proposal with two-level pacing" + 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" + stateful: true + expected: | + Return a seller-optimized proposal with aggregate even pacing and a + subordinate front-loaded pacing term on the second allocation. + sample_request: + buying_mode: "brief" + brief: "Propose a $100,000 shared display budget across prospecting and retargeting, optimize allocation for clicks, pace the buy evenly, and front-load retargeting." + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + context_outputs: + - name: shared_proposal_id + path: "proposals[0].proposal_id" + validations: + - check: response_schema + description: "Response matches get-products-response.json" + - check: field_value + path: "proposals[0].budget_allocation.mode" + value: "seller_optimized" + description: "Proposal delegates cross-package allocation" + - check: field_value + path: "proposals[0].pacing" + value: "even" + description: "Proposal preserves aggregate pacing" + - check: field_contains + path: "proposals[0].allocations[*]" + value: + product_id: "shared_budget_retargeting" + pacing: "front_loaded" + description: "Proposal expresses subordinate pacing on the intended product" + + - id: finalize_seller_optimized_proposal + title: "Commit the shared-budget 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" + stateful: true + expected: "Return the same seller-optimized proposal as committed firm terms." + sample_request: + buying_mode: "refine" + refine: + - scope: "proposal" + proposal_id: "$context.shared_proposal_id" + action: "finalize" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + validations: + - check: response_schema + description: "Response matches get-products-response.json" + - check: field_value + path: "proposals[0].proposal_id" + value: "$context.shared_proposal_id" + description: "Finalization returns the proposal the buyer targeted" + - check: field_value + path: "proposals[0].proposal_status" + value: "committed" + description: "Proposal is ready for execution" + - check: field_value + path: "proposals[0].budget_allocation.mode" + value: "seller_optimized" + description: "Finalization preserves shared allocation terms" + - check: field_value + path: "proposals[0].pacing" + value: "even" + description: "Finalization preserves aggregate pacing" + - check: field_contains + path: "proposals[0].allocations[*]" + value: + product_id: "shared_budget_retargeting" + pacing: "front_loaded" + description: "Finalization preserves product-specific subordinate pacing" + + - id: create_from_seller_optimized_proposal + title: "Execute the shared-budget proposal" + 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" + stateful: true + expected: | + Create packages from the committed constraints, preserve the shared + allocation goals and aggregate pacing, and translate allocation + pacing to package pacing. + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + brand: + domain: "acmeoutdoor.example" + proposal_id: "$context.shared_proposal_id" + total_budget: + amount: 100000 + currency: "USD" + start_time: "asap" + end_time: "2099-06-30T23:59:59Z" + idempotency_key: "$generate:uuid_v4#seller_optimized_budget_proposal_execute" + context_outputs: + - name: shared_proposal_media_buy_id + path: "media_buy_id" + validations: + - check: response_schema + description: "Response matches create-media-buy-response.json" + - check: field_value + path: "budget_allocation.mode" + value: "seller_optimized" + description: "Execution preserves delegated allocation" + - check: field_value + path: "pacing" + value: "even" + description: "Execution preserves aggregate pacing" + - check: field_contains + path: "packages[*]" + value: + product_id: "shared_budget_retargeting" + pacing: "front_loaded" + description: "Execution derives package pacing for the intended product" + + - id: read_seller_optimized_proposal_buy + title: "Read back the proposal-derived shared buy" + task: get_media_buys + schema_ref: "media-buy/get-media-buys-request.json" + response_schema_ref: "media-buy/get-media-buys-response.json" + doc_ref: "/media-buy/task-reference/get_media_buys" + stateful: true + expected: "Return the proposal-derived allocation and pacing configuration unchanged." + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + media_buy_ids: + - "$context.shared_proposal_media_buy_id" + validations: + - check: response_schema + description: "Response matches get-media-buys-response.json" + - check: field_value + path: "media_buys[0].budget_allocation.mode" + value: "seller_optimized" + description: "Readback preserves shared allocation" + - check: field_contains + path: "media_buys[0].packages[*]" + value: + product_id: "shared_budget_retargeting" + pacing: "front_loaded" + description: "Readback preserves product-matched proposal pacing" diff --git a/static/schemas/source/core/bidding-policy-capability.json b/static/schemas/source/core/bidding-policy-capability.json new file mode 100644 index 0000000000..aa2e2af2f6 --- /dev/null +++ b/static/schemas/source/core/bidding-policy-capability.json @@ -0,0 +1,137 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/bidding-policy-capability.json", + "title": "Bidding Policy Capability", + "description": "Structured declaration of the canonical bidding policies a seller can preserve without semantic translation. Media-buy/package authored scope and fixed/seller-optimized allocation context are advertised independently. Buyers MUST inspect the exact scope, allocation context, mode, strength, and combination rather than treating presence of this object as blanket support.", + "x-adcp-hoist": true, + "type": "object", + "properties": { + "media_buy": { + "$ref": "#/definitions/scope_capability", + "description": "Policies accepted at media-buy scope and inherited by packages that omit a package override." + }, + "package": { + "$ref": "#/definitions/scope_capability", + "description": "Policies accepted as package-authored policies, including explicit automatic overrides." + } + }, + "minProperties": 1, + "additionalProperties": false, + "definitions": { + "scope_capability": { + "type": "object", + "description": "Support at one authored scope, partitioned by the media buy's resulting allocation mode. Omitted contexts are unsupported; claims in one context make no claim about the other.", + "properties": { + "fixed": { + "$ref": "#/definitions/policy_profile", + "description": "Policies supported when budget allocation is fixed or omitted." + }, + "seller_optimized": { + "$ref": "#/definitions/policy_profile", + "description": "Policies supported when budget_allocation.mode is seller_optimized." + } + }, + "minProperties": 1, + "additionalProperties": false + }, + "policy_profile": { + "type": "object", + "properties": { + "modes": { + "type": "array", + "description": "Standalone policy modes accepted in this exact scope and allocation context. Combination-only components belong only in supported_combinations and need not appear here. `automatic` means the seller accepts and preserves an explicit `{automatic:true}` authored block; omission only invokes inheritance/default behavior.", + "items": { + "type": "string", + "enum": ["automatic", "bid_amount", "max_bid", "cost_per", "roas"] + }, + "minItems": 1, + "uniqueItems": true + }, + "cost_per_strengths": { + "type": "array", + "description": "Supported standalone cost_per strengths in this profile. Required exactly when modes includes cost_per.", + "items": { + "type": "string", + "enum": ["cap", "target"] + }, + "minItems": 1, + "uniqueItems": true + }, + "roas_strengths": { + "type": "array", + "description": "Supported standalone roas strengths in this profile. Required exactly when modes includes roas.", + "items": { + "type": "string", + "enum": ["floor", "target"] + }, + "minItems": 1, + "uniqueItems": true + }, + "supported_combinations": { + "type": "array", + "description": "Multi-field policies supported in this exact scope and allocation context. Each entry independently qualifies the strengths supported in that combination; standalone strength support does not imply combination support. Absence means no multi-field combination is claimed.", + "items": { + "oneOf": [ + { "$ref": "#/definitions/max_bid_with_cost_per" }, + { "$ref": "#/definitions/max_bid_with_roas" } + ] + }, + "minItems": 1 + } + }, + "anyOf": [ + { "required": ["modes"] }, + { "required": ["supported_combinations"] } + ], + "allOf": [ + { + "if": { + "properties": { "modes": { "contains": { "const": "cost_per" } } }, + "required": ["modes"] + }, + "then": { "required": ["cost_per_strengths"] }, + "else": { "not": { "required": ["cost_per_strengths"] } } + }, + { + "if": { + "properties": { "modes": { "contains": { "const": "roas" } } }, + "required": ["modes"] + }, + "then": { "required": ["roas_strengths"] }, + "else": { "not": { "required": ["roas_strengths"] } } + } + ], + "additionalProperties": false + }, + "max_bid_with_cost_per": { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "max_bid_with_cost_per" }, + "cost_per_strengths": { + "type": "array", + "description": "cost_per strengths supported when combined with max_bid.", + "items": { "type": "string", "enum": ["cap", "target"] }, + "minItems": 1, + "uniqueItems": true + } + }, + "required": ["kind", "cost_per_strengths"], + "additionalProperties": false + }, + "max_bid_with_roas": { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "max_bid_with_roas" }, + "roas_strengths": { + "type": "array", + "description": "roas strengths supported when combined with max_bid.", + "items": { "type": "string", "enum": ["floor", "target"] }, + "minItems": 1, + "uniqueItems": true + } + }, + "required": ["kind", "roas_strengths"], + "additionalProperties": false + } + } +} diff --git a/static/schemas/source/core/bidding-policy.json b/static/schemas/source/core/bidding-policy.json new file mode 100644 index 0000000000..8fa7de8236 --- /dev/null +++ b/static/schemas/source/core/bidding-policy.json @@ -0,0 +1,78 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/bidding-policy.json", + "title": "Bidding Policy", + "description": "Buyer-authored execution policy for automatic delivery, auction bidding, average outcome cost, or return on ad spend. The containing object determines authored scope: media-buy `bidding` is a complete inherited default and package `bidding` is a complete package override. Sellers MUST preserve authored scope on readback and MUST NOT copy an inherited media-buy policy into package `bidding`. Every monetary field in this block is denominated in the media-buy currency; the selected pricing option supplies the auction unit, never another denomination. Auction-unit identity is the pricing_model plus every canonical billing-event qualifier after defaults are applied: for example CPV view threshold, CPP demographic system/demographic, CPA event tuple, time time_unit, and flat-rate/DOOH parameters. An extension qualifier participates only when its registered extension specification explicitly defines how it contributes to auction-unit identity. A media-buy bid_amount or max_bid is valid only when every inheriting package resolves the same auction-unit identity. Every affected pricing option MUST use the media-buy currency; split currency-mismatched packages into separate buys. Seller-optimized media-buy cost_per/roas bind to the primary budget_allocation.optimization_goals goal. Package-authored cost_per/roas bind to the package primary optimization goal. The primary goal is the earliest array entry among goals with the lowest explicit numeric priority; unprioritized goals follow explicitly prioritized goals; when all priorities are absent, the first entry is primary. In fixed allocation, an inherited media-buy cost_per is valid only when all inheriting packages have compatible primary-goal result units; inherited roas requires value-bearing primary goals on every inheriting package. Canonical ROAS requires each value-bearing event source to declare the media-buy currency in value_currencies; each buy consumes only exact-currency records and sellers MUST NOT convert them. Absence invokes inheritance or provider automatic delivery; `{automatic:true}` is an explicit authored policy that overrides inheritance. Sellers MUST reject unsupported modes, combinations, units, currency, goal bindings, or native placements before any provider mutation and MUST NOT silently translate semantics.", + "type": "object", + "properties": { + "automatic": { + "type": "boolean", + "const": true, + "description": "Explicitly use seller/provider automatic bidding at this authored scope. At package scope this is a complete override of a media-buy policy, not inheritance. It MUST be the only field in the block and MUST be preserved on readback." + }, + "bid_amount": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Manual auction bid denominated in the media-buy currency and expressed per the selected pricing option's auction unit. For example, a CPM option interprets the amount per thousand impressions. This is the amount submitted to the auction, not a promise that the clearing price equals it. Requires an auction-priced pricing option whose currency equals the media-buy currency." + }, + "max_bid": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Hard per-auction ceiling denominated in the media-buy currency and expressed per the selected pricing option's auction unit. This is the only canonical hard auction ceiling and MUST NOT be translated into an average outcome-cost control. Requires an auction-priced pricing option whose currency equals the media-buy currency. May stand alone or supplement cost_per/roas only when the relevant scope capability advertises that combination." + }, + "cost_per": { + "type": "object", + "description": "Average cost control per result of the scope-bound primary optimization goal. At seller-optimized media-buy scope it binds to budget_allocation.optimization_goals; at package scope it binds to that package's optimization_goals; at fixed media-buy scope it binds independently to each inheriting package and is valid only when their primary-goal result units are compatible. Metric goals are compatible only when metric and every result-defining qualifier match; vendor_metric goals only when vendor and metric_id match; event goals only when the event_type/custom_event_name set and resolved attribution_window match. Primary is the earliest array entry among goals tied for the lowest explicit numeric priority; unprioritized goals follow explicitly prioritized goals; when all priorities are absent, the first entry is primary.", + "properties": { + "amount": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Average cost amount per scope-bound primary-goal result, denominated in the media-buy currency." + }, + "strength": { + "type": "string", + "enum": ["cap", "target"], + "description": "`cap` optimizes for an average at or below the amount and accepts underdelivery when necessary; `target` optimizes around the amount while balancing volume and spend. Neither is a per-result or per-auction guarantee." + } + }, + "required": ["amount", "strength"], + "additionalProperties": false + }, + "roas": { + "type": "object", + "description": "Dimensionless return-on-ad-spend control bound to the same scope-specific primary goal rules as cost_per. The bound goal must be value-bearing; a fixed media-buy default requires a value-bearing primary goal on every inheriting package. Every referenced value-bearing event source MUST declare value_currencies containing the media-buy currency. The seller validates this at buy creation; each buy consumes only exact-currency records, while other declared currencies remain available to other buys. Sellers MUST NOT perform currency conversion.", + "properties": { + "value": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Return per unit of ad spend; 4 means 4 units of value per 1 unit spent." + }, + "strength": { + "type": "string", + "enum": ["floor", "target"], + "description": "`floor` prefers underdelivery to knowingly optimizing below the requested return; `target` optimizes around the requested return. Neither guarantees realized return." + } + }, + "required": ["value", "strength"], + "additionalProperties": false + } + }, + "minProperties": 1, + "allOf": [ + { + "not": { + "anyOf": [ + { "required": ["automatic", "bid_amount"] }, + { "required": ["automatic", "max_bid"] }, + { "required": ["automatic", "cost_per"] }, + { "required": ["automatic", "roas"] }, + { "required": ["bid_amount", "max_bid"] }, + { "required": ["bid_amount", "cost_per"] }, + { "required": ["bid_amount", "roas"] }, + { "required": ["cost_per", "roas"] } + ] + } + } + ], + "additionalProperties": false +} diff --git a/static/schemas/source/core/budget-allocation.json b/static/schemas/source/core/budget-allocation.json new file mode 100644 index 0000000000..fe4dc16daf --- /dev/null +++ b/static/schemas/source/core/budget-allocation.json @@ -0,0 +1,64 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/budget-allocation.json", + "title": "Budget Allocation", + "description": "How a media buy's total budget is allocated across its packages. Fixed allocation preserves independent package budgets. Seller-optimized allocation delegates continuous cross-package allocation to the seller within the media-buy total, package caps, minimum-spend targets, flight windows, and pacing controls. When a seller-optimized media-buy BiddingPolicy carries cost_per or roas, that control binds to this allocation block's primary optimization goal rather than independently to each package goal.", + "x-adcp-hoist": true, + "discriminator": { + "propertyName": "mode" + }, + "oneOf": [ + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "fixed", + "description": "Packages have independent budgets. The seller does not automatically move budget between packages." + } + }, + "required": [ + "mode" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "seller_optimized", + "description": "Packages draw from a shared media-buy budget. The seller continuously allocates spend across packages to optimize the declared goals." + }, + "optimization_goals": { + "type": "array", + "description": "Goals the seller uses to allocate the shared budget across packages. These are distinct from packages[].optimization_goals, which optimize delivery within an individual package. The primary goal is the earliest array entry among goals with the lowest explicit numeric priority; goals without priority follow all explicitly prioritized goals; when all priorities are omitted, the first entry is primary. It supplies the result unit/value source for media-buy bidding.cost_per or bidding.roas. Legacy monetary target kinds are prohibited at allocation scope because canonical controls belong in media-buy bidding. Every participating product MUST support the primary cross-package goal; otherwise the seller MUST reject the request with TERMS_REJECTED or UNSUPPORTED_FEATURE and identify the incompatible package.", + "items": { + "allOf": [ + { "$ref": "/schemas/core/optimization-goal.json" }, + { + "not": { + "properties": { + "target": { + "properties": { + "kind": { "enum": ["cost_per", "per_ad_spend"] } + }, + "required": ["kind"] + } + }, + "required": ["target"] + } + } + ] + }, + "minItems": 1 + } + }, + "required": [ + "mode", + "optimization_goals" + ], + "additionalProperties": false + } + ] +} diff --git a/static/schemas/source/core/event-custom-data.json b/static/schemas/source/core/event-custom-data.json index 12dd059e99..1d70589c0c 100644 --- a/static/schemas/source/core/event-custom-data.json +++ b/static/schemas/source/core/event-custom-data.json @@ -7,13 +7,13 @@ "properties": { "value": { "type": "number", - "description": "Monetary value of the event (should be accompanied by currency)", + "description": "Monetary value of the event. For an event source that declares value_currencies, currency is required and MUST appear in that list. Legacy sources without the new contract remain schema-compatible but are ineligible for canonical ROAS.", "minimum": 0 }, "currency": { "type": "string", "pattern": "^[A-Z]{3}$", - "description": "ISO 4217 currency code" + "description": "ISO 4217 currency code. For a source declaring value_currencies, monetary records MUST carry a currency from that list." }, "order_id": { "type": "string", diff --git a/static/schemas/source/core/media-buy-features.json b/static/schemas/source/core/media-buy-features.json index 12684374f7..c5120e8f2a 100644 --- a/static/schemas/source/core/media-buy-features.json +++ b/static/schemas/source/core/media-buy-features.json @@ -20,6 +20,16 @@ "committed_metrics_supported": { "type": "boolean", "description": "Seller has per-package snapshot infrastructure for the reporting contract. When true, the seller MUST populate `package.committed_metrics` on committed `create_media_buy` responses where `confirmed_at` is non-null, MUST omit `package.committed_metrics` while `confirmed_at` is null for a provisional buy, and MUST honor append-only mid-flight metric additions via `update_media_buy`. The unified `committed_metrics` array (per the metric-accountability design) covers both standard and vendor-defined metric entries, so a single flag is load-bearing. Buyers filtering on this flag are detecting 'this seller can stamp the reporting contract,' which closes the audit gap from PR #3510 where absence of `committed_metrics` was indistinguishable between 'didn't snapshot' and 'snapshot infrastructure not implemented.'" + }, + "seller_optimized_budget": { + "type": "boolean", + "description": "Supports media-buy-level shared budgets with budget_allocation.mode `seller_optimized`. Sellers declaring true MUST accept eligible explicit-package and proposal executions, enforce the aggregate budget and package constraints, apply media-buy-level pacing, and echo the allocation configuration on buy read surfaces. Product combinations may still be rejected when their currencies, optimization capabilities, pricing terms, or delivery constraints are incompatible." + }, + "bidding_policy": { + "allOf": [ + { "$ref": "/schemas/core/bidding-policy-capability.json" } + ], + "description": "Structured support for canonical bidding by authored scope, allocation context, mode, strength, and strength-qualified multi-field combination. Presence does not imply support for both scopes, both allocation modes, or every policy shape. Sellers MUST preserve every advertised semantic exactly and reject unadvertised policies rather than translating them." } }, "additionalProperties": { diff --git a/static/schemas/source/core/media-buy.json b/static/schemas/source/core/media-buy.json index 398a97e7ba..284077dfbc 100644 --- a/static/schemas/source/core/media-buy.json +++ b/static/schemas/source/core/media-buy.json @@ -62,9 +62,32 @@ }, "total_budget": { "type": "number", - "description": "Total budget amount", + "description": "Hard aggregate lifetime budget amount", "minimum": 0 }, + "currency": { + "type": "string", + "description": "Single ISO 4217 denomination for total_budget, every package budget/minimum, and every canonical BiddingPolicy monetary field. Every package's selected pricing option MUST declare this currency; packages requiring another currency belong in a separate media buy.", + "pattern": "^[A-Z]{3}$" + }, + "budget_allocation": { + "allOf": [ + { + "$ref": "/schemas/core/budget-allocation.json" + } + ], + "description": "Accepted cross-package budget allocation configuration. Omitted means fixed allocation for legacy buys." + }, + "pacing": { + "$ref": "/schemas/enums/pacing.json", + "description": "Aggregate pacing strategy for total_budget across the media-buy flight." + }, + "bidding": { + "allOf": [ + { "$ref": "/schemas/core/bidding-policy.json" } + ], + "description": "Media-buy-authored bidding policy. This is the complete default inherited by packages that omit package.bidding; `{automatic:true}` is an explicit authored automatic policy. In seller-optimized mode, cost_per/roas bind to the primary budget_allocation optimization goal. In fixed mode, inherited cost_per requires compatible package primary-goal result units and inherited roas requires value-bearing primary goals. Monetary fields use media_buy.currency; every affected pricing option MUST declare the same currency. Package overrides are permitted only where advertised; conflicts MUST be rejected atomically with BIDDING_PLACEMENT_CONFLICT." + }, "packages": { "type": "array", "description": "Array of packages within this media buy", diff --git a/static/schemas/source/core/optimization-goal.json b/static/schemas/source/core/optimization-goal.json index 8ce01dab9f..819a4aa2b1 100644 --- a/static/schemas/source/core/optimization-goal.json +++ b/static/schemas/source/core/optimization-goal.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/optimization-goal.json", "title": "Optimization Goal", - "description": "A single optimization target for a package. Packages accept an array of optimization_goals. When multiple goals are present, priority determines which the seller focuses on — 1 is highest priority (primary goal); higher numbers are secondary. When priorities are present but no goal is priority 1, the goal with the lowest priority value is primary (e.g., priorities of 2 and 3 mean 2 is primary). Duplicate priority values result in undefined seller behavior.", + "description": "A single objective function: what to maximize or optimize, in what units, and in what priority order. Used on packages to optimize delivery within one package and on seller-optimized budget allocations to allocate spend across packages. Currency-bearing execution policy belongs in BiddingPolicy in 3.2. Legacy target.cost_per and target.per_ad_spend remain accepted only on package goals for migration and are deprecated. The primary goal is the earliest array entry among goals with the lowest explicit numeric priority; goals without priority follow all explicitly prioritized goals; when all priorities are omitted, the first entry is primary. This array-order tie-break makes duplicate priorities deterministic.", "discriminator": { "propertyName": "kind" }, @@ -60,7 +60,8 @@ "oneOf": [ { "type": "object", - "description": "Target cost per unit of the metric (e.g., cost per click, cost per completed view).", + "deprecated": true, + "description": "DEPRECATED legacy target cost per unit of the metric. Normalize to package.bidding.cost_per with strength target while retaining this goal as the objective.", "properties": { "kind": { "type": "string", "const": "cost_per" }, "value": { "type": "number", "exclusiveMinimum": 0, "description": "Target cost per metric unit in the buy currency" } @@ -83,7 +84,7 @@ "priority": { "type": "integer", "minimum": 1, - "description": "Relative priority among all optimization goals on this package. 1 = highest priority (primary goal); higher numbers are lower priority (secondary signals). When omitted, sellers may use array position as priority." + "description": "Relative priority among sibling goals. Lower numbers rank first. Goals without priority follow explicitly prioritized goals. Ties use array order, so the earliest goal at the lowest explicit priority is primary; when all priorities are omitted, the first goal is primary." } }, "required": ["kind", "metric"], @@ -115,12 +116,12 @@ }, "value_field": { "type": "string", - "description": "Which field in the event's custom_data carries the monetary value. The seller must use this field for value extraction and aggregation when computing ROAS and conversion value metrics. Required on at least one entry when target.kind is 'per_ad_spend' or 'maximize_value' — sellers must reject these target kinds when no event source entry includes value_field. When present without a value-oriented target, the seller may use it for delivery reporting (conversion_value, roas) but must not change the optimization objective. Common values: 'value', 'order_total', 'profit_margin'. This is not passed as a parameter to underlying platform APIs — the seller maps it to their platform's value ingestion mechanism." + "description": "Which field in the event's custom_data carries the monetary value. The seller must use this field for value extraction and aggregation when computing ROAS and conversion value metrics. Required on at least one entry when target.kind is 'per_ad_spend' or 'maximize_value' — sellers must reject these target kinds when no event source entry includes value_field. When canonical bidding.roas binds to this goal, every referenced value-bearing source MUST declare value_currencies containing the media-buy currency; the seller validates this at buy creation. Once that source contract is declared, log_event rejects monetary records whose currency is missing or absent from the list. For each buy, only records exactly matching its media-buy currency contribute to ROAS; records in other declared currencies remain available to matching buys, and sellers MUST NOT convert between them. When present without a value-oriented target, the seller may use it for delivery reporting but must not change the objective. Common values: 'value', 'order_total', 'profit_margin'. This is not passed as a parameter to underlying platform APIs — the seller maps it to their platform's value ingestion mechanism." }, "value_factor": { "type": "number", "default": 1, - "description": "Multiplier the seller must apply to value_field before aggregation. Use -1 for refund events (negate the value), 0.01 for values in cents, -0.01 for refunds in cents. A value of 0 zeroes out this source's value contribution (the source still counts for event dedup). Defaults to 1. This is not passed as a parameter to underlying platform APIs — the seller applies it when computing aggregated value metrics." + "description": "Unit-scaling multiplier the seller must apply to value_field before aggregation. Use -1 for refund events (negate the value), 0.01 for values in cents, -0.01 for refunds in cents. It MUST NOT be used for currency conversion. A value of 0 zeroes out this source's value contribution (the source still counts for event dedup). Defaults to 1. This is not passed as a parameter to underlying platform APIs — the seller applies it when computing aggregated value metrics." } }, "required": ["event_source_id", "event_type"], @@ -136,7 +137,8 @@ "oneOf": [ { "type": "object", - "description": "Target cost per conversion event (after deduplication across event_sources).", + "deprecated": true, + "description": "DEPRECATED legacy target cost per conversion event. Normalize to package.bidding.cost_per with strength target while retaining this event goal as the objective.", "properties": { "kind": { "type": "string", "const": "cost_per" }, "value": { "type": "number", "exclusiveMinimum": 0, "description": "Target cost per event in the buy currency" } @@ -146,7 +148,8 @@ }, { "type": "object", - "description": "Target return per unit of ad spend, calculated as sum(value_field * value_factor) / spend across all deduplicated events. Requires value_field on at least one event_sources entry.", + "deprecated": true, + "description": "DEPRECATED legacy target return per unit of ad spend. Normalize to package.bidding.roas with strength target while retaining this event goal and its value-bearing event source. Requires value_field on at least one event_sources entry.", "properties": { "kind": { "type": "string", "const": "per_ad_spend" }, "value": { "type": "number", "exclusiveMinimum": 0, "description": "Target return ratio (e.g., 4.0 means $4 of value per $1 spent)" } @@ -172,7 +175,7 @@ "priority": { "type": "integer", "minimum": 1, - "description": "Relative priority among all optimization goals on this package. 1 = highest priority (primary goal); higher numbers are lower priority (secondary signals). When omitted, sellers may use array position as priority." + "description": "Relative priority among sibling goals. Lower numbers rank first. Goals without priority follow explicitly prioritized goals. Ties use array order, so the earliest goal at the lowest explicit priority is primary; when all priorities are omitted, the first goal is primary." } }, "required": ["kind", "event_sources"], @@ -199,7 +202,8 @@ "oneOf": [ { "type": "object", - "description": "Target cost per unit of the vendor metric (e.g., cost per attention-second).", + "deprecated": true, + "description": "DEPRECATED legacy target cost per vendor-metric unit. Normalize to package.bidding.cost_per with strength target while retaining this vendor-metric goal as the objective.", "properties": { "kind": { "type": "string", "const": "cost_per" }, "value": { "type": "number", "exclusiveMinimum": 0, "description": "Target cost per metric unit in the buy currency. Units of the metric are vendor-defined." } @@ -222,7 +226,7 @@ "priority": { "type": "integer", "minimum": 1, - "description": "Relative priority among all optimization goals on this package. 1 = highest priority (primary goal); higher numbers are lower priority (secondary signals). When omitted, sellers may use array position as priority." + "description": "Relative priority among sibling goals. Lower numbers rank first. Goals without priority follow explicitly prioritized goals. Ties use array order, so the earliest goal at the lowest explicit priority is primary; when all priorities are omitted, the first goal is primary." } }, "required": ["kind", "vendor", "metric_id"], diff --git a/static/schemas/source/core/package.json b/static/schemas/source/core/package.json index 26e7f9c234..0da10a3046 100644 --- a/static/schemas/source/core/package.json +++ b/static/schemas/source/core/package.json @@ -17,7 +17,12 @@ }, "budget": { "type": "number", - "description": "Budget allocation for this package in the currency specified by the pricing option", + "description": "Hard lifetime spend cap for this package in the media-buy currency. Every selected pricing option in an AdCP-authored media buy MUST declare that same currency. In seller-optimized allocation mode this is a ceiling, not a current allocation. May be omitted when the package is bounded only by the shared media-buy total.", + "minimum": 0 + }, + "min_spend_target": { + "type": "number", + "description": "Soft lifetime spend target accepted for this package under seller-optimized budget allocation. This is an allocation preference, not a billing or delivery guarantee.", "minimum": 0 }, "pacing": { @@ -30,9 +35,16 @@ }, "bid_price": { "type": "number", - "description": "Bid price for auction-based pricing. This is the exact bid/price to honor unless the selected pricing option has max_bid=true, in which case bid_price is the buyer's maximum willingness to pay (ceiling).", + "deprecated": true, + "description": "DEPRECATED legacy bidding representation. 3.2 sellers normalize accepted legacy input and SHOULD echo bidding instead. Removed in the next major.", "minimum": 0 }, + "bidding": { + "allOf": [ + { "$ref": "/schemas/core/bidding-policy.json" } + ], + "description": "Package-authored bidding policy, echoed only when the buyer authored a package override. `{automatic:true}` is an explicit automatic-bidding override. Omission means the package inherits media-buy bidding or, when both scopes are absent, uses provider automatic delivery. Monetary fields are denominated in the media-buy currency. Sellers MUST NOT materialize inherited media-buy policy here." + }, "price_breakdown": { "description": "Breakdown of the effective price for this package. On fixed-price packages, echoes the pricing option's breakdown. On auction packages, shows the clearing price breakdown including any commission or settlement terms.", "$ref": "/schemas/pricing-options/price-breakdown.json" @@ -200,6 +212,35 @@ "required": [ "package_id" ], + "allOf": [ + { + "not": { + "required": ["bidding", "bid_price"] + } + }, + { + "if": { "required": ["bidding"] }, + "then": { + "properties": { + "optimization_goals": { + "not": { + "contains": { + "properties": { + "target": { + "properties": { + "kind": { "enum": ["cost_per", "per_ad_spend"] } + }, + "required": ["kind"] + } + }, + "required": ["target"] + } + } + } + } + } + } + ], "dependencies": { "params": ["format_kind"] }, diff --git a/static/schemas/source/core/planned-delivery.json b/static/schemas/source/core/planned-delivery.json index 7d8f3cc6d0..0cc4797f8c 100644 --- a/static/schemas/source/core/planned-delivery.json +++ b/static/schemas/source/core/planned-delivery.json @@ -63,6 +63,24 @@ "description": "ISO 4217 currency code for the budget.", "pattern": "^[A-Z]{3}$" }, + "budget_allocation": { + "allOf": [ + { + "$ref": "/schemas/core/budget-allocation.json" + } + ], + "description": "Seller-accepted cross-package allocation authority and goals. Presence with seller_optimized mode means automatic within-buy reallocations are part of the committed delivery, not separate modification actions." + }, + "pacing": { + "$ref": "/schemas/enums/pacing.json", + "description": "Aggregate pacing strategy the seller will apply to total_budget." + }, + "bidding": { + "allOf": [ + { "$ref": "/schemas/core/bidding-policy.json" } + ], + "description": "Seller-interpreted media-buy bidding policy used for governance and delivery transparency. Goal-bound controls follow budget-allocation scope semantics and monetary fields use the planned delivery currency. Package-authored overrides, including explicit automatic overrides, remain on packages rather than being copied into this aggregate field." + }, "enforced_policies": { "type": "array", "description": "Registry policy IDs the seller will enforce for this delivery.", diff --git a/static/schemas/source/core/pricing-option.json b/static/schemas/source/core/pricing-option.json index df22c8139b..ff6182561e 100644 --- a/static/schemas/source/core/pricing-option.json +++ b/static/schemas/source/core/pricing-option.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/pricing-option.json", "title": "Pricing Option", - "description": "A pricing model option offered by a publisher for a product. Discriminated by pricing_model field. If fixed_price is present, it's fixed pricing. If absent, it's auction-based (floor_price and price_guidance optional). Bid-based auction models may also include max_bid as a boolean signal to interpret bid_price as a buyer ceiling instead of an exact honored price.", + "description": "A seller's commercial pricing offer, discriminated by pricing_model. fixed_price is an agreed seller price; floor_price is the seller minimum for an auction-priced option; price_guidance is non-binding discovery guidance. Buyer execution instructions belong in BiddingPolicy. The legacy max_bid boolean and package bid_price are deprecated in 3.2 and removed in the next major.", "discriminator": { "propertyName": "pricing_model" }, diff --git a/static/schemas/source/core/product-allocation.json b/static/schemas/source/core/product-allocation.json index eb1ddbc37f..fbe51edec2 100644 --- a/static/schemas/source/core/product-allocation.json +++ b/static/schemas/source/core/product-allocation.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/product-allocation.json", "title": "Product Allocation", - "description": "A budget allocation for a specific product within a proposal. Percentages across all allocations in a proposal should sum to 100.", + "description": "A product included in a proposal's budget plan. Fixed proposals use allocation_percentage. Seller-optimized proposals use optional minimum-spend targets and maximum-spend constraints instead of an exact allocation.", "type": "object", "properties": { "product_id": { @@ -11,10 +11,26 @@ }, "allocation_percentage": { "type": "number", - "description": "Percentage of total budget allocated to this product (0-100)", + "description": "Exact percentage of total budget allocated to this product in a fixed proposal. Percentages across all allocations MUST sum to 100. Must be absent in a seller-optimized proposal.", "minimum": 0, "maximum": 100 }, + "min_spend_target_percentage": { + "type": "number", + "description": "Soft minimum-spend target as a percentage of the executed total budget. Only valid in seller-optimized proposals. The seller SHOULD attempt to reach it, but it is not a delivery guarantee. Minimum targets across allocations MUST sum to no more than 100.", + "minimum": 0, + "maximum": 100 + }, + "max_spend_percentage": { + "type": "number", + "description": "Hard maximum share of the executed total budget that this product may spend. Only valid in seller-optimized proposals. Maximums across allocations MUST collectively permit 100 percent of the budget to be spent.", + "minimum": 0, + "maximum": 100 + }, + "pacing": { + "$ref": "/schemas/enums/pacing.json", + "description": "Recommended subordinate package pacing. On proposal execution this becomes pacing on the derived package and MUST NOT cause delivery to exceed aggregate proposal/media-buy pacing. On a committed proposal it is a firm delivery term." + }, "pricing_option_id": { "type": "string", "description": "Recommended pricing option ID from the product's pricing_options array" @@ -62,8 +78,7 @@ } }, "required": [ - "product_id", - "allocation_percentage" + "product_id" ], "additionalProperties": true } diff --git a/static/schemas/source/core/proposal.json b/static/schemas/source/core/proposal.json index de758bef3c..f549a44164 100644 --- a/static/schemas/source/core/proposal.json +++ b/static/schemas/source/core/proposal.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/proposal.json", "title": "Proposal", - "description": "A proposed media plan with budget allocations across products. Represents the publisher's strategic recommendation for how to structure a campaign based on the brief. Proposals are actionable: committed proposals can be executed directly via create_media_buy by providing the proposal_id; draft proposals must first be finalized via get_products refine action 'finalize'.", + "description": "A proposed media plan with fixed or seller-optimized budget allocation across products. Represents the publisher's strategic recommendation for how to structure a campaign based on the brief. Proposals are actionable: committed proposals can be executed directly via create_media_buy by providing the proposal_id; draft proposals must first be finalized via get_products refine action 'finalize'.", "type": "object", "properties": { "proposal_id": { @@ -22,12 +22,24 @@ }, "allocations": { "type": "array", - "description": "Budget allocations across products. Allocation percentages MUST sum to 100. Publishers are responsible for ensuring the sum equals 100; buyers SHOULD validate this before execution.", + "description": "Products and budget constraints in this plan. Fixed proposals require allocation_percentage on every entry and percentages MUST sum to 100. Seller-optimized proposals forbid exact allocation_percentage and may instead supply min_spend_target_percentage and max_spend_percentage. Publishers are responsible for validating cross-entry sums; buyers SHOULD validate them before execution.", "items": { "$ref": "/schemas/core/product-allocation.json" }, "minItems": 1 }, + "budget_allocation": { + "allOf": [ + { + "$ref": "/schemas/core/budget-allocation.json" + } + ], + "description": "How the executed total budget is allocated across proposal products. Omit for legacy fixed proposals." + }, + "pacing": { + "$ref": "/schemas/enums/pacing.json", + "description": "Recommended aggregate pacing for the executed media-buy budget. On a committed proposal this is part of the firm delivery terms." + }, "proposal_status": { "$ref": "/schemas/enums/proposal-status.json", "description": "Lifecycle status of this proposal and the per-proposal source of truth for whether finalization is required before create_media_buy. When absent, the proposal is ready to buy (backward compatible). 'draft' means indicative pricing — finalize via refine before purchasing. 'committed' means firm pricing with inventory reserved until expires_at and executable via create_media_buy." @@ -85,5 +97,64 @@ "name", "allocations" ], + "allOf": [ + { + "if": { + "properties": { + "budget_allocation": { + "properties": { + "mode": { + "const": "seller_optimized" + } + }, + "required": [ + "mode" + ] + } + }, + "required": [ + "budget_allocation" + ] + }, + "then": { + "properties": { + "allocations": { + "items": { + "not": { + "required": [ + "allocation_percentage" + ] + } + } + } + } + }, + "else": { + "properties": { + "allocations": { + "items": { + "required": [ + "allocation_percentage" + ], + "not": { + "anyOf": [ + { + "required": [ + "min_spend_target_percentage" + ] + }, + { + "required": [ + "max_spend_percentage" + ] + } + ] + } + } + } + } + } + } + ], "additionalProperties": true } diff --git a/static/schemas/source/enums/error-code.json b/static/schemas/source/enums/error-code.json index c428569043..40374e25ff 100644 --- a/static/schemas/source/enums/error-code.json +++ b/static/schemas/source/enums/error-code.json @@ -59,6 +59,8 @@ "MULTI_FINALIZE_UNSUPPORTED", "IO_REQUIRED", "TERMS_REJECTED", + "BIDDING_PLACEMENT_CONFLICT", + "AMBIGUOUS_BIDDING_POLICY", "REQUOTE_REQUIRED", "VERSION_UNSUPPORTED", "CAMPAIGN_SUSPENDED", @@ -153,6 +155,8 @@ "MULTI_FINALIZE_UNSUPPORTED": "Returned by sellers that cannot guarantee atomic commit across multiple proposals in a single `get_products` call (multiple `action: 'finalize'` entries in `refine[]` targeting different `proposal_id` values). The buyer's intent — atomic multi-proposal finalize — is structurally well-formed and per spec atomic, but this seller's downstream stack cannot satisfy the atomicity guarantee (e.g., the proposals route to two different ad servers with no 2PC). More specific than `INVALID_REQUEST` so buyers can distinguish 'this seller doesn't support multi-finalize' from 'the request itself is malformed'. Recovery: correctable (sequence the finalize calls one-`proposal_id`-per-call; there is no capability flag for multi-finalize support, so a successful first-attempt is the only positive discovery signal). See [refinement guide § Finalize is exclusive](/docs/media-buy/product-discovery/refinement#finalize-is-exclusive-within-refine).", "IO_REQUIRED": "The committed proposal requires a signed insertion order but no io_acceptance was provided. Recovery: correctable (review the proposal's insertion_order, accept terms, and include io_acceptance on create_media_buy).", "TERMS_REJECTED": "Buyer-proposed measurement_terms were rejected by the seller. The error details SHOULD identify which specific term was rejected and the seller's acceptable range or supported vendors. Recovery: correctable (adjust the proposed terms and retry, or omit measurement_terms to accept the product's defaults).", + "BIDDING_PLACEMENT_CONFLICT": "The authored media-buy/package bidding scopes cannot be represented by the provider's native campaign, package, or shared-strategy placement rules. Sellers MUST detect this before any provider mutation and SHOULD identify the conflicting scopes and provider constraint in error.details. Recovery: correctable (remove or align package overrides, move the policy to the supported scope, or choose a compatible budget mode/product combination).", + "AMBIGUOUS_BIDDING_POLICY": "The same effective package combines the canonical bidding block with legacy bid_price or monetary optimization-goal target fields, so two bidding interpretations are present. Sellers MUST reject rather than choosing a winner. Recovery: correctable (emit only the 3.2 bidding block or only one legacy representation).", "REQUOTE_REQUIRED": "An update_media_buy request changes the parameter envelope (budget, flight dates, volume, targeting) the original quote was priced against. The pricing_option remains locked; the seller is declining the requested shape at that price. Distinct from TERMS_REJECTED (measurement) and POLICY_VIOLATION (content). Sellers SHOULD populate error.details.envelope_field with the field path(s) that breached the envelope (e.g., 'packages[0].budget', 'end_time') so the buyer's agent can decide whether to adjust the update, rediscover products, add packages where supported, or create a separate media buy. AdCP 3.1 does not define an amendment-quote artifact that can be attached to update_media_buy.", "VERSION_UNSUPPORTED": "The declared adcp_version (release-precision) or adcp_major_version (deprecated) is not supported by this seller. The error details SHOULD follow `error-details/version-unsupported.json` — `supported_versions` (release-precision strings) is authoritative for retry; `supported_majors` is deprecated. Recovery: correctable (re-pin to a release in supported_versions and retry; or call get_adcp_capabilities without a version pin to discover supported_versions).", "CAMPAIGN_SUSPENDED": "Campaign governance has been suspended pending human review; the governance agent MUST reject `check_governance` and `report_plan_outcome` calls on the affected plan until the escalation is resolved. Distinct from `ACCOUNT_SUSPENDED` (account-wide) — this is scoped to a single plan/campaign. Recovery: transient (wait for the escalation to resolve; contact the plan operator if the suspension persists).", @@ -410,6 +414,14 @@ "recovery": "correctable", "suggestion": "adjust the proposed terms and retry, or omit measurement_terms to accept the product's defaults" }, + "BIDDING_PLACEMENT_CONFLICT": { + "recovery": "correctable", + "suggestion": "align media-buy and package bidding scopes with the provider's supported placement, or choose a compatible budget mode/product combination" + }, + "AMBIGUOUS_BIDDING_POLICY": { + "recovery": "correctable", + "suggestion": "remove either the canonical bidding block or all legacy bidding fields from the effective package" + }, "REQUOTE_REQUIRED": { "recovery": "correctable", "suggestion": "adjust the update to stay within the current quote envelope, rediscover products/terms, add packages when available, or create a separate media buy; 3.1 does not define an amendment-quote artifact for update_media_buy" diff --git a/static/schemas/source/enums/media-buy-valid-action.json b/static/schemas/source/enums/media-buy-valid-action.json index cc2cb1fdae..6b0be53c67 100644 --- a/static/schemas/source/enums/media-buy-valid-action.json +++ b/static/schemas/source/enums/media-buy-valid-action.json @@ -21,8 +21,10 @@ "increase_budget", "decrease_budget", "reallocate_budget", + "update_budget_allocation", "update_targeting", "update_pacing", + "update_bidding", "update_frequency_caps", "replace_creative", "update_creative_assignments", @@ -41,11 +43,13 @@ "extend_flight": "Push the buy's `end_time` later. Start time unchanged.", "shorten_flight": "Pull the buy's `end_time` earlier. Start time unchanged.", "update_flight_dates": "Shift `start_time` and/or `end_time`. Distinct from extend/shorten because shifting a start date is operationally different from extending an end date and approval paths typically differ.", - "increase_budget": "Raise budget on one or more packages. Maps to higher values on `packages[].budget`.", - "decrease_budget": "Lower budget on one or more packages. Maps to lower values on `packages[].budget`. Sellers typically bound this by already-spent; constraint metadata covers bounds in a follow-up RFC.", + "increase_budget": "Raise the media-buy total budget, raise one or more package caps, or remove a seller-optimized package cap. Maps to a higher `total_budget.amount`, higher values on `packages[].budget`, or `packages[].budget: null` (which relaxes the ceiling).", + "decrease_budget": "Lower the media-buy total budget or one or more package caps. Maps to a lower `total_budget.amount` or lower values on `packages[].budget`. Sellers typically bound this by already-spent.", "reallocate_budget": "Redistribute budget across packages on `packages[].budget` without changing the sum.", + "update_budget_allocation": "Change fixed versus seller-optimized allocation mode, cross-package optimization goals, or package minimum-spend targets. Maps to `budget_allocation` and `packages[].min_spend_target`, including null to clear a target. Automatic seller allocation within an already-authorized seller-optimized buy is delivery behavior, not this action.", "update_targeting": "Update targeting overlays on existing packages.", - "update_pacing": "Update pacing on existing packages.", + "update_pacing": "Update aggregate media-buy pacing or pacing on existing packages.", + "update_bidding": "Replace or clear the media-buy bidding default or package-authored bidding overrides. Maps to `bidding` and `packages[].bidding`; the seller validates the resulting effective policies atomically before mutation.", "update_frequency_caps": "Update frequency capping rules. Distinct from targeting because frequency is renegotiated mid-flight on a different cadence (especially for CTV/video buys).", "replace_creative": "Swap a creative for another without changing assignment logic. Distinct AM workflow from changing the assignment set.", "update_creative_assignments": "Change which creatives are assigned to which packages.", @@ -65,20 +69,22 @@ "extend_flight": { "update_fields": ["end_time", "packages[].end_time"] }, "shorten_flight": { "update_fields": ["end_time", "packages[].end_time"] }, "update_flight_dates": { "update_fields": ["start_time", "end_time", "packages[].start_time", "packages[].end_time"] }, - "increase_budget": { "update_fields": ["packages[].budget"] }, - "decrease_budget": { "update_fields": ["packages[].budget"] }, + "increase_budget": { "update_fields": ["total_budget", "packages[].budget"] }, + "decrease_budget": { "update_fields": ["total_budget", "packages[].budget"] }, "reallocate_budget": { "update_fields": ["packages[].budget"] }, + "update_budget_allocation": { "update_fields": ["budget_allocation", "packages[].min_spend_target"] }, "update_targeting": { "update_fields": ["packages[].targeting_overlay", "packages[].keyword_targets_add", "packages[].keyword_targets_remove", "packages[].negative_keywords_add", "packages[].negative_keywords_remove"] }, - "update_pacing": { "update_fields": ["packages[].pacing"] }, + "update_pacing": { "update_fields": ["pacing", "packages[].pacing"] }, + "update_bidding": { "update_fields": ["bidding", "packages[].bidding"] }, "update_frequency_caps": { "update_fields": ["packages[].targeting_overlay.frequency_cap"] }, "replace_creative": { "update_fields": ["packages[].creatives"] }, "update_creative_assignments": { "update_fields": ["packages[].creative_assignments"] }, "remove_creative": { "update_fields": ["packages[].creatives", "packages[].creative_assignments"] }, "add_packages": { "update_fields": ["new_packages"] }, "remove_packages": { "update_fields": ["packages[].canceled"] }, - "update_budget": { "update_fields": ["packages[].budget"], "rollup": ["increase_budget", "decrease_budget", "reallocate_budget"] }, + "update_budget": { "update_fields": ["total_budget", "budget_allocation", "packages[].budget", "packages[].min_spend_target"], "rollup": ["increase_budget", "decrease_budget", "reallocate_budget", "update_budget_allocation"] }, "update_dates": { "update_fields": ["start_time", "end_time", "packages[].start_time", "packages[].end_time"], "rollup": ["extend_flight", "shorten_flight", "update_flight_dates"] }, - "update_packages": { "update_fields": ["packages[]"], "rollup": ["update_targeting", "update_pacing", "update_frequency_caps", "reallocate_budget", "remove_packages"] }, + "update_packages": { "update_fields": ["packages[]"], "rollup": ["update_targeting", "update_pacing", "update_bidding", "update_frequency_caps", "reallocate_budget", "remove_packages"] }, "sync_creatives": { "update_fields": ["packages[].creatives", "packages[].creative_assignments"], "rollup": ["replace_creative", "update_creative_assignments", "remove_creative"] } } } diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index ed105bb19e..bcefe9ebfd 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -245,6 +245,14 @@ "$ref": "/schemas/core/proposal.json", "description": "A proposed media plan with budget allocations across products - actionable via create_media_buy" }, + "budget-allocation": { + "$ref": "/schemas/core/budget-allocation.json", + "description": "Fixed or seller-optimized allocation of a media-buy total budget across packages" + }, + "bidding-policy": { + "$ref": "/schemas/core/bidding-policy.json", + "description": "Buyer-authored bidding, average-cost, or return policy at media-buy or package scope" + }, "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..390fc01d73 100644 --- a/static/schemas/source/media-buy/create-media-buy-request.json +++ b/static/schemas/source/media-buy/create-media-buy-request.json @@ -2,11 +2,45 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/create-media-buy-request.json", "title": "Create Media Buy Request", - "description": "Request parameters for creating a media buy. Supports two modes: (1) Manual mode - provide packages array with explicit line item configurations, or (2) Proposal mode - provide proposal_id and total_budget to execute a proposal from get_products. One of packages or proposal_id must be provided.", + "description": "Request parameters for creating a media buy. Supports explicit packages with fixed or seller-optimized budget allocation, or execution of a committed proposal with proposal_id and total_budget. Exactly one of packages or proposal_id must be provided.", "type": "object", "allOf": [ { "$ref": "/schemas/core/version-envelope.json" + }, + { + "if": { "required": ["bidding"] }, + "then": { + "properties": { + "packages": { + "items": { + "not": { + "anyOf": [ + { "required": ["bid_price"] }, + { + "properties": { + "optimization_goals": { + "contains": { + "properties": { + "target": { + "properties": { + "kind": { "enum": ["cost_per", "per_ad_spend"] } + }, + "required": ["kind"] + } + }, + "required": ["target"] + } + } + }, + "required": ["optimization_goals"] + } + ] + } + } + } + } + } } ], "x-mutates-state": true, @@ -29,11 +63,11 @@ }, "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. With total_budget, the publisher creates packages using the proposal's fixed percentages or seller-optimized constraints. Alternative to providing packages. 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'." }, "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.", + "description": "Hard aggregate lifetime budget for the media buy. Required when executing a proposal and for seller-optimized explicit packages. Optional in fixed explicit-package mode; when present there, amount MUST equal the sum of package budgets. For a fixed proposal, the publisher applies allocation percentages to this amount. For a seller-optimized proposal or explicit buy, packages draw dynamically from this shared total.", "properties": { "amount": { "type": "number", @@ -42,7 +76,8 @@ }, "currency": { "type": "string", - "description": "ISO 4217 currency code" + "description": "ISO 4217 currency code", + "pattern": "^[A-Z]{3}$" } }, "required": [ @@ -51,9 +86,17 @@ ], "additionalProperties": false }, + "budget_allocation": { + "allOf": [ + { + "$ref": "/schemas/core/budget-allocation.json" + } + ], + "description": "How budget is allocated across explicit packages. Omit for legacy fixed allocation. In proposal mode the committed proposal supplies this configuration and callers MUST omit it here." + }, "packages": { "type": "array", - "description": "Array of package configurations. Required when not using proposal_id. When executing a proposal, this can be omitted and packages will be derived from the proposal's allocations.", + "description": "Array of package configurations. Required when not using proposal_id. Fixed allocation requires budget on every package. Seller-optimized allocation permits package budget to be omitted or to act as a hard cap. When executing a proposal, omit packages; the seller derives them from the committed proposal.", "items": { "$ref": "/schemas/media-buy/package-request.json" }, @@ -119,6 +162,16 @@ "format": "date-time", "description": "Campaign end date/time in ISO 8601 format" }, + "pacing": { + "$ref": "/schemas/enums/pacing.json", + "description": "Aggregate pacing strategy for the media-buy budget across the media-buy flight. This controls how much the buy spends over time. Package pacing is subordinate and influences which package receives the aggregate spend; package pacing MUST NOT cause aggregate delivery to exceed this strategy. Defaults to even when total_budget is present. When executing a proposal that carries pacing, omit this field or send the identical value; the seller MUST reject a conflicting override with TERMS_REJECTED." + }, + "bidding": { + "allOf": [ + { "$ref": "/schemas/core/bidding-policy.json" } + ], + "description": "Complete media-buy bidding default inherited by packages that omit package.bidding. `{automatic:true}` records an explicit automatic policy. In seller-optimized mode, cost_per/roas bind to the primary budget_allocation.optimization_goals goal. In fixed mode, inherited cost_per is valid only when inheriting package primary-goal result units are compatible; inherited roas requires value-bearing primary goals. Every monetary field uses total_budget.currency or the single currency derived for the media buy, and every affected pricing option MUST declare that currency. Sellers MUST reject incompatible units, combinations, currency, or overrides before mutation with BIDDING_PLACEMENT_CONFLICT. Media-buy bidding combined with any inheriting package's legacy bid_price or legacy monetary optimization-goal target is ambiguous and MUST be rejected with AMBIGUOUS_BIDDING_POLICY." + }, "paused": { "type": "boolean", "description": "Create the media buy in a paused delivery state. When true, and the buy would otherwise be active because creatives are assigned and the flight has started, the seller returns media_buy_status 'paused'. Setup blockers still take precedence: a buy with no creatives remains 'pending_creatives', and a future-dated buy remains 'pending_start' until its flight can start. Defaults to false.", @@ -216,6 +269,98 @@ "start_time", "end_time" ], + "anyOf": [ + { + "title": "Explicit packages with fixed allocation", + "properties": { + "budget_allocation": { + "properties": { + "mode": { + "const": "fixed" + } + }, + "required": [ + "mode" + ] + }, + "packages": { + "items": { + "allOf": [ + { + "$ref": "/schemas/media-buy/package-request.json" + }, + { + "required": [ + "budget" + ] + }, + { + "not": { + "required": [ + "min_spend_target" + ] + } + } + ] + } + } + }, + "required": [ + "packages" + ], + "not": { + "required": [ + "proposal_id" + ] + } + }, + { + "title": "Explicit packages with seller-optimized allocation", + "properties": { + "budget_allocation": { + "properties": { + "mode": { + "const": "seller_optimized" + } + }, + "required": [ + "mode" + ] + } + }, + "required": [ + "packages", + "total_budget", + "budget_allocation" + ], + "not": { + "required": [ + "proposal_id" + ] + } + }, + { + "title": "Committed proposal execution", + "required": [ + "proposal_id", + "total_budget" + ], + "not": { + "anyOf": [ + { + "required": [ + "packages" + ] + }, + { + "required": [ + "budget_allocation" + ] + } + ] + } + } + ], "dependencies": { "proposal_id": [ "total_budget" 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..8e5a7f654a 100644 --- a/static/schemas/source/media-buy/create-media-buy-response.json +++ b/static/schemas/source/media-buy/create-media-buy-response.json @@ -57,14 +57,32 @@ }, "currency": { "type": "string", - "description": "ISO 4217 currency code (e.g., USD, EUR, GBP) for monetary values at this media buy level. total_budget is denominated in this currency. Package-level fields may override with package.currency. In proposal mode the seller derives this from the request's total_budget object (total_budget.currency); in manual mode it is present when all packages share a currency. Matches the currency field in subsequent get_media_buys responses.", + "description": "Single ISO 4217 currency code for total_budget, package budget constraints, and canonical BiddingPolicy monetary fields. Every selected pricing option MUST declare this currency; packages needing another currency belong in another media buy. In proposal mode the seller derives it from total_budget.currency; in explicit-package mode the seller derives or validates one common pricing-option currency. Matches subsequent get_media_buys responses.", "pattern": "^[A-Z]{3}$" }, "total_budget": { "type": "number", - "description": "Total budget amount across all packages, denominated in currency. Note: the create_media_buy request encodes total_budget as an object {amount, currency} (proposal mode only); this response field is the flattened scalar amount, with currency promoted to the sibling currency field. Present when the seller can compute a deterministic aggregate — always in proposal mode, conditionally in manual mode when all packages share a currency. Matches the total_budget field in subsequent get_media_buys responses.", + "description": "Hard aggregate lifetime budget, denominated in currency. The request encodes total_budget as an object {amount, currency}; this response flattens amount and promotes currency to its sibling field. Present for proposal and seller-optimized modes, and when supplied or deterministically derived in fixed explicit-package mode. Matches subsequent get_media_buys responses.", "minimum": 0 }, + "budget_allocation": { + "allOf": [ + { + "$ref": "/schemas/core/budget-allocation.json" + } + ], + "description": "Accepted cross-package allocation configuration. Omitted means fixed allocation for legacy buys." + }, + "pacing": { + "$ref": "/schemas/enums/pacing.json", + "description": "Accepted aggregate media-buy pacing strategy." + }, + "bidding": { + "allOf": [ + { "$ref": "/schemas/core/bidding-policy.json" } + ], + "description": "Accepted media-buy-authored bidding policy with goal binding and monetary denomination preserved. Packages that inherit it omit package.bidding; explicit package policies, including `{automatic:true}`, remain at package scope." + }, "valid_actions": { "type": "array", "description": "Flat-vocabulary actions the buyer can perform on this media buy after creation. Saves a round-trip to get_media_buys. Deprecated in favor of `available_actions[]`, which carries `mode`, optional SLA, and optional `terms_ref`. Sellers SHOULD populate both during the 3.x deprecation window; consumers MUST prefer `available_actions[]` when both are present. Removed in 4.0.", diff --git a/static/schemas/source/media-buy/get-media-buy-delivery-response.json b/static/schemas/source/media-buy/get-media-buy-delivery-response.json index 47fc090acd..6726b4b21e 100644 --- a/static/schemas/source/media-buy/get-media-buy-delivery-response.json +++ b/static/schemas/source/media-buy/get-media-buy-delivery-response.json @@ -255,6 +255,11 @@ "$ref": "/schemas/enums/pricing-model.json", "description": "Pricing model used for this media buy" }, + "pacing_index": { + "type": "number", + "description": "Aggregate media-buy delivery pace relative to the media-buy pacing plan (1.0 = on track, <1.0 = behind, >1.0 = ahead). This is the authoritative pacing signal for seller-optimized buys; package pacing indexes are subordinate diagnostics.", + "minimum": 0 + }, "totals": { "allOf": [ { @@ -294,7 +299,7 @@ }, "pacing_index": { "type": "number", - "description": "Delivery pace (1.0 = on track, <1.0 = behind, >1.0 = ahead)", + "description": "Package delivery pace relative to its package-level pacing plan (1.0 = on track, <1.0 = behind, >1.0 = ahead). In seller-optimized mode this is a subordinate diagnostic and may be absent when no package pacing preference exists.", "minimum": 0 }, "pricing_model": { @@ -308,7 +313,7 @@ }, "currency": { "type": "string", - "description": "ISO 4217 currency code (e.g., USD, EUR, GBP) for this package's pricing. Indicates the currency in which the rate and spend values are denominated. Different packages can use different currencies when supported by the publisher.", + "description": "ISO 4217 currency code for this package's pricing rate and spend. For AdCP-authored media buys this MUST equal the media-buy currency. A different package currency may appear only when reporting a legacy or externally created buy whose original contract allowed mixed denominations.", "pattern": "^[A-Z]{3}$" }, "delivery_status": { diff --git a/static/schemas/source/media-buy/get-media-buys-response.json b/static/schemas/source/media-buy/get-media-buys-response.json index 54df21de3d..d0936922ca 100644 --- a/static/schemas/source/media-buy/get-media-buys-response.json +++ b/static/schemas/source/media-buy/get-media-buys-response.json @@ -62,14 +62,32 @@ }, "currency": { "type": "string", - "description": "ISO 4217 currency code (e.g., USD, EUR, GBP) for monetary values at this media buy level. total_budget is always denominated in this currency. Package-level fields may override with package.currency.", + "description": "Single ISO 4217 denomination for total_budget, package budget constraints, and canonical BiddingPolicy monetary fields. Every selected pricing option on an AdCP-authored media buy MUST declare this currency. Legacy or externally-created mixed-currency buys must not expose canonical bidding until normalized or split.", "pattern": "^[A-Z]{3}$" }, "total_budget": { "type": "number", - "description": "Total budget amount across all packages, denominated in media_buy.currency", + "description": "Hard aggregate lifetime budget, denominated in media_buy.currency", "minimum": 0 }, + "budget_allocation": { + "allOf": [ + { + "$ref": "/schemas/core/budget-allocation.json" + } + ], + "description": "Current cross-package allocation configuration. Omitted means fixed allocation for legacy buys." + }, + "pacing": { + "$ref": "/schemas/enums/pacing.json", + "description": "Aggregate pacing strategy for the media-buy budget." + }, + "bidding": { + "allOf": [ + { "$ref": "/schemas/core/bidding-policy.json" } + ], + "description": "Current media-buy-authored bidding policy with scope-specific goal binding and media-buy-currency denomination. Package entries omit bidding when they inherit this block; explicit package automatic overrides remain visible as `{automatic:true}`." + }, "start_time": { "type": "string", "format": "date-time", @@ -223,19 +241,39 @@ }, "budget": { "type": "number", - "description": "Package budget amount, denominated in package.currency when present, otherwise media_buy.currency", + "description": "Hard lifetime package spend cap denominated in media_buy.currency. In seller-optimized mode this is not a current allocation.", + "minimum": 0 + }, + "min_spend_target": { + "type": "number", + "description": "Accepted soft lifetime spend target for this package under seller-optimized allocation.", "minimum": 0 }, "currency": { "type": "string", - "description": "ISO 4217 currency code for monetary values at this package level (budget, bid_price, snapshot.spend). When absent, inherit media_buy.currency.", + "description": "Legacy/readback package denomination for buys created outside the canonical 3.2 path. For AdCP-authored media buys this MUST equal media_buy.currency; canonical package budget and BiddingPolicy values always use media_buy.currency. Snapshot currency may still identify externally reported spend denomination.", "pattern": "^[A-Z]{3}$" }, "bid_price": { "type": "number", - "description": "Current bid price for auction-based packages. Denominated in package.currency when present, otherwise media_buy.currency. Relevant for automated price optimization loops.", + "deprecated": true, + "description": "DEPRECATED legacy bid representation. 3.2 sellers SHOULD normalize and echo package.bidding instead.", "minimum": 0 }, + "bidding": { + "allOf": [ + { "$ref": "/schemas/core/bidding-policy.json" } + ], + "description": "Package-authored bidding override. `{automatic:true}` explicitly overrides media_buy.bidding with provider automatic delivery. Omitted when this package inherits; sellers MUST NOT copy an inherited block here. Monetary fields use media_buy.currency." + }, + "optimization_goals": { + "type": "array", + "description": "Current package objective functions. Currency-bearing execution controls are returned separately in package.bidding or inherited from media_buy.bidding.", + "items": { + "$ref": "/schemas/core/optimization-goal.json" + }, + "minItems": 1 + }, "format_ids": { "type": "array", "description": "Legacy named-format IDs supplied for this package on create_media_buy. Sellers SHOULD echo this field whenever the request included it, including dual-emission cases where another selector won precedence.", @@ -266,6 +304,10 @@ "description": "Goal impression count for impression-based packages", "minimum": 0 }, + "pacing": { + "$ref": "/schemas/enums/pacing.json", + "description": "Package-level pacing preference. Under seller-optimized allocation this is subordinate to the media-buy aggregate pacing." + }, "targeting_overlay": { "$ref": "/schemas/core/targeting.json", "description": "Targeting overlay applied to this package, echoed from the most recent create_media_buy or update_media_buy. Sellers SHOULD echo any persisted targeting so buyers can verify what was stored without replaying their own request. Sellers claiming the property-lists or collection-lists specialisms MUST include, within this targeting_overlay, the PropertyListReference / CollectionListReference they persisted." @@ -434,6 +476,35 @@ "required": [ "package_id" ], + "allOf": [ + { + "not": { + "required": ["bidding", "bid_price"] + } + }, + { + "if": { "required": ["bidding"] }, + "then": { + "properties": { + "optimization_goals": { + "not": { + "contains": { + "properties": { + "target": { + "properties": { + "kind": { "enum": ["cost_per", "per_ad_spend"] } + }, + "required": ["kind"] + } + }, + "required": ["target"] + } + } + } + } + } + } + ], "dependencies": { "params": ["format_kind"] }, diff --git a/static/schemas/source/media-buy/media-buy-delivery-webhook-result.json b/static/schemas/source/media-buy/media-buy-delivery-webhook-result.json index 1b1263b462..6b48cceadc 100644 --- a/static/schemas/source/media-buy/media-buy-delivery-webhook-result.json +++ b/static/schemas/source/media-buy/media-buy-delivery-webhook-result.json @@ -111,6 +111,11 @@ "pricing_model": { "$ref": "/schemas/enums/pricing-model.json" }, + "pacing_index": { + "type": "number", + "description": "Aggregate media-buy delivery pace relative to the media-buy pacing plan. Authoritative for seller-optimized buys.", + "minimum": 0 + }, "totals": { "allOf": [ { @@ -149,6 +154,7 @@ }, "pacing_index": { "type": "number", + "description": "Subordinate package pacing diagnostic. May be absent for seller-optimized packages without package-level pacing.", "minimum": 0 }, "pricing_model": { diff --git a/static/schemas/source/media-buy/package-request.json b/static/schemas/source/media-buy/package-request.json index f70f397654..d7feb5ee0c 100644 --- a/static/schemas/source/media-buy/package-request.json +++ b/static/schemas/source/media-buy/package-request.json @@ -7,10 +7,35 @@ "allOf": [ { "$ref": "/schemas/core/version-envelope.json" + }, + { + "if": { "required": ["bidding"] }, + "then": { + "properties": { + "optimization_goals": { + "not": { + "contains": { + "properties": { + "target": { + "properties": { + "kind": { "enum": ["cost_per", "per_ad_spend"] } + }, + "required": ["kind"] + } + }, + "required": ["target"] + } + } + } + } + } } ], "not": { - "required": ["capability_ids"] + "anyOf": [ + { "required": ["capability_ids"] }, + { "required": ["bidding", "bid_price"] } + ] }, "properties": { "product_id": { @@ -45,7 +70,12 @@ }, "budget": { "type": "number", - "description": "Budget allocation for this package in the media buy's currency", + "description": "Hard lifetime spend cap for this package in the media buy's currency. Required in fixed allocation mode. Optional in seller-optimized mode; when omitted, the package is bounded by the shared total_budget and any other package constraints. In seller-optimized mode this is a ceiling, not a reserved or current allocation.", + "minimum": 0 + }, + "min_spend_target": { + "type": "number", + "description": "Soft lifetime spend target for this package in the media buy's currency. Only valid with seller-optimized budget allocation. The seller SHOULD attempt to deliver at least this amount before allocating incremental spend elsewhere, but inventory, policy, optimization targets, or other delivery constraints may prevent it. This is not a billing guarantee. Must not exceed the package budget when both are present.", "minimum": 0 }, "pacing": { @@ -58,9 +88,16 @@ }, "bid_price": { "type": "number", - "description": "Bid price for auction-based pricing options. This is the exact bid/price to honor unless selected pricing_option has max_bid=true, in which case bid_price is the buyer's maximum willingness to pay (ceiling).", + "deprecated": true, + "description": "DEPRECATED in 3.2 and removed in the next major. Use bidding.bid_amount or bidding.max_bid. Legacy normalization: selected pricing_option.max_bid=true maps to bidding.max_bid; otherwise maps to bidding.bid_amount. A package MUST NOT supply both representations.", "minimum": 0 }, + "bidding": { + "allOf": [ + { "$ref": "/schemas/core/bidding-policy.json" } + ], + "description": "Package-authored bidding policy. This complete block replaces, rather than field-merges with, any media-buy bidding policy for this package. `{automatic:true}` explicitly overrides a media-buy policy with provider automatic bidding; omission inherits the complete media-buy block. Monetary fields use the media-buy currency, while the selected pricing option supplies only the auction unit and MUST declare that same currency. Sellers MUST reject a new bidding block combined with legacy bid_price or legacy monetary optimization-goal targets on the same effective package with AMBIGUOUS_BIDDING_POLICY." + }, "impressions": { "type": "number", "description": "Impression goal for this package", @@ -215,7 +252,6 @@ }, "required": [ "product_id", - "budget", "pricing_option_id" ], "dependencies": { diff --git a/static/schemas/source/media-buy/package-update.json b/static/schemas/source/media-buy/package-update.json index 1f0277f69c..83c7b4b22b 100644 --- a/static/schemas/source/media-buy/package-update.json +++ b/static/schemas/source/media-buy/package-update.json @@ -11,8 +11,13 @@ "x-entity": "package" }, "budget": { - "type": "number", - "description": "Updated budget allocation for this package in the currency specified by the pricing option", + "type": ["number", "null"], + "description": "Updated hard spend cap for this package in the media-buy currency. Every selected pricing option in an AdCP-authored media buy MUST declare that same currency. In seller-optimized mode a number changes the package ceiling and null removes it so only the shared total and other constraints bound the package. null is invalid when the resulting allocation mode is fixed.", + "minimum": 0 + }, + "min_spend_target": { + "type": ["number", "null"], + "description": "Updated soft lifetime spend target for this package. A number is valid only for seller-optimized allocation and must not exceed the resulting package budget when one exists. null removes the target. Sellers MUST validate the complete post-update state atomically.", "minimum": 0 }, "pacing": { @@ -20,9 +25,17 @@ }, "bid_price": { "type": "number", - "description": "Updated bid price for auction-based pricing options. This is the exact bid/price to honor unless selected pricing_option has max_bid=true, in which case bid_price is the buyer's maximum willingness to pay (ceiling).", + "deprecated": true, + "description": "DEPRECATED in 3.2 and removed in the next major. Use bidding. A package update MUST NOT supply both a non-null bidding object and bid_price. During migration, bidding:null MAY accompany bid_price to clear the canonical block and set the legacy representation atomically.", "minimum": 0 }, + "bidding": { + "description": "Replace the complete package-authored bidding policy. An object replaces any prior package block and remains a complete override of the media-buy default. `{automatic:true}` explicitly selects provider automatic bidding at package scope. null clears the package-authored block so the package inherits media-buy bidding; if the media-buy block is also absent, provider automatic delivery applies. Monetary fields use the media-buy currency and require the package pricing option to declare that currency. During legacy migration, null MAY accompany bid_price or monetary optimization-goal targets; only a non-null canonical bidding object conflicts with those representations.", + "anyOf": [ + { "$ref": "/schemas/core/bidding-policy.json" }, + { "type": "null" } + ] + }, "impressions": { "type": "number", "description": "Updated impression goal for this package", @@ -184,6 +197,33 @@ "required": [ "package_id" ], + "allOf": [ + { + "if": { + "properties": { "bidding": { "type": "object" } }, + "required": ["bidding"] + }, + "then": { + "properties": { + "optimization_goals": { + "not": { + "contains": { + "properties": { + "target": { + "properties": { + "kind": { "enum": ["cost_per", "per_ad_spend"] } + }, + "required": ["kind"] + } + }, + "required": ["target"] + } + } + } + } + } + } + ], "not": { "anyOf": [ { "required": ["product_id"] }, @@ -192,7 +232,11 @@ { "required": ["format_kind"] }, { "required": ["params"] }, { "required": ["capability_ids"] }, - { "required": ["pricing_option_id"] } + { "required": ["pricing_option_id"] }, + { + "properties": { "bidding": { "type": "object" } }, + "required": ["bidding", "bid_price"] + } ] }, "additionalProperties": true diff --git a/static/schemas/source/media-buy/sync-event-sources-request.json b/static/schemas/source/media-buy/sync-event-sources-request.json index e1b1e5bb59..54fbe63b0e 100644 --- a/static/schemas/source/media-buy/sync-event-sources-request.json +++ b/static/schemas/source/media-buy/sync-event-sources-request.json @@ -46,6 +46,16 @@ }, "minItems": 1 }, + "value_currencies": { + "type": "array", + "description": "ISO 4217 currencies this source accepts for monetary custom_data values. Required for a source used by canonical ROAS. Once declared, log_event monetary records for this source MUST include a currency from this list. Sources omitting the field retain legacy event compatibility but are ineligible for canonical ROAS. One source may list multiple currencies; each media buy consumes only records matching its own currency and no conversion occurs.", + "items": { + "type": "string", + "pattern": "^[A-Z]{3}$" + }, + "minItems": 1, + "uniqueItems": true + }, "action_source": { "$ref": "/schemas/enums/action-source.json", "description": "Flat action-source category for this event source, such as website, app, in_store, system_generated, or other. Producers SHOULD also populate `surface` when the flat value is too coarse, and SHOULD keep this field aligned with `surface.category` when the category is also an ActionSource value." diff --git a/static/schemas/source/media-buy/sync-event-sources-response.json b/static/schemas/source/media-buy/sync-event-sources-response.json index d11a5746f5..9653363d1a 100644 --- a/static/schemas/source/media-buy/sync-event-sources-response.json +++ b/static/schemas/source/media-buy/sync-event-sources-response.json @@ -44,6 +44,16 @@ "$ref": "/schemas/enums/event-type.json" } }, + "value_currencies": { + "type": "array", + "description": "ISO 4217 currencies accepted for monetary event values from this source. Canonical ROAS buys require their media-buy currency in this list.", + "items": { + "type": "string", + "pattern": "^[A-Z]{3}$" + }, + "minItems": 1, + "uniqueItems": true + }, "action_source": { "$ref": "/schemas/enums/action-source.json", "description": "Type of event source (website pixel, app SDK, in-store, etc.)" diff --git a/static/schemas/source/media-buy/update-media-buy-request.json b/static/schemas/source/media-buy/update-media-buy-request.json index df859a8c3b..b4bf7b0d0f 100644 --- a/static/schemas/source/media-buy/update-media-buy-request.json +++ b/static/schemas/source/media-buy/update-media-buy-request.json @@ -7,6 +7,69 @@ "allOf": [ { "$ref": "/schemas/core/version-envelope.json" + }, + { + "if": { + "properties": { "bidding": { "type": "object" } }, + "required": ["bidding"] + }, + "then": { + "properties": { + "packages": { + "items": { + "not": { + "anyOf": [ + { "required": ["bid_price"] }, + { + "properties": { + "optimization_goals": { + "contains": { + "properties": { + "target": { + "properties": { + "kind": { "enum": ["cost_per", "per_ad_spend"] } + }, + "required": ["kind"] + } + }, + "required": ["target"] + } + } + }, + "required": ["optimization_goals"] + } + ] + } + } + } + } + } + }, + { + "if": { + "required": ["new_packages"], + "not": { + "properties": { + "budget_allocation": { + "properties": { + "mode": { "const": "seller_optimized" } + }, + "required": ["mode"] + } + }, + "required": ["budget_allocation"] + } + }, + "then": { + "properties": { + "new_packages": { + "items": { + "required": ["budget"], + "not": { "required": ["min_spend_target"] } + } + } + } + } } ], "x-mutates-state": true, @@ -47,6 +110,44 @@ "format": "date-time", "description": "New end date/time in ISO 8601 format" }, + "total_budget": { + "type": "object", + "description": "Updated hard aggregate lifetime budget. currency MUST equal the existing media-buy currency; an update does not redenominate a buy. In fixed mode the amount MUST equal the sum of package budgets after applying this update. In seller-optimized mode this changes the shared pool; already-spent amounts still count against the new total.", + "properties": { + "amount": { + "type": "number", + "minimum": 0 + }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$" + } + }, + "required": [ + "amount", + "currency" + ], + "additionalProperties": false + }, + "budget_allocation": { + "allOf": [ + { + "$ref": "/schemas/core/budget-allocation.json" + } + ], + "description": "Updated allocation configuration. Switching between fixed and seller-optimized modes is allowed only when update_budget_allocation is advertised in available_actions and the resulting package constraints are valid." + }, + "pacing": { + "$ref": "/schemas/enums/pacing.json", + "description": "Updated aggregate media-buy pacing. Package pacing remains subordinate to this aggregate strategy." + }, + "bidding": { + "description": "Replace the complete media-buy-authored bidding default. An object replaces the prior block; `{automatic:true}` records an explicit automatic policy. null clears it; packages with explicit package.bidding remain explicit, while packages without overrides fall back to provider automatic delivery. Goal binding follows the resulting budget allocation: seller-optimized outcome controls bind to allocation goals, while fixed inherited cost_per requires compatible package result units. Monetary fields use the media-buy currency and all affected pricing options MUST match it. The seller MUST validate all resulting policies atomically before mutation.", + "anyOf": [ + { "$ref": "/schemas/core/bidding-policy.json" }, + { "type": "null" } + ] + }, "packages": { "type": "array", "description": "Package-specific updates for existing packages", @@ -61,7 +162,7 @@ }, "new_packages": { "type": "array", - "description": "New packages to add to this media buy. Uses the same schema as create_media_buy packages. Sellers that support mid-flight package additions advertise `add_packages` in both `valid_actions[]` (deprecated) and as an entry in `available_actions[]` (authoritative). Sellers that do not support this MUST reject with ACTION_NOT_ALLOWED (preferred) or UNSUPPORTED_FEATURE (legacy).", + "description": "New packages to add to this media buy. Uses the same schema as create_media_buy packages. When budget_allocation is omitted or fixed, every new package MUST carry budget and MUST NOT carry min_spend_target. To add a package without a hard cap to an existing seller-optimized buy, include its resulting seller_optimized budget_allocation block in the update so the allocation context is schema-visible. Repeating an unchanged allocation block does not itself switch modes. Sellers that support mid-flight package additions advertise `add_packages` in both `valid_actions[]` (deprecated) and as an entry in `available_actions[]` (authoritative). Sellers that do not support this MUST reject with ACTION_NOT_ALLOWED (preferred) or UNSUPPORTED_FEATURE (legacy).", "items": { "$ref": "/schemas/media-buy/package-request.json" }, diff --git a/static/schemas/source/media-buy/update-media-buy-response.json b/static/schemas/source/media-buy/update-media-buy-response.json index 183b9dad96..3047370fac 100644 --- a/static/schemas/source/media-buy/update-media-buy-response.json +++ b/static/schemas/source/media-buy/update-media-buy-response.json @@ -44,9 +44,27 @@ }, "total_budget": { "type": "number", - "description": "Updated total budget amount across all packages, denominated in currency. Echoed when the update affects package budgets so buyers can verify the new aggregate without a round-trip to get_media_buys. Matches the total_budget field in subsequent get_media_buys responses.", + "description": "Updated hard aggregate lifetime budget, denominated in currency. Echoed when the update affects the shared total or package budgets so buyers can verify the new aggregate without a round-trip to get_media_buys.", "minimum": 0 }, + "budget_allocation": { + "allOf": [ + { + "$ref": "/schemas/core/budget-allocation.json" + } + ], + "description": "Post-update cross-package allocation configuration when affected by the update." + }, + "pacing": { + "$ref": "/schemas/enums/pacing.json", + "description": "Post-update aggregate pacing when affected by the update." + }, + "bidding": { + "allOf": [ + { "$ref": "/schemas/core/bidding-policy.json" } + ], + "description": "Post-update media-buy-authored bidding policy when affected by the update, preserving explicit automatic mode, scope-specific goal binding, and media-buy-currency denomination. Omitted when the authored media-buy block was cleared." + }, "implementation_date": { "type": [ "string", diff --git a/static/schemas/source/pricing-options/cpc-option.json b/static/schemas/source/pricing-options/cpc-option.json index 58c07115cc..cb524f6271 100644 --- a/static/schemas/source/pricing-options/cpc-option.json +++ b/static/schemas/source/pricing-options/cpc-option.json @@ -32,7 +32,8 @@ }, "max_bid": { "type": "boolean", - "description": "When true, bid_price is interpreted as the buyer's maximum willingness to pay (ceiling) rather than an exact price. Sellers may optimize actual clearing prices between floor_price and bid_price based on delivery pacing. When false or absent, bid_price (if provided) is the exact bid/price to honor.", + "deprecated": true, + "description": "DEPRECATED in 3.2 and removed in the next major. Legacy hint used only to normalize package bid_price to bidding.max_bid (true) or bidding.bid_amount (false/absent). New buyers express intent directly in bidding.", "default": false }, "price_guidance": { diff --git a/static/schemas/source/pricing-options/cpcv-option.json b/static/schemas/source/pricing-options/cpcv-option.json index 51b92c31eb..a91958a62e 100644 --- a/static/schemas/source/pricing-options/cpcv-option.json +++ b/static/schemas/source/pricing-options/cpcv-option.json @@ -32,7 +32,8 @@ }, "max_bid": { "type": "boolean", - "description": "When true, bid_price is interpreted as the buyer's maximum willingness to pay (ceiling) rather than an exact price. Sellers may optimize actual clearing prices between floor_price and bid_price based on delivery pacing. When false or absent, bid_price (if provided) is the exact bid/price to honor.", + "deprecated": true, + "description": "DEPRECATED in 3.2 and removed in the next major. Legacy hint used only to normalize package bid_price to bidding.max_bid (true) or bidding.bid_amount (false/absent). New buyers express intent directly in bidding.", "default": false }, "price_guidance": { diff --git a/static/schemas/source/pricing-options/cpm-option.json b/static/schemas/source/pricing-options/cpm-option.json index 7e3bf90972..1e34ccf250 100644 --- a/static/schemas/source/pricing-options/cpm-option.json +++ b/static/schemas/source/pricing-options/cpm-option.json @@ -32,7 +32,8 @@ }, "max_bid": { "type": "boolean", - "description": "When true, bid_price is interpreted as the buyer's maximum willingness to pay (ceiling) rather than an exact price. Sellers may optimize actual clearing prices between floor_price and bid_price based on delivery pacing. When false or absent, bid_price (if provided) is the exact bid/price to honor.", + "deprecated": true, + "description": "DEPRECATED in 3.2 and removed in the next major. Legacy hint used only to normalize package bid_price to bidding.max_bid (true) or bidding.bid_amount (false/absent). New buyers express intent directly in bidding.", "default": false }, "price_guidance": { diff --git a/static/schemas/source/pricing-options/cpv-option.json b/static/schemas/source/pricing-options/cpv-option.json index 6048d6ade1..7a09fe80a3 100644 --- a/static/schemas/source/pricing-options/cpv-option.json +++ b/static/schemas/source/pricing-options/cpv-option.json @@ -32,7 +32,8 @@ }, "max_bid": { "type": "boolean", - "description": "When true, bid_price is interpreted as the buyer's maximum willingness to pay (ceiling) rather than an exact price. Sellers may optimize actual clearing prices between floor_price and bid_price based on delivery pacing. When false or absent, bid_price (if provided) is the exact bid/price to honor.", + "deprecated": true, + "description": "DEPRECATED in 3.2 and removed in the next major. Legacy hint used only to normalize package bid_price to bidding.max_bid (true) or bidding.bid_amount (false/absent). New buyers express intent directly in bidding.", "default": false }, "price_guidance": { diff --git a/static/schemas/source/pricing-options/vcpm-option.json b/static/schemas/source/pricing-options/vcpm-option.json index 75c61f98cc..ee93ebba4f 100644 --- a/static/schemas/source/pricing-options/vcpm-option.json +++ b/static/schemas/source/pricing-options/vcpm-option.json @@ -32,7 +32,8 @@ }, "max_bid": { "type": "boolean", - "description": "When true, bid_price is interpreted as the buyer's maximum willingness to pay (ceiling) rather than an exact price. Sellers may optimize actual clearing prices between floor_price and bid_price based on delivery pacing. When false or absent, bid_price (if provided) is the exact bid/price to honor.", + "deprecated": true, + "description": "DEPRECATED in 3.2 and removed in the next major. Legacy hint used only to normalize package bid_price to bidding.max_bid (true) or bidding.bid_amount (false/absent). New buyers express intent directly in bidding.", "default": false }, "price_guidance": { diff --git a/tests/composed-schema-validation.test.cjs b/tests/composed-schema-validation.test.cjs index 0c54047f86..eca27c7067 100644 --- a/tests/composed-schema-validation.test.cjs +++ b/tests/composed-schema-validation.test.cjs @@ -414,6 +414,549 @@ async function runTests() { 'Create media buy with natural key account' ); + await testSchemaValidation( + '/schemas/media-buy/create-media-buy-request.json', + { + idempotency_key: 'shared-budget-create-0001', + account: { account_id: 'acc_test_001' }, + total_budget: { amount: 100000, currency: 'USD' }, + budget_allocation: { + mode: 'seller_optimized', + optimization_goals: [ + { kind: 'metric', metric: 'clicks' } + ] + }, + pacing: 'even', + bidding: { + cost_per: { amount: 25, strength: 'cap' }, + max_bid: 8 + }, + packages: [ + { + product_id: 'prospecting', + pricing_option_id: 'cpm_auction', + budget: 70000, + min_spend_target: 20000 + }, + { + product_id: 'retargeting', + pricing_option_id: 'cpm_auction', + pacing: 'front_loaded' + } + ], + brand: { domain: 'acmecorp.com' }, + start_time: 'asap', + end_time: '2099-12-31T23:59:59Z' + }, + 'Create media buy accepts seller-optimized shared budget with package constraints' + ); + + await testSchemaValidation( + '/schemas/core/bidding-policy.json', + { + cost_per: { amount: 25, strength: 'target' }, + max_bid: 4.5 + }, + 'Bidding policy accepts average-cost target with a provider-supported auction ceiling' + ); + + await testSchemaValidation( + '/schemas/core/bidding-policy.json', + { automatic: true }, + 'Bidding policy accepts an explicit automatic policy override' + ); + + await testSchemaRejection( + '/schemas/core/bidding-policy.json', + { automatic: true, max_bid: 4.5 }, + 'Bidding policy rejects automatic combined with a monetary mode' + ); + + await testSchemaValidation( + '/schemas/core/media-buy-features.json', + { + bidding_policy: { + package: { + fixed: { + modes: ['cost_per'], + cost_per_strengths: ['cap'] + } + } + } + }, + 'Bidding capability can advertise only package cost caps' + ); + + await testSchemaValidation( + '/schemas/core/media-buy-features.json', + { + bidding_policy: { + media_buy: { + fixed: { + modes: ['max_bid', 'roas'], + roas_strengths: ['floor', 'target'], + supported_combinations: [ + { kind: 'max_bid_with_roas', roas_strengths: ['floor'] } + ] + } + }, + package: { + seller_optimized: { + modes: ['automatic', 'bid_amount'] + } + } + } + }, + 'Bidding capability declares independent scopes, strengths, and combinations' + ); + + await testSchemaRejection( + '/schemas/core/media-buy-features.json', + { bidding_policy: true }, + 'Bidding capability rejects the former coarse boolean claim' + ); + + await testSchemaRejection( + '/schemas/core/bidding-policy-capability.json', + { + package: { fixed: { modes: ['cost_per'] } } + }, + 'Bidding capability requires strengths for cost_per support' + ); + + await testSchemaValidation( + '/schemas/core/bidding-policy-capability.json', + { + media_buy: { + fixed: { + supported_combinations: [ + { kind: 'max_bid_with_cost_per', cost_per_strengths: ['cap'] } + ] + } + } + }, + 'Bidding capability advertises combination-only support without standalone component modes' + ); + + await testSchemaRejection( + '/schemas/core/bidding-policy-capability.json', + { + package: { + modes: ['automatic'] + } + }, + 'Bidding capability requires an explicit allocation context' + ); + + await testSchemaValidation( + '/schemas/media-buy/sync-event-sources-request.json', + { + idempotency_key: 'value-currency-source-0001', + account: { account_id: 'acc_test_001' }, + event_sources: [ + { + event_source_id: 'commerce_events', + event_types: ['purchase'], + value_currencies: ['USD', 'EUR'] + } + ] + }, + 'Event source declares the currencies available to canonical ROAS buys' + ); + + await testSchemaValidation( + '/schemas/core/event-custom-data.json', + { value: 25 }, + 'Legacy monetary event data remains compatible without a source currency contract' + ); + + await testSchemaValidation( + '/schemas/media-buy/create-media-buy-request.json', + { + idempotency_key: 'automatic-package-override-001', + account: { account_id: 'acc_test_001' }, + total_budget: { amount: 20000, currency: 'USD' }, + bidding: { max_bid: 5 }, + packages: [ + { + product_id: 'display_default', + pricing_option_id: 'cpm_usd_auction', + budget: 10000 + }, + { + product_id: 'display_automatic', + pricing_option_id: 'cpm_usd_auction', + budget: 10000, + bidding: { automatic: true } + } + ], + brand: { domain: 'acmecorp.com' }, + start_time: 'asap', + end_time: '2099-12-31T23:59:59Z' + }, + 'Package automatic policy explicitly overrides a media-buy bidding default' + ); + + await testSchemaValidation( + '/schemas/media-buy/package-update.json', + { + package_id: 'pkg_automatic_001', + bidding: { automatic: true } + }, + 'Package update accepts an explicit automatic bidding override' + ); + + await testSchemaValidation( + '/schemas/core/package.json', + { + package_id: 'pkg_automatic_001', + bidding: { automatic: true } + }, + 'Package readback preserves an explicit automatic bidding override' + ); + + await testSchemaValidation( + '/schemas/media-buy/package-request.json', + { + product_id: 'search_clicks', + pricing_option_id: 'cpc_auction', + budget: 10000, + bidding: { bid_amount: 2.25 }, + optimization_goals: [ + { kind: 'metric', metric: 'clicks' } + ] + }, + 'Package accepts explicit manual bidding separately from its objective' + ); + + await testSchemaValidation( + '/schemas/media-buy/package-update.json', + { + package_id: 'pkg_search_001', + bidding: null + }, + 'Package update accepts clearing an authored bidding override to restore inheritance' + ); + + await testSchemaValidation( + '/schemas/media-buy/package-update.json', + { + package_id: 'pkg_shared_001', + budget: null, + min_spend_target: null + }, + 'Package update accepts clearing seller-optimized package spend constraints' + ); + + await testSchemaValidation( + '/schemas/media-buy/package-update.json', + { + package_id: 'pkg_search_001', + bidding: null, + bid_price: 2.25 + }, + 'Package update accepts atomically clearing canonical bidding and setting legacy bid_price' + ); + + await testSchemaValidation( + '/schemas/media-buy/package-update.json', + { + package_id: 'pkg_conversion_001', + bidding: null, + optimization_goals: [ + { + kind: 'event', + event_sources: [{ event_source_id: 'purchases', event_type: 'purchase' }], + target: { kind: 'cost_per', value: 25 } + } + ] + }, + 'Package update accepts clearing canonical bidding with a legacy monetary goal target' + ); + + await testSchemaRejection( + '/schemas/media-buy/package-update.json', + { + package_id: 'pkg_search_001', + bidding: { max_bid: 3 }, + bid_price: 2.25 + }, + 'Package update rejects non-null canonical bidding combined with legacy bid_price' + ); + + await testSchemaRejection( + '/schemas/media-buy/package-update.json', + { + package_id: 'pkg_conversion_001', + bidding: { cost_per: { amount: 25, strength: 'target' } }, + optimization_goals: [ + { + kind: 'event', + event_sources: [{ event_source_id: 'purchases', event_type: 'purchase' }], + target: { kind: 'cost_per', value: 25 } + } + ] + }, + 'Package update rejects non-null canonical bidding with a legacy monetary goal target' + ); + + await testSchemaRejection( + '/schemas/core/bidding-policy.json', + { + bid_amount: 2.25, + max_bid: 3 + }, + 'Bidding policy rejects simultaneous manual bid and hard auction ceiling' + ); + + await testSchemaRejection( + '/schemas/core/bidding-policy.json', + { + cost_per: { amount: 25, strength: 'cap' }, + roas: { value: 4, strength: 'floor' } + }, + 'Bidding policy rejects multiple primary policy modes' + ); + + await testSchemaRejection( + '/schemas/media-buy/package-request.json', + { + product_id: 'search_clicks', + pricing_option_id: 'cpc_auction', + budget: 10000, + bid_price: 2.25, + bidding: { max_bid: 3 } + }, + 'Package rejects canonical bidding combined with legacy bid_price' + ); + + await testSchemaRejection( + '/schemas/media-buy/package-request.json', + { + product_id: 'conversion_search', + pricing_option_id: 'cpc_auction', + budget: 10000, + bidding: { cost_per: { amount: 25, strength: 'target' } }, + optimization_goals: [ + { + kind: 'event', + event_sources: [{ event_source_id: 'purchases', event_type: 'purchase' }], + target: { kind: 'cost_per', value: 25 } + } + ] + }, + 'Package rejects canonical bidding combined with a legacy monetary goal target' + ); + + await testSchemaRejection( + '/schemas/media-buy/create-media-buy-request.json', + { + idempotency_key: 'ambiguous-media-bidding-001', + account: { account_id: 'acc_test_001' }, + bidding: { max_bid: 5 }, + packages: [ + { + product_id: 'display_standard', + pricing_option_id: 'cpm_auction', + budget: 10000, + bid_price: 4 + } + ], + brand: { domain: 'acmecorp.com' }, + start_time: 'asap', + end_time: '2099-12-31T23:59:59Z' + }, + 'Media-buy bidding rejects an inheriting package with legacy bid_price' + ); + + await testSchemaRejection( + '/schemas/media-buy/create-media-buy-request.json', + { + idempotency_key: 'fixed-missing-budget-001', + account: { account_id: 'acc_test_001' }, + packages: [ + { + product_id: 'display_standard', + pricing_option_id: 'cpm_fixed' + } + ], + brand: { domain: 'acmecorp.com' }, + start_time: 'asap', + end_time: '2099-12-31T23:59:59Z' + }, + 'Create media buy keeps package budget required in fixed mode' + ); + + await testSchemaRejection( + '/schemas/media-buy/update-media-buy-request.json', + { + idempotency_key: 'fixed-add-missing-budget-001', + account: { account_id: 'acc_test_001' }, + media_buy_id: 'mb_fixed_001', + new_packages: [ + { + product_id: 'display_standard', + pricing_option_id: 'cpm_fixed' + } + ] + }, + 'Update media buy keeps new-package budget required when allocation is fixed or omitted' + ); + + await testSchemaRejection( + '/schemas/media-buy/update-media-buy-request.json', + { + idempotency_key: 'explicit-fixed-add-missing-001', + account: { account_id: 'acc_test_001' }, + media_buy_id: 'mb_fixed_002', + budget_allocation: { mode: 'fixed' }, + new_packages: [ + { + product_id: 'display_standard', + pricing_option_id: 'cpm_fixed' + } + ] + }, + 'Update media buy requires new-package budget when fixed allocation is explicit' + ); + + await testSchemaValidation( + '/schemas/media-buy/update-media-buy-request.json', + { + idempotency_key: 'shared-add-uncapped-001', + account: { account_id: 'acc_test_001' }, + media_buy_id: 'mb_shared_001', + budget_allocation: { + mode: 'seller_optimized', + optimization_goals: [ + { kind: 'metric', metric: 'clicks' } + ] + }, + new_packages: [ + { + product_id: 'retargeting', + pricing_option_id: 'cpm_auction', + min_spend_target: 5000 + } + ] + }, + 'Update media buy accepts an uncapped new package with explicit seller-optimized allocation context' + ); + + await testSchemaRejection( + '/schemas/media-buy/create-media-buy-request.json', + { + idempotency_key: 'shared-missing-total-001', + account: { account_id: 'acc_test_001' }, + budget_allocation: { + mode: 'seller_optimized', + optimization_goals: [ + { kind: 'metric', metric: 'clicks' } + ] + }, + packages: [ + { + product_id: 'display_standard', + pricing_option_id: 'cpm_fixed' + } + ], + brand: { domain: 'acmecorp.com' }, + start_time: 'asap', + end_time: '2099-12-31T23:59:59Z' + }, + 'Create media buy rejects seller-optimized allocation without total_budget' + ); + + await testSchemaRejection( + '/schemas/media-buy/create-media-buy-request.json', + { + idempotency_key: 'lowercase-currency-0001', + account: { account_id: 'acc_test_001' }, + total_budget: { amount: 10000, currency: 'usd' }, + packages: [ + { + product_id: 'display_standard', + pricing_option_id: 'cpm_fixed', + budget: 10000 + } + ], + brand: { domain: 'acmecorp.com' }, + start_time: 'asap', + end_time: '2099-12-31T23:59:59Z' + }, + 'Create media buy rejects a malformed media-buy currency' + ); + + await testSchemaRejection( + '/schemas/media-buy/create-media-buy-request.json', + { + idempotency_key: 'allocation-legacy-target-0001', + account: { account_id: 'acc_test_001' }, + total_budget: { amount: 10000, currency: 'USD' }, + budget_allocation: { + mode: 'seller_optimized', + optimization_goals: [ + { + kind: 'metric', + metric: 'clicks', + target: { kind: 'cost_per', value: 3 } + } + ] + }, + packages: [ + { + product_id: 'display_standard', + pricing_option_id: 'cpm_fixed' + } + ], + brand: { domain: 'acmecorp.com' }, + start_time: 'asap', + end_time: '2099-12-31T23:59:59Z' + }, + 'Seller-optimized allocation goals reject legacy monetary targets' + ); + + await testSchemaValidation( + '/schemas/core/proposal.json', + { + proposal_id: 'prop_shared_001', + name: 'Seller-optimized performance plan', + budget_allocation: { + mode: 'seller_optimized', + optimization_goals: [ + { kind: 'metric', metric: 'clicks' } + ] + }, + pacing: 'even', + allocations: [ + { + product_id: 'prospecting', + min_spend_target_percentage: 20, + max_spend_percentage: 70, + pacing: 'even' + }, + { + product_id: 'retargeting', + max_spend_percentage: 60, + pacing: 'front_loaded' + } + ] + }, + 'Proposal accepts seller-optimized percentage constraints without exact allocations' + ); + + await testSchemaRejection( + '/schemas/core/proposal.json', + { + proposal_id: 'prop_fixed_invalid_001', + name: 'Invalid fixed plan', + allocations: [ + { product_id: 'display_standard' } + ] + }, + 'Proposal keeps allocation_percentage required in fixed mode' + ); + log(''); log('Build Creative Request Schema (push_notification_config field):', 'info');