From 156d1d95d27dbaf52e98cc9f22c9788524743abe Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 5 Aug 2026 04:12:08 +0200 Subject: [PATCH] Unify package targeting resolution --- .changeset/add-placement-selection.md | 5 + docs/media-buy/advanced-topics/targeting.mdx | 43 +-- .../product-discovery/media-products.mdx | 5 +- .../task-reference/create_media_buy.mdx | 8 +- .../task-reference/get_media_buys.mdx | 7 +- .../task-reference/update_media_buy.mdx | 9 +- scripts/error-code-drift-dispositions.json | 5 + .../scenarios/demographic_targeting.yaml | 30 +- .../schemas/source/core/creative-asset.json | 4 +- .../source/core/creative-assignment.json | 4 +- .../core/inventory-targeting-resolution.json | 138 +++++++++ static/schemas/source/core/package.json | 99 ++++++- .../source/core/placement-selection.json | 62 ++++ static/schemas/source/core/product.json | 4 +- .../source/core/targeting-resolution.json | 147 ++++++++++ static/schemas/source/core/targeting.json | 6 +- .../schemas/source/core/x-entity-types.json | 2 +- static/schemas/source/enums/error-code.json | 6 + .../source/enums/media-buy-valid-action.json | 9 +- .../media-buy/get-media-buys-response.json | 98 ++++++- .../source/media-buy/package-request.json | 3 +- .../source/media-buy/package-update.json | 2 +- ...package-status-targeting-overlay-echo.json | 265 ++++++++++++++++-- tests/demographic-targeting.test.cjs | 6 +- ...dia-buy-targeting-overlay-vectors.test.cjs | 20 +- tests/placement-selection-schema.test.cjs | 197 +++++++++++++ 26 files changed, 1095 insertions(+), 89 deletions(-) create mode 100644 .changeset/add-placement-selection.md create mode 100644 static/schemas/source/core/inventory-targeting-resolution.json create mode 100644 static/schemas/source/core/placement-selection.json create mode 100644 static/schemas/source/core/targeting-resolution.json create mode 100644 tests/placement-selection-schema.test.cjs diff --git a/.changeset/add-placement-selection.md b/.changeset/add-placement-selection.md new file mode 100644 index 0000000000..44e2f083d4 --- /dev/null +++ b/.changeset/add-placement-selection.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Unify package targeting intent and applied readback. Placement selection now lives inside `targeting_overlay`, properties/placements/collections resolve jointly through `targeting_resolution.inventory`, demographic execution moves under `targeting_resolution.demographics`, and all targeting axes share a complete applied overlay with exact-equivalence semantics. Adds the `update_placements` lifecycle action and `PLACEMENT_SELECTION_INVALID` recovery contract. diff --git a/docs/media-buy/advanced-topics/targeting.mdx b/docs/media-buy/advanced-topics/targeting.mdx index 407f0efbf4..1489ce3cd8 100644 --- a/docs/media-buy/advanced-topics/targeting.mdx +++ b/docs/media-buy/advanced-topics/targeting.mdx @@ -153,9 +153,11 @@ Multiple intervals or signals may be unioned only when their canonical predicate Age is a registered restricted-attribute category because some campaign plans prohibit its use in regulated contexts. A direct `targeting_overlay.demographics.age` predicate and a signal carrying `demographic_predicate.age` are both age-based targeting for governance evaluation. They are blocked only when the applicable campaign plan lists `age` in `restricted_attributes`; the registry entry does not create a global prohibition on otherwise lawful demographic targeting. -### Exact readback +### Exact targeting readback -Whenever demographic targeting was requested or applied, package state includes `demographic_targeting_resolution`. It preserves both predicates and the execution mechanism: +Package state separates buyer intent from seller-applied targeting. `targeting_overlay` preserves the complete request; `targeting_resolution.applied` reports the complete applied result in the same vocabulary. `equivalent` is always `true` for stored packages—sellers reject silent drift. Specialized evidence lives under the same resolution object: `demographics` records predicate execution, while `inventory` preserves property-placement-collection relationships. + +Whenever demographic targeting was requested or applied, `targeting_resolution.demographics` preserves both predicates and the execution mechanism: ```json { @@ -169,27 +171,36 @@ Whenever demographic targeting was requested or applied, package state includes } } }, - "demographic_targeting_resolution": { - "requested": { - "age": { - "min": 21, - "include_unknown": false, - "accepted_bases": ["verified", "declared"], - "accepted_verification_methods": ["world_id"] - } - }, + "targeting_resolution": { "applied": { - "age": { "min": 21, "include_unknown": false } + "demographics": { + "age": { "min": 21, "include_unknown": false } + } }, "equivalent": true, - "execution": { "type": "continuous_bounds" }, - "applied_bases": ["verified"], - "applied_verification_methods": ["world_id"] + "resolved_at": "2026-09-01T10:00:00Z", + "demographics": { + "requested": { + "age": { + "min": 21, + "include_unknown": false, + "accepted_bases": ["verified", "declared"], + "accepted_verification_methods": ["world_id"] + } + }, + "applied": { + "age": { "min": 21, "include_unknown": false } + }, + "equivalent": true, + "execution": { "type": "continuous_bounds" }, + "applied_bases": ["verified"], + "applied_verification_methods": ["world_id"] + } } } ``` -`targeting_overlay.demographics` preserves the booked predicate and buyer determination constraints. The resolution's `applied` field contains the canonical predicate actually applied; `equivalent` is always `true` for stored packages, and buyers should still recompute predicate equality from `requested` and `applied`. `applied_bases` and `applied_verification_methods` record the effective configured eligibility paths after intersecting buyer constraints, product capability, and age compliance. They do not assert which path every individual impression used. A seller rejects any non-equivalent or unsupported compilation. The same core `Package` schema carries this readback in synchronous `CreateMediaBuySuccess` responses and terminal async completion artifacts; `get_media_buys` exposes the corresponding `PackageStatus` field. Updates use the normal full-overlay replacement semantics and refresh the resolution atomically. +`targeting_overlay.demographics` preserves the requested predicate and buyer determination constraints. `targeting_resolution.applied.demographics` is the canonical applied overlay value, while `targeting_resolution.demographics` carries the lossless execution proof. Buyers should recompute predicate equality from its `requested` and `applied` values. `applied_bases` and `applied_verification_methods` record effective configured eligibility paths; they do not assert which path every impression used. The same Package shape appears on create, update, async completion, and `get_media_buys`. Updates replace the requested overlay and refresh the entire resolution atomically. ## Why Brief-Based Targeting? diff --git a/docs/media-buy/product-discovery/media-products.mdx b/docs/media-buy/product-discovery/media-products.mdx index 0c741a0e03..75093e9df0 100644 --- a/docs/media-buy/product-discovery/media-products.mdx +++ b/docs/media-buy/product-discovery/media-products.mdx @@ -196,7 +196,7 @@ Products can optionally declare specific public ad placements within their inven - **`kind: "seller_inline"`** - Public buyer-facing placement metadata defined inline by the sales agent; requires `name` - **`publisher_domain`** - Domain whose `adagents.json` defines the referenced placement. New multi-publisher products SHOULD include it so the placement namespace is explicit. - **`placement_id`** - Placement ID in the publisher namespace. Buyers reference it with `publisher_domain` in `creative_assignments[].placement_refs`; legacy `placement_ids` strings are only unambiguous in single-publisher contexts. -- **`mode: "targetable"`** - The buyer may reference this publisher-scoped placement when assigning creatives or otherwise selecting placements within the product +- **`mode: "targetable"`** - The buyer may select this publisher-scoped placement in `packages[].targeting_overlay.placement_selection` and route creatives to it with `creative_assignments[].placement_refs` - **`mode: "included"`** - The public placement is part of the product's described composition, but the buyer cannot cherry-pick it by `placement_id` - **`video_placement_types`** - Declared video placement types for OLV and other video placements. Concrete placements usually declare one value; aggregate placements may declare multiple. - **`audio_distribution_types`** - Declared audio distribution types for radio, streaming-audio, podcast, gaming, and other audio placements. Concrete placements usually declare one value; aggregate placements may declare multiple. @@ -205,6 +205,7 @@ Products can optionally declare specific public ad placements within their inven - **Publisher reference rule** - Publisher-referenced product placements resolve to `{publisher_domain, placement_id}` in the publisher's `adagents.json` - **Private inventory rule** - Seller-private delivery objects, ad-server mappings, and source/origin details must stay out of `get_products` - **Creative assignment** - Different creatives can be assigned to targetable placements +- **Purchased placement selection** - `packages[].targeting_overlay.placement_selection` chooses the complete purchased targetable-placement set and is resolved jointly with property and collection targeting; `mode: "included"` placements remain included - **Omitting placement targeting** - Creatives without `placement_refs` or legacy `placement_ids` run on all buyer-targetable placements in the package, and the seller still controls included-only delivery composition - **Use registered IDs when available** - If the publisher declares canonical `placements` in `adagents.json`, product placements SHOULD use the catalog ID as `placement_id` - **Preserve registry semantics** - When a product references a registered placement, it is referring to that same placement. The product may narrow `format_ids` or `format_options`, or add operational detail, but it should not change the placement's meaning incompatibly @@ -1252,7 +1253,7 @@ A buyer agent turns a chosen dimensional row into a buy through the existing buy - `kind: "geo"` rows map to `packages[].targeting_overlay.geo_countries`, `geo_regions`, `geo_metros`, or `geo_postal_areas`, depending on `geo_level` and seller support. Country rows use ISO 3166-1 alpha-2 `geo_code`; region rows use ISO 3166-2 `geo_code`; metro rows include the corresponding targeting `system` enum; native postal rows include `country` plus the country-local `system`. - `kind: "device_type"` and `kind: "device_platform"` rows map to the corresponding targeting overlay fields when the seller supports device targeting. - `kind: "audience"` rows map to `audience_include` or signal targeting only when the audience is selectable for that product; informational audience rows are planning signals, not automatic targeting handles. -- `kind: "placement"` rows map first to product refinement or seller-supported placement targeting for the package. `dimensions[].placement_ref` identifies the inventory slice the forecast row describes; it does not by itself narrow the purchased package and buyers SHOULD NOT treat it as a shortcut for `creative_assignments[].placement_refs`. If the buyer wants to buy only that placement, the product must expose the placement as `mode: "targetable"` or the buyer should request a refined product/proposal. `creative_assignments[].placement_refs` is only the creative-routing surface after the buy's inventory scope is established; it does not by itself narrow the purchased inventory. Proposal-level forecast points with placement dimensions should include `product_id` when the placement maps to one allocation's product; without product context, placement rows on proposal-level forecasts are informational planning rows, not directly executable choices. +- `kind: "placement"` rows map to `packages[].targeting_overlay.placement_selection` when the product exposes the referenced placement as `mode: "targetable"`; otherwise the buyer requests a refined product/proposal. `dimensions[].placement_ref` identifies the inventory slice the forecast row describes but does not by itself narrow the purchased package. Buyers SHOULD NOT treat it as a shortcut for `creative_assignments[].placement_refs`, which remains only the creative-routing surface within the purchased selection. Proposal-level forecast points with placement dimensions should include `product_id` when the placement maps to one allocation's product; without product context, placement rows on proposal-level forecasts are informational planning rows, not directly executable choices. `kind: "signal"` rows use canonical `signal_ref` plus optional `signal_value` to describe a signal bucket. Use `presence: "present"` for rows where the signal is available with the supplied value, and `presence: "absent"` with `signal_value: null` for the explicit not-present bucket. `signal_id` is only a shorthand when the enclosing object already identifies the signal unambiguously, such as a coverage forecast nested directly under a single `get_signals` item. Product-level forecasts use `ForecastPoint.product_id` for product context; do not add a separate product dimension item. diff --git a/docs/media-buy/task-reference/create_media_buy.mdx b/docs/media-buy/task-reference/create_media_buy.mdx index 1297f9525f..22aafe21b5 100644 --- a/docs/media-buy/task-reference/create_media_buy.mdx +++ b/docs/media-buy/task-reference/create_media_buy.mdx @@ -198,7 +198,7 @@ When executing a proposal, `proposal_status` on the returned proposal determines | `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`. | -| `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. | +| `targeting_overlay` | TargetingOverlay | No | Complete buyer-requested targeting (see [Targeting](/docs/media-buy/advanced-topics/targeting)). Property, placement, and collection inventory axes are resolved jointly. Use `placement_selection: {mode: "selected", placement_refs: [...]}` for the complete selected targetable-placement set or `{mode: "default"}` for the product/seller default. For `demographics`, inspect the selected product's `demographic_targeting` declaration first; sellers accept only an exact compilation or reject it. Sellers never silently broaden, narrow, drop, or substitute any targeting axis. | | `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. | | `creative_assignments` | CreativeAssignment[] | No | Assign existing library creatives with optional weights and placement targeting | @@ -208,6 +208,8 @@ 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. | +`targeting_overlay.placement_selection` controls purchased placement inventory, while `creative_assignments[].placement_refs` only routes a creative within that inventory. Product placements with `mode: "included"` remain included and cannot be removed by the buyer. Sellers resolve properties, placements, and collections as one inventory graph: every explicitly selected placement must have at least one eligible property-placement pair after property targeting. Unknown, cross-publisher, duplicate, included-only, property-incompatible, collection-incompatible, or otherwise invalid selections are rejected with `PLACEMENT_SELECTION_INVALID`. A successful create echoes the exact requested `targeting_overlay` and returns the complete applied result in `targeting_resolution`; its `inventory.placements[].property_scope` preserves where each placement can actually run. + ## Response ### Success Response @@ -219,7 +221,7 @@ When executing a proposal, `proposal_status` on the returned proposal determines | `confirmed_at` | ISO 8601 timestamp when the seller committed to the media buy. Stable after it is set. May be `null` in deferred/manual approval flows until seller commitment occurs. | | `creative_deadline` | ISO 8601 timestamp for creative upload deadline | | `revision` | Initial media-buy revision. Use this value as the `revision` token on the next `update_media_buy` call intended to change state. | -| `packages` | Array of created packages with complete state. Packages may include per-package `creative_deadline` when different from the media buy deadline, and SHOULD echo every format selector field supplied on create (`format_option_refs`, `format_ids`, and/or `format_kind`/`params`) so read surfaces are lossless even when one selector wins precedence. | +| `packages` | Array of created packages with complete state. Packages may include per-package `creative_deadline`, SHOULD echo every supplied format selector, and MUST echo the requested `targeting_overlay` plus seller-confirmed `targeting_resolution` whenever targeting was requested. | `confirmed_at` is seller commitment time, not a delivery-status timestamp. Do not update it when a buy later pauses, resumes, starts delivery, completes, or reports performance. A committed synchronous create stamps it immediately. Use the `submitted` response branch when no `media_buy_id` is being returned to the buyer. Sellers MAY instead return synchronous success with `media_buy_id`, `packages`, and `confirmed_at: null` for a provisional buy; such buys MUST be retrievable via `get_media_buys` and MUST transition by setting `confirmed_at` exactly once on commitment. A provisional buy with `confirmed_at: null` MUST NOT be `active` and MUST NOT include `packages[].committed_metrics`. @@ -1023,6 +1025,8 @@ Common errors and resolutions: | `TARGETING_TOO_NARROW` | Targeting yields zero inventory | Broaden geographic or audience criteria | | `POLICY_VIOLATION` | Brand/product violates policy | Review publisher's content policies | | `INVALID_PRICING_OPTION` | pricing_option_id not found | Use ID from product's `pricing_options` | +| `PLACEMENT_SELECTION_INVALID` | Placement identity, selectability, combination, or creative-routing consistency is invalid | Branch on `error.details.reason`; choose a valid complete set from the product's targetable placements or use `mode: "default"` | +| `REQUOTE_REQUIRED` | The placement set is valid but changes the product's priced envelope | Re-discover products or choose a replacement product/package with terms covering the requested placement set | | `CREATIVE_ID_EXISTS` | Creative ID already exists in the seller's creative namespace | For library-backed sellers, assign existing creatives via `creative_assignments` or update via `sync_creatives`; for inline-only sellers, use a different package-scoped `creative_id` | Example error response: diff --git a/docs/media-buy/task-reference/get_media_buys.mdx b/docs/media-buy/task-reference/get_media_buys.mdx index 8c002d900b..af39bca647 100644 --- a/docs/media-buy/task-reference/get_media_buys.mdx +++ b/docs/media-buy/task-reference/get_media_buys.mdx @@ -67,6 +67,7 @@ Returns an array of media buys with current status, creative approval state, and | `cancellation` | Cancellation metadata (present only when `status` is `canceled`). Object with `canceled_at` (ISO 8601), `canceled_by` (`"buyer"` or `"seller"`), and optional `reason`. | | `revision` | Current revision number. Pass in `update_media_buy` for optimistic concurrency. | | `valid_actions` | Actions the buyer can perform in the current state (e.g., `["pause", "cancel", "update_budget"]`). See [valid actions mapping](#valid-actions-mapping). | +| `available_actions` | Authoritative structured actions available on this buy, including mode and optional SLA/terms metadata. `update_placements` authorizes `packages[].targeting_overlay.placement_selection` changes. | | `history` | Revision history entries, most recent first. Only present when `include_history > 0`. Append-only — entries are never modified or deleted. | | `webhook_activity` | Recent reporting and health webhook fires for the calling principal, most-recent first. Only present when `include_webhook_activity` is true AND the seller surfaces fire history for this buy. See [Webhook activity](#webhook-activity) for three-state presence semantics. | | `context` | Opaque media-buy-level correlation data echoed unchanged from `create_media_buy`. Sellers MUST include persisted context when the media buy was created through AdCP with context, and MAY omit it for media buys created outside AdCP or without context. Use it to reconcile `media_buy_id` with buyer tracking state. | @@ -84,8 +85,8 @@ Returns an array of media buys with current status, creative approval state, and | `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. | | `params` | Parameters for the direct canonical selector in `format_kind`, echoed whenever the original request included them; requires `format_kind`. | -| `targeting_overlay` | Targeting currently applied to the package. Demographic targeting preserves the buyer's age predicate and accepted determination bases. | -| `demographic_targeting_resolution` | Lossless demographic readback containing `requested`, `applied`, exact-only `equivalent: true`, execution mechanism, and effective `applied_bases` / `applied_verification_methods` when the buyer constrained determination. Required whenever demographics were requested or applied. Sellers reject non-equivalent predicates, unsupported bases, and empty compliance intersections. | +| `targeting_overlay` | Complete buyer-requested targeting preserved from create or the latest full-overlay update. This is intent, not provider-effective state. | +| `targeting_resolution` | Complete seller-applied targeting. `applied` uses the same canonical vocabulary and `equivalent` is always true for stored packages. `inventory` jointly resolves properties, placements, and collections; `demographics` contains lossless predicate, execution, basis, and verification detail. | | `start_time` | Flight start time (ISO 8601). Check this before interpreting delivery status. | | `end_time` | Flight end time (ISO 8601) | | `paused` | Whether buyer has paused this package | @@ -335,6 +336,8 @@ The `valid_actions` array tells agents what operations are permitted on a media Sellers MAY omit actions based on business rules (e.g., omit `cancel` when the media buy has contractual obligations that prevent cancellation). +The flat `valid_actions` values above are the legacy compatibility view. Consumers MUST prefer `available_actions[]` when present. A package placement change is permitted only when that authoritative array contains `action: "update_placements"`; otherwise the seller rejects with `ACTION_NOT_ALLOWED`. + For creative changes, `sync_creatives` in `valid_actions` is a legacy creative-change action label, not proof that the `sync_creatives` task exists. 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. ## Common Scenarios diff --git a/docs/media-buy/task-reference/update_media_buy.mdx b/docs/media-buy/task-reference/update_media_buy.mdx index 77fa8f9a87..2e149e568d 100644 --- a/docs/media-buy/task-reference/update_media_buy.mdx +++ b/docs/media-buy/task-reference/update_media_buy.mdx @@ -37,6 +37,7 @@ The mapping is normative — sellers and SDKs MUST use this table to translate b 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_targeting` | `packages[].targeting_overlay`, `packages[].keyword_targets_add`, `packages[].keyword_targets_remove`, `packages[].negative_keywords_add`, `packages[].negative_keywords_remove` | | +| `update_placements` | `packages[].targeting_overlay.placement_selection` | Replaces the complete buyer-selected targetable-placement set as part of the atomic full-overlay replacement; `mode: "default"` restores the product or seller default | | `update_pacing` | `packages[].pacing` | | | `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 | @@ -239,7 +240,7 @@ Configure automated delivery reporting for this media buy: | `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. | -| `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. | +| `targeting_overlay` | TargetingOverlay | Complete targeting replacement. Omit the field to leave all targeting unchanged; when present, omitted nested axes are removed or restored to their product defaults. `placement_selection` changes require `update_placements` in `available_actions[]` and are validated jointly with property and collection targeting. Demographic changes compile against the product's current capability. The response echoes requested intent and refreshes `targeting_resolution` atomically. Unsupported, inexact, or empty intersections are rejected rather than silently adjusted. Priced changes may return `REQUOTE_REQUIRED`. | | `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`. | | `keyword_targets_remove` | KeywordTarget[] | Keyword targets to remove by (keyword, match_type) identity. | @@ -250,6 +251,8 @@ Configure automated delivery reporting for this media buy: `package_id` is required to identify the package to update. +Placement changes are atomic with the rest of the targeting overlay and use the request's normal revision and idempotency rules. Every selected reference must resolve to a `mode: "targetable"` placement on the package product, with `publisher_domain` present, and retain at least one eligible property-placement pair after property and collection targeting. `mode: "included"` placements remain included. Unknown, cross-publisher, duplicate, included-only, property-incompatible, collection-incompatible, or invalid combinations are rejected with `PLACEMENT_SELECTION_INVALID`. If narrowing would orphan an existing creative route, the seller rejects unless the same request supplies compatible replacement assignments. A valid selection that changes priced terms is rejected with `REQUOTE_REQUIRED`. + 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. ## Response @@ -266,7 +269,7 @@ For how `budget`, `pacing`, `impressions`, and flight dates interact — and the | `invoice_recipient` | Updated invoice recipient, echoed from request when provided. Confirms the seller accepted the billing override. Bank details are omitted (write-only). | | `valid_actions` | Flat-vocabulary actions the buyer can perform after this update. Saves a round-trip to `get_media_buys`. Deprecated in favor of `available_actions[]` and removed in 4.0. | | `available_actions` | Structured per-buy resolution of actions available after this update, each entry with action, resolved `mode`, optional `sla`, optional `terms_ref`. Authoritative — buyer SDKs SHOULD prefer this over `valid_actions` when both are present. | -| `affected_packages` | For package-level updates, array of full Package objects showing complete post-update state for each directly modified package. This is a state snapshot, not a sparse delta: sellers MUST NOT return `{ package_id }`-only stubs. Campaign-level updates that do not modify packages may return an empty array. | +| `affected_packages` | Full post-update Package state for each directly modified package. Sellers MUST return the requested `targeting_overlay` and complete applied `targeting_resolution`, including joint inventory and demographic detail when applicable; `{package_id}`-only stubs are invalid. | ### Error Response @@ -743,6 +746,8 @@ Common errors and resolutions: | `CREATIVE_ID_EXISTS` | Creative ID already exists in the seller's creative namespace | For library-backed sellers, assign existing creatives via `creative_assignments` or update via `sync_creatives`; for inline-only sellers, use a different package-scoped `creative_id` | | `BUDGET_EXCEEDED` | Operation would exceed allocated budget | Reduce the amount or increase media buy total budget | | `CONFLICT` | Revision mismatch — another update was applied since you last read | Re-read via `get_media_buys` and retry with current `revision` | +| `ACTION_NOT_ALLOWED` | The buy does not currently advertise the action mapped from the requested field, including `update_placements` for `packages[].targeting_overlay.placement_selection` | Inspect `error.details.currently_available_actions`; switch to the advertised flow or choose a different product/package | +| `PLACEMENT_SELECTION_INVALID` | Placement identity, selectability, combination, or post-update creative routing is invalid | Branch on `error.details.reason`; choose a valid complete set from the product's targetable placements, update creative assignments atomically, or restore `mode: "default"` | | `REQUOTE_REQUIRED` | Requested change (budget, dates, volume, targeting, or signal targeting price) falls outside the envelope the original quote was priced against | Adjust the update to fit the current quote, rediscover products/terms, add packages when `add_packages` is available, or create a separate media buy. 3.1 does not define an amendment-quote artifact for `update_media_buy`. Seller's `error.details.envelope_field` names which fields breached. | | `VALIDATION_ERROR` | Request format or business rule violation | Check error `field` and `message` for specifics | diff --git a/scripts/error-code-drift-dispositions.json b/scripts/error-code-drift-dispositions.json index f3f92291b3..cfed34dde9 100644 --- a/scripts/error-code-drift-dispositions.json +++ b/scripts/error-code-drift-dispositions.json @@ -51,6 +51,11 @@ "target_version": "3.1", "note": "Pairs with allowed_actions[] / available_actions[] surface (#4480). Returned when an update_media_buy mutation maps to an action not currently available on the buy; carries structured error.details (attempted_action, reason, currently_available_actions) so buyer SDKs can offer recovery without re-fetching. Wire change — held for 3.1." }, + "PLACEMENT_SELECTION_INVALID": { + "disposition": "held-for-next-minor", + "target_version": "3.2", + "note": "Purchased-placement selection (#6132) — distinguishes invalid placement identity, selectability, combinations, and orphaned creative routes from unsupported actions and price-envelope requotes. New 3.2 media-buy wire code." + }, "AUTH_INVALID": { "disposition": "held-for-next-minor", "target_version": "3.1", diff --git a/static/compliance/source/protocols/media-buy/scenarios/demographic_targeting.yaml b/static/compliance/source/protocols/media-buy/scenarios/demographic_targeting.yaml index 919437d84a..f73c4d1b9c 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/demographic_targeting.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/demographic_targeting.yaml @@ -258,27 +258,27 @@ phases: - check: response_schema description: "Response matches get-media-buys-response.json" - check: field_value - path: "media_buys[0].packages[0].demographic_targeting_resolution.equivalent" + path: "media_buys[0].packages[0].targeting_resolution.demographics.equivalent" value: true description: "Ages 21–35 were applied exactly" - check: field_value - path: "media_buys[0].packages[0].demographic_targeting_resolution.execution.type" + path: "media_buys[0].packages[0].targeting_resolution.demographics.execution.type" value: "continuous_bounds" description: "Readback names the execution mechanism" - check: field_value - path: "media_buys[0].packages[1].demographic_targeting_resolution.applied.age.include_unknown" + path: "media_buys[0].packages[1].targeting_resolution.demographics.applied.age.include_unknown" value: true description: "Unknown-age inclusion is preserved explicitly" - check: field_value - path: "media_buys[0].packages[1].demographic_targeting_resolution.requested.age.min" + path: "media_buys[0].packages[1].targeting_resolution.demographics.requested.age.min" value: 65 description: "Open-upper request preserves its inclusive lower bound" - check: field_value - path: "media_buys[0].packages[1].demographic_targeting_resolution.applied.age.min" + path: "media_buys[0].packages[1].targeting_resolution.demographics.applied.age.min" value: 65 description: "Applied predicate remains 65+" - check: field_absent - path: "media_buys[0].packages[1].demographic_targeting_resolution.applied.age.max" + path: "media_buys[0].packages[1].targeting_resolution.demographics.applied.age.max" description: "Seller does not narrow the open upper bound to max_supported_age" - id: execute_exact_interval_union @@ -344,27 +344,27 @@ phases: - check: response_schema description: "Response matches get-media-buys-response.json" - check: field_value - path: "media_buys[0].packages[0].demographic_targeting_resolution.equivalent" + path: "media_buys[0].packages[0].targeting_resolution.demographics.equivalent" value: true description: "Bucket union is exactly equal to the buyer request" - check: field_value - path: "media_buys[0].packages[0].demographic_targeting_resolution.applied.age.min" + path: "media_buys[0].packages[0].targeting_resolution.demographics.applied.age.min" value: 18 description: "Applied union starts at age 18" - check: field_value - path: "media_buys[0].packages[0].demographic_targeting_resolution.applied.age.max" + path: "media_buys[0].packages[0].targeting_resolution.demographics.applied.age.max" value: 34 description: "Applied union ends at age 34" - check: field_value - path: "media_buys[0].packages[0].demographic_targeting_resolution.execution.type" + path: "media_buys[0].packages[0].targeting_resolution.demographics.execution.type" value: "enumerated_intervals" description: "Readback identifies interval execution" - check: field_contains - path: "media_buys[0].packages[0].demographic_targeting_resolution.execution.interval_ids" + path: "media_buys[0].packages[0].targeting_resolution.demographics.execution.interval_ids" value: "age_18_24" description: "Readback includes the first contributing interval" - check: field_contains - path: "media_buys[0].packages[0].demographic_targeting_resolution.execution.interval_ids" + path: "media_buys[0].packages[0].targeting_resolution.demographics.execution.interval_ids" value: "age_25_34" description: "Readback includes the second contributing interval" @@ -602,14 +602,14 @@ phases: - check: response_schema description: "Response matches get-media-buys-response.json" - check: field_value - path: "media_buys[0].packages[0].demographic_targeting_resolution.equivalent" + path: "media_buys[0].packages[0].targeting_resolution.demographics.equivalent" value: true description: "Signal predicate exactly matches buyer intent" - check: field_value - path: "media_buys[0].packages[0].demographic_targeting_resolution.execution.type" + path: "media_buys[0].packages[0].targeting_resolution.demographics.execution.type" value: "signals" description: "Readback identifies signal-backed execution" - check: field_value - path: "media_buys[0].packages[0].demographic_targeting_resolution.execution.signal_refs[0].signal_id" + path: "media_buys[0].packages[0].targeting_resolution.demographics.execution.signal_refs[0].signal_id" value: "adults_25_34" description: "Readback carries the authoritative signal identity" diff --git a/static/schemas/source/core/creative-asset.json b/static/schemas/source/core/creative-asset.json index 10dd7f1764..c6ee014332 100644 --- a/static/schemas/source/core/creative-asset.json +++ b/static/schemas/source/core/creative-asset.json @@ -90,7 +90,7 @@ }, "placement_refs": { "type": "array", - "description": "Optional structured placement references where this uploaded creative should run when uploading via create_media_buy or update_media_buy. New senders SHOULD use this field for placement-level targeting because placement IDs are publisher-scoped. References product placements by `{ publisher_domain, placement_id }`. If omitted, creative runs on all buyer-targetable placements. If both `placement_refs` and legacy `placement_ids` are present, `placement_refs` wins and receivers MUST ignore `placement_ids`. Only used during upload to media buy - not stored in creative library.", + "description": "Optional structured placement references where this uploaded creative should run when uploading via create_media_buy or update_media_buy. New senders SHOULD use this field for placement-level creative routing because placement IDs are publisher-scoped. References product placements by `{ publisher_domain, placement_id }` within targeting_overlay.placement_selection and never changes purchased inventory. If omitted, creative runs on all buyer-targetable placements in targeting_resolution.inventory. If both `placement_refs` and legacy `placement_ids` are present, `placement_refs` wins and receivers MUST ignore `placement_ids`. Only used during upload to media buy - not stored in creative library.", "items": { "$ref": "/schemas/core/placement-ref.json" }, @@ -98,7 +98,7 @@ }, "placement_ids": { "type": "array", - "description": "Legacy shorthand array of placement IDs where this creative should run when uploading via create_media_buy or update_media_buy. New senders SHOULD use `placement_refs` because placement IDs are publisher-scoped and strings are ambiguous in multi-publisher products. If omitted, creative runs on all buyer-targetable placements. If `placement_refs` is also present, receivers MUST ignore this field. Only used during upload to media buy - not stored in creative library.", + "description": "Legacy shorthand array of placement IDs where this creative should run when uploading via create_media_buy or update_media_buy. New senders SHOULD use `placement_refs` because placement IDs are publisher-scoped and strings are ambiguous in multi-publisher products. This routes the creative within targeting_overlay.placement_selection and never changes purchased inventory. If omitted, creative runs on all buyer-targetable placements in targeting_resolution.inventory. If `placement_refs` is also present, receivers MUST ignore this field. Only used during upload to media buy - not stored in creative library.", "items": { "type": "string" }, diff --git a/static/schemas/source/core/creative-assignment.json b/static/schemas/source/core/creative-assignment.json index 48509c7083..f6fb1cdb6b 100644 --- a/static/schemas/source/core/creative-assignment.json +++ b/static/schemas/source/core/creative-assignment.json @@ -18,7 +18,7 @@ }, "placement_refs": { "type": "array", - "description": "Optional array of structured placement references where this creative should run within the already-purchased package inventory. New senders SHOULD use this field for placement-level creative routing because placement IDs are publisher-scoped. This field does not narrow the purchased package inventory by itself; use product refinement or seller-supported package targeting to buy only one placement. When omitted, the creative runs on all buyer-targetable placements in the package. References entries from the product's `placements[]` array by `{ publisher_domain, placement_id }`; if `publisher_domain` is omitted in the ref, receivers MAY interpret it relative to the seller agent's own publisher domain in legacy single-publisher contexts. If both `placement_refs` and legacy `placement_ids` are present, `placement_refs` wins and receivers MUST ignore `placement_ids`.", + "description": "Optional array of structured placement references where this creative should run within the already-purchased package inventory. New senders SHOULD use this field for placement-level creative routing because placement IDs are publisher-scoped. This field does not narrow purchased inventory; use targeting_overlay.placement_selection together with property and collection targeting. When omitted, the creative runs on all buyer-targetable placements in targeting_resolution.inventory. References entries from the product's `placements[]` array by `{ publisher_domain, placement_id }`; if `publisher_domain` is omitted in the ref, receivers MAY interpret it relative to the seller agent's own publisher domain in legacy single-publisher contexts. If both `placement_refs` and legacy `placement_ids` are present, `placement_refs` wins and receivers MUST ignore `placement_ids`.", "items": { "$ref": "/schemas/core/placement-ref.json" }, @@ -26,7 +26,7 @@ }, "placement_ids": { "type": "array", - "description": "Legacy shorthand array of placement IDs where this creative should run within the already-purchased package inventory. New senders SHOULD use `placement_refs` because placement IDs are publisher-scoped and strings are ambiguous in multi-publisher products. This field does not narrow the purchased package inventory by itself; use product refinement or seller-supported package targeting to buy only one placement. When omitted, the creative runs on all buyer-targetable placements in the package. Receivers MAY interpret string IDs relative to the seller agent's own publisher domain in legacy single-publisher contexts. If `placement_refs` is also present, receivers MUST ignore this field.", + "description": "Legacy shorthand array of placement IDs where this creative should run within the already-purchased package inventory. New senders SHOULD use `placement_refs` because placement IDs are publisher-scoped and strings are ambiguous in multi-publisher products. This field does not narrow purchased inventory; use targeting_overlay.placement_selection together with property and collection targeting. When omitted, the creative runs on all buyer-targetable placements in targeting_resolution.inventory. Receivers MAY interpret string IDs relative to the seller agent's own publisher domain in legacy single-publisher contexts. If `placement_refs` is also present, receivers MUST ignore this field.", "items": { "type": "string" }, diff --git a/static/schemas/source/core/inventory-targeting-resolution.json b/static/schemas/source/core/inventory-targeting-resolution.json new file mode 100644 index 0000000000..d9cc6e5b5d --- /dev/null +++ b/static/schemas/source/core/inventory-targeting-resolution.json @@ -0,0 +1,138 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/inventory-targeting-resolution.json", + "title": "Inventory Targeting Resolution", + "description": "Seller-confirmed effective inventory scope after jointly resolving the product's publisher_properties, placements, and collections against the buyer's property, placement, and collection targeting. These axes are not independent: each resolved placement carries the property and optional collection scope where it can actually run. Sellers MUST reject an empty effective scope or any explicitly selected placement with no eligible property-placement pair rather than silently dropping a property, placement, or collection selection.", + "type": "object", + "properties": { + "properties": { + "type": "array", + "description": "Effective non-empty property scope after intersecting product publisher_properties with property_list and subtracting property_list_exclude. Selectors keep large all/tag-based scopes compact; by_id may enumerate a bounded resolved set.", + "items": { + "$ref": "/schemas/core/publisher-property-selector.json" + }, + "minItems": 1 + }, + "placements": { + "type": "array", + "description": "Effective placements and the inventory scope on which each can run. Every buyer-selected placement MUST appear exactly once. Product-included placements appear with selection_source: included. A seller-default placement appears with selection_source: default when it was not explicitly selected.", + "items": { + "type": "object", + "properties": { + "placement_ref": { + "allOf": [ + { + "$ref": "/schemas/core/placement-ref.json" + }, + { + "required": [ + "publisher_domain" + ] + } + ] + }, + "selection_source": { + "type": "string", + "enum": [ + "selected", + "default", + "included" + ], + "description": "Why this placement is in the effective package inventory." + }, + "property_scope": { + "type": "array", + "description": "Non-empty subset of properties on which this placement can run after product, publisher-catalog, authorization, and property-targeting validation. Every selector MUST address the same publisher_domain as placement_ref and be a subset of top-level properties.", + "items": { + "$ref": "/schemas/core/publisher-property-selector.json" + }, + "minItems": 1 + }, + "collection_scope": { + "type": "array", + "description": "Optional effective collection scope for this placement after collection targeting. Every entry MUST be a subset of top-level collections.", + "items": { + "$ref": "/schemas/core/collection-selector.json" + }, + "minItems": 1 + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "required": [ + "placement_ref", + "selection_source", + "property_scope" + ], + "additionalProperties": false + }, + "minItems": 1 + }, + "collections": { + "type": "array", + "description": "Effective collection scope after intersecting product collections with collection_list and subtracting collection_list_exclude.", + "items": { + "$ref": "/schemas/core/collection-selector.json" + }, + "minItems": 1 + }, + "list_snapshots": { + "type": "array", + "description": "Resolution evidence for external property or collection lists. This deliberately omits auth_token even when the request-side list reference carried one.", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "property_include", + "property_exclude", + "collection_include", + "collection_exclude" + ] + }, + "agent_url": { + "type": "string", + "format": "uri" + }, + "list_id": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "description": "List-provider version, ETag, content digest, or other stable snapshot identifier when exposed by the list agent." + }, + "resolved_at": { + "type": "string", + "format": "date-time" + }, + "matched_count": { + "type": "integer", + "minimum": 0 + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "required": [ + "kind", + "agent_url", + "list_id", + "resolved_at", + "matched_count" + ], + "additionalProperties": false + }, + "minItems": 1 + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "required": [ + "properties" + ], + "additionalProperties": false +} diff --git a/static/schemas/source/core/package.json b/static/schemas/source/core/package.json index 26e7f9c234..048b727aa1 100644 --- a/static/schemas/source/core/package.json +++ b/static/schemas/source/core/package.json @@ -74,11 +74,12 @@ "additionalProperties": true }, "targeting_overlay": { - "$ref": "/schemas/core/targeting.json" + "$ref": "/schemas/core/targeting.json", + "description": "Complete buyer-requested targeting intent persisted for this package. Sellers MUST echo it after create, update, and subsequent reads. This is requested state; targeting_resolution carries the complete applied result." }, - "demographic_targeting_resolution": { - "$ref": "/schemas/core/demographic-targeting-resolution.json", - "description": "Requested-versus-applied demographic targeting and the seller execution mechanism. Sellers MUST include this whenever demographic targeting was requested or applied." + "targeting_resolution": { + "$ref": "/schemas/core/targeting-resolution.json", + "description": "Complete seller-confirmed applied targeting. Required whenever targeting_overlay is present and also emitted when the seller applied targetable defaults without an explicit buyer overlay. Includes specialized demographic execution evidence and joint property-placement-collection inventory resolution when applicable." }, "measurement_terms": { "$ref": "/schemas/core/measurement-terms.json", @@ -201,7 +202,95 @@ "package_id" ], "dependencies": { - "params": ["format_kind"] + "params": ["format_kind"], + "targeting_overlay": ["targeting_resolution"] }, + "allOf": [ + { + "if": { + "properties": { + "targeting_overlay": { + "required": ["demographics"] + } + }, + "required": ["targeting_overlay"] + }, + "then": { + "properties": { + "targeting_resolution": { + "required": ["demographics"] + } + } + } + }, + { + "if": { + "properties": { + "targeting_overlay": { + "anyOf": [ + { "required": ["property_list"] }, + { "required": ["property_list_exclude"] }, + { "required": ["collection_list"] }, + { "required": ["collection_list_exclude"] }, + { "required": ["placement_selection"] } + ] + } + }, + "required": ["targeting_overlay"] + }, + "then": { + "properties": { + "targeting_resolution": { + "required": ["inventory"] + } + } + } + }, + { + "if": { + "properties": { + "targeting_overlay": { + "required": ["placement_selection"] + } + }, + "required": ["targeting_overlay"] + }, + "then": { + "properties": { + "targeting_resolution": { + "properties": { + "inventory": { + "required": ["placements"] + } + } + } + } + } + }, + { + "if": { + "properties": { + "targeting_overlay": { + "anyOf": [ + { "required": ["collection_list"] }, + { "required": ["collection_list_exclude"] } + ] + } + }, + "required": ["targeting_overlay"] + }, + "then": { + "properties": { + "targeting_resolution": { + "properties": { + "inventory": { + "required": ["collections"] + } + } + } + } + } + } + ], "additionalProperties": true } diff --git a/static/schemas/source/core/placement-selection.json b/static/schemas/source/core/placement-selection.json new file mode 100644 index 0000000000..cb1b156d1f --- /dev/null +++ b/static/schemas/source/core/placement-selection.json @@ -0,0 +1,62 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/placement-selection.json", + "title": "Placement Selection", + "description": "The buyer-selected targetable placement set inside a package targeting_overlay. It is resolved jointly with property and collection targeting. Creative assignment placement_refs route only within the applied placement selection and never change it. Sellers validate the selection against the package product and effective inventory scope and reject invalid or silently adjusted selections.", + "type": "object", + "oneOf": [ + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "selected", + "description": "Use the complete buyer-selected set in placement_refs. This has replacement semantics, not add/remove semantics." + }, + "placement_refs": { + "type": "array", + "description": "The complete selected set of targetable placements. Every reference MUST match a placement on the package product whose mode is targetable. publisher_domain is required here even though legacy creative-routing PlacementRef inputs may omit it. Unknown, cross-publisher, duplicate, included-only, or seller-incompatible references are rejected with PLACEMENT_SELECTION_INVALID. Product placements whose mode is included remain part of the purchased package and are not listed here.", + "items": { + "allOf": [ + { + "$ref": "/schemas/core/placement-ref.json" + }, + { + "required": [ + "publisher_domain" + ] + } + ] + }, + "minItems": 1, + "uniqueItems": true + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "required": [ + "mode", + "placement_refs" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "default", + "description": "Request the product or seller default targetable placement set. Package targeting_overlay echoes mode: default as requested; targeting_resolution.applied SHOULD expand the actual refs as mode: selected when enumerable, and targeting_resolution.inventory records the effective property-placement mapping." + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "required": [ + "mode" + ], + "additionalProperties": false + } + ] +} diff --git a/static/schemas/source/core/product.json b/static/schemas/source/core/product.json index 1dbeacd743..ddbeb69093 100644 --- a/static/schemas/source/core/product.json +++ b/static/schemas/source/core/product.json @@ -199,7 +199,7 @@ }, "signal_targeting_options": { "type": "array", - "description": "Inline seller-offered signals that may be applied to packages for this product at create_media_buy time. Each entry references a named signal definition with signal_ref scope 'product' for a product-local signal option, scope 'data_provider' for an external signal definition published in adagents.json signals[] that the seller is authorized to apply, or scope 'signal_source' for a source-native signal. Product-local options define name and value_type inline; data-provider and signal-source options may omit those fields when the referenced definition or source is authoritative. Use this field when the selectable menu is product-specific, has product-specific pricing or activation handles, is the relevant subset for a brief/refine result, or should be rendered without an additional get_signals call. Wholesale products may omit this field and rely on get_signals for the selectable signal feed. Buyers select eligible signals through packages[].targeting_overlay.signal_targeting_groups when signal_targeting_rules allow; fixed/default entries are applied by the seller and echoed on the package state. Sellers MUST set signal_targeting_allowed to true whenever this field is present. Bundled, non-selectable signal metadata belongs in included_signals; legacy data_provider_signals may appear only for backwards compatibility.", + "description": "Inline seller-offered signals that may be applied to packages for this product at create_media_buy time. Each entry references a named signal definition with signal_ref scope 'product' for a product-local signal option, scope 'data_provider' for an external signal definition published in adagents.json signals[] that the seller is authorized to apply, or scope 'signal_source' for a source-native signal. Product-local options define name and value_type inline; data-provider and signal-source options may omit those fields when the referenced definition or source is authoritative. Use this field when the selectable menu is product-specific, has product-specific pricing or activation handles, is the relevant subset for a brief/refine result, or should be rendered without an additional get_signals call. Wholesale products may omit this field and rely on get_signals for the selectable signal feed. Buyers select eligible signals through packages[].targeting_overlay.signal_targeting_groups when signal_targeting_rules allow; fixed/default entries are applied by the seller and emitted under packages[].targeting_resolution.applied.signal_targeting_groups. Sellers MUST set signal_targeting_allowed to true whenever this field is present. Bundled, non-selectable signal metadata belongs in included_signals; legacy data_provider_signals may appear only for backwards compatibility.", "items": { "$ref": "/schemas/core/product-signal-targeting-option.json" }, @@ -212,7 +212,7 @@ "signal_targeting_allowed": { "type": "boolean", "default": false, - "description": "Whether this product has a package-level signal_targeting_groups surface. When false (default), signals are bundled into the product terms and cannot be selected or explicitly echoed as package signal groups. When true, eligible signals from inline signal_targeting_options or from get_signals may be buyer-selected or seller-applied according to signal_targeting_rules and are represented through packages[].targeting_overlay.signal_targeting_groups. Editability is controlled by signal_targeting_rules; fixed/default-only products still set this to true when applied signal groups are echoed." + "description": "Whether this product has a package-level signal_targeting_groups surface. When false (default), signals are bundled into the product terms and cannot be selected or explicitly reported as package signal groups. When true, buyer-requested groups live in packages[].targeting_overlay.signal_targeting_groups and the complete seller-applied groups live in packages[].targeting_resolution.applied.signal_targeting_groups. Editability is controlled by signal_targeting_rules; fixed/default-only products still set this to true so the applied groups can be reported." }, "demographic_targeting": { "$ref": "/schemas/core/demographic-targeting-capability.json", diff --git a/static/schemas/source/core/targeting-resolution.json b/static/schemas/source/core/targeting-resolution.json new file mode 100644 index 0000000000..f8afa3cfb2 --- /dev/null +++ b/static/schemas/source/core/targeting-resolution.json @@ -0,0 +1,147 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/targeting-resolution.json", + "title": "Targeting Resolution", + "description": "Seller-confirmed applied targeting for a stored package. targeting_overlay is the complete buyer-requested intent; this object is the complete applied result. Sellers MUST reject unsupported or non-equivalent targeting rather than silently broadening, narrowing, dropping, or substituting an axis. Shape differences are allowed only when they are provably equivalent, such as expanding a requested default into explicit effective placement references.", + "type": "object", + "properties": { + "applied": { + "$ref": "/schemas/core/targeting.json", + "description": "Complete targeting overlay actually applied by the seller, expressed in the same canonical vocabulary as the request. Seller-applied defaults SHOULD be expanded here when they can be enumerated." + }, + "equivalent": { + "type": "boolean", + "const": true, + "description": "Always true for stored package state. The requested targeting_overlay and applied targeting MUST denote the same eligible set; sellers reject non-equivalent requests." + }, + "resolved_at": { + "type": "string", + "format": "date-time", + "description": "When the seller resolved the applied targeting and any referenced external lists or catalogs." + }, + "inventory": { + "$ref": "/schemas/core/inventory-targeting-resolution.json", + "description": "Joint property, placement, and collection resolution. Required whenever any of those inventory axes were requested or applied." + }, + "demographics": { + "$ref": "/schemas/core/demographic-targeting-resolution.json", + "description": "Lossless demographic predicate and execution evidence. Required whenever demographic targeting was requested or applied. Its requested value MUST equal targeting_overlay.demographics, and its applied predicate MUST agree with applied.demographics." + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "required": [ + "applied", + "equivalent", + "resolved_at" + ], + "allOf": [ + { + "if": { + "properties": { + "applied": { + "required": [ + "demographics" + ] + } + } + }, + "then": { + "required": [ + "demographics" + ] + } + }, + { + "if": { + "properties": { + "applied": { + "anyOf": [ + { + "required": [ + "property_list" + ] + }, + { + "required": [ + "property_list_exclude" + ] + }, + { + "required": [ + "collection_list" + ] + }, + { + "required": [ + "collection_list_exclude" + ] + }, + { + "required": [ + "placement_selection" + ] + } + ] + } + } + }, + "then": { + "required": [ + "inventory" + ] + } + }, + { + "if": { + "properties": { + "applied": { + "required": [ + "placement_selection" + ] + } + } + }, + "then": { + "properties": { + "inventory": { + "required": [ + "placements" + ] + } + } + } + }, + { + "if": { + "properties": { + "applied": { + "anyOf": [ + { + "required": [ + "collection_list" + ] + }, + { + "required": [ + "collection_list_exclude" + ] + } + ] + } + } + }, + "then": { + "properties": { + "inventory": { + "required": [ + "collections" + ] + } + } + } + } + ], + "additionalProperties": true +} diff --git a/static/schemas/source/core/targeting.json b/static/schemas/source/core/targeting.json index 2d1bb5edc0..7b515529a6 100644 --- a/static/schemas/source/core/targeting.json +++ b/static/schemas/source/core/targeting.json @@ -141,7 +141,7 @@ }, "signal_targeting_groups": { "$ref": "/schemas/core/package-signal-targeting-groups.json", - "description": "Basic Boolean grouping for seller-offered signals. v1 supports a required top-level operator 'all' and child groups with operator 'any' for include groups or 'none' for exclusion groups. Example semantics: group 1 any(A, B) plus group 2 none(C, D) means (A OR B) AND NOT (C OR D). Signal entries reference named signal definitions with signal_ref scope 'product' for product-local signal options or scope 'data_provider' for external signals published in adagents.json signals[]. For simple include-only targeting, send one child group with operator 'any'. Sellers SHOULD reject entries that are not available for the product through inline signal_targeting_options or get_signals, are not active for the account, or exceed the product's signal_targeting_allowed/signal_targeting_rules/product terms. Signal targeting limits are product-scoped, not declared in get_adcp_capabilities, because products may be backed by different ad servers. Sellers MUST echo applied signal_targeting_groups on the resulting package state, including fixed/default selections. On update_media_buy, sellers MAY reject changes that require repricing with REQUOTE_REQUIRED." + "description": "Basic Boolean grouping for seller-offered signals. v1 supports a required top-level operator 'all' and child groups with operator 'any' for include groups or 'none' for exclusion groups. Example semantics: group 1 any(A, B) plus group 2 none(C, D) means (A OR B) AND NOT (C OR D). Signal entries reference named signal definitions with signal_ref scope 'product' for product-local signal options or scope 'data_provider' for external signals published in adagents.json signals[]. For simple include-only targeting, send one child group with operator 'any'. Sellers SHOULD reject entries that are not available for the product through inline signal_targeting_options or get_signals, are not active for the account, or exceed the product's signal_targeting_allowed/signal_targeting_rules/product terms. Signal targeting limits are product-scoped, not declared in get_adcp_capabilities, because products may be backed by different ad servers. targeting_overlay preserves buyer-requested groups; targeting_resolution.applied MUST contain the complete applied groups, including fixed/default selections. On update_media_buy, sellers MAY reject changes that require repricing with REQUOTE_REQUIRED." }, "signal_targeting": { "type": "array", @@ -167,6 +167,10 @@ "$ref": "/schemas/core/property-list-ref.json", "description": "Reference to a property list whose properties must not carry the buyer's ads. Matched properties are removed from delivery. Use for brand-safety do-not-run lists (apps, sites). Exclude wins on overlap with property_list, and applies regardless of the product's property_targeting_allowed flag. Seller must declare support in get_adcp_capabilities." }, + "placement_selection": { + "$ref": "/schemas/core/placement-selection.json", + "description": "Purchased targetable-placement selection, resolved jointly with property and collection targeting. mode: selected supplies the complete selected set; mode: default requests the product or seller default. Every selected ref MUST resolve to a mode: targetable placement on the package product and have at least one eligible property-placement pair after property targeting. Product placements with mode: included remain included. Sellers reject invalid combinations with PLACEMENT_SELECTION_INVALID and never silently drop or widen a selection." + }, "collection_list": { "$ref": "/schemas/core/collection-list-ref.json", "description": "Reference to a collection list for including specific collections (programs, shows) within this product. The package runs on the intersection of matched collections and this list. Use for inclusion-based collection targeting. Seller must declare support in get_adcp_capabilities." diff --git a/static/schemas/source/core/x-entity-types.json b/static/schemas/source/core/x-entity-types.json index eadb2f8b6b..ac22f14b9c 100644 --- a/static/schemas/source/core/x-entity-types.json +++ b/static/schemas/source/core/x-entity-types.json @@ -63,7 +63,7 @@ "audience": "A buyer-managed audience (CRM, lookalike seed, suppression). `audience_id` in media-buy/sync-audiences-request.", "signal": "A data signal. New discovery, activation, product targeting, and buy-time surfaces use `signal_ref`; legacy `signal_id` objects are deprecated. Opaque activation ids use `signal_activation_id` instead.", "signal_activation_id": "Opaque identifier used to select or activate a signal from a signals agent or seller-offered signal feed. `signal_agent_segment_id` in signals/* schemas and product signal targeting option declarations. Scoped to the agent that issued it; not interchangeable with `signal`.", - "demographic_interval_id": "A seller-scoped enumerated demographic interval exposed by Product.demographic_targeting and echoed by Package.demographic_targeting_resolution. The identifier is meaningful only with the seller product that published the interval; its authoritative age bounds travel alongside it in the product capability.", + "demographic_interval_id": "A seller-scoped enumerated demographic interval exposed by Product.demographic_targeting and echoed by Package.targeting_resolution.demographics. The identifier is meaningful only with the seller product that published the interval; its authoritative age bounds travel alongside it in the product capability.", "event_source": "A conversion pixel or event feed. `event_source_id` in media-buy/sync-event-sources-request, media-buy/log-event-request, and core/event.json.", "impairment": "An open dependency-impact entry on a media buy — `impairment_id` in core/impairment.json. Stable for the lifetime of the open impairment; doubles as `notification_id` on the impairment webhook so receivers dedupe across at-least-once delivery.", "collection_list": "A buyer-managed collection list. `list_id` on collection/* schemas.", diff --git a/static/schemas/source/enums/error-code.json b/static/schemas/source/enums/error-code.json index c428569043..55eb6fa46c 100644 --- a/static/schemas/source/enums/error-code.json +++ b/static/schemas/source/enums/error-code.json @@ -83,6 +83,7 @@ "AGENT_BLOCKED", "CREDENTIAL_IN_ARGS", "ACTION_NOT_ALLOWED", + "PLACEMENT_SELECTION_INVALID", "PRIVATE_FIELD_IN_PUBLIC_PLACEMENT", "FORMAT_PROJECTION_FAILED", "FORMAT_DECLARATION_DIVERGENT", @@ -177,6 +178,7 @@ "AGENT_BLOCKED": "The calling buyer agent's commercial relationship with the seller is permanently denied — the agent is blocked. Sibling to `AGENT_SUSPENDED` on the agent-relationship axis but with no recovery path (a suspension may lift via re-onboarding; a block does not). The code itself is the discriminator — same posture as `AGENT_SUSPENDED`: no `error.details` payload, no per-agent commercial state, cross-tenant onboarding oracle clamp + channel-coverage requirements normative in error-handling.mdx Per-Agent Authorization Gate. Recovery: terminal (no autonomous recovery — the agent MUST surface to a human at the buyer; relationships are reinstated only through offline operator action with the seller, not via any seller-callable AdCP task).", "CREDENTIAL_IN_ARGS": "The seller detected authentication material or caller-supplied trust material placed in request args (top-level, in `context`, in `ext`, or any other nested location in the task payload) instead of arriving on the relevant transport authentication or trust channel. This includes buyer-principal credentials that should arrive on the inbound transport (`Authorization: Bearer` per RFC 6750 §2 for HTTP, RFC 9421 signature headers for signed requests, MCP/A2A authentication framing per RFC 9728 §3), and evaluator-call credentials or JWK/JWKS/JWKS-URI trust material smuggled into evaluator-related payload fields instead of being established through the creative agent's outbound transport authentication to the evaluator. Distinct from `AUTH_REQUIRED` (no credentials presented or presented credentials rejected on the transport channel) and `PERMISSION_DENIED` (authenticated caller not authorized for the action). Distinct from the receiver-side credentials carried in `push_notification_config.authentication.credentials`, which configure the seller's webhook callback authentication and are not buyer-principal or evaluator-call credentials — those are an explicit carve-out and MUST NOT trigger this code. Sellers SHOULD reject credential-in-args under AdCP 3.1; the requirement upgrades to MUST 90 days after the 3.1 publication date. Recovery: terminal — the agent MUST NOT auto-retry. Auto-retry against this code re-logs the credential on each attempt across the seller's request logs, observability stack, and any LLM-context surfaces in the buyer-side recovery loop, exactly the prompt-injection exfiltration surface that motivated the rule. Wire placement. Sellers MUST flip transport-level failure markers (HTTP 4xx, MCP `isError: true`, A2A `failed`) and populate both layers per the two-layer model in `error-handling.mdx#envelope-vs-payload-errors-the-two-layer-model`. The code itself is the discriminator; no `error.details` shape is defined, and `error.field` MUST NOT echo the offending credential value or any prefix of it (e.g., `\"Bearer ey...\"`) — the path that triggered detection is sufficient (e.g., `request.access_token`, `request.context.credentials`, `request.ext.api_key`, `request.evaluator.ext.api_key`). `error.message` MUST be generic and MUST NOT contain credential material. Sellers MUST drop the smuggled credential from logs, audit rows, and observability spans before persisting the rejection — the rejection itself is otherwise an exfiltration surface.", "ACTION_NOT_ALLOWED": "The requested mutation maps to an action that is not currently available on this media buy. Sellers MUST populate `error.details` with `attempted_action` (the `media_buy_valid_action` value the request maps to), `reason` (an `action-not-allowed-reason` value: `wrong_status`, `not_supported_on_product`, `not_supported_on_buy`, or `mode_mismatch`), and `currently_available_actions` (echo of the buy's resolved `available_actions[]` so the buyer SDK can offer recovery without a separate get_media_buys round-trip). Recovery: correctable when `reason` is `wrong_status` (wait for or transition to an allowed status) or `mode_mismatch` (re-issue through the appropriate flow). Terminal-for-this-buy when `reason` is `not_supported_on_product` or `not_supported_on_buy` — buyers select a different product or renegotiate buy terms.", + "PLACEMENT_SELECTION_INVALID": "A create_media_buy or update_media_buy `packages[].targeting_overlay.placement_selection` is syntactically valid but cannot be applied exactly to the package product and its jointly resolved property/collection scope. Sellers MUST reject rather than silently widen, narrow, normalize, or partially apply the set. `error.field` SHOULD point to the placement_selection or offending placement_refs entry. `error.details.reason` SHOULD be one of `unknown_placement`, `publisher_mismatch`, `included_not_selectable`, `duplicate_reference`, `property_incompatible`, `collection_incompatible`, `incompatible_combination`, or `orphaned_creative_assignment`; details MAY identify offending placement references and applicable product constraints. Every selected placement requires at least one eligible property-placement pair after property targeting. Use ACTION_NOT_ALLOWED when update_placements is unavailable and REQUOTE_REQUIRED when the set is valid but changes the priced envelope. Recovery: correctable (select a valid complete set from the product's targetable placements, adjust property/collection targeting, update incompatible creative assignments atomically, restore mode: default, or choose a replacement product/package).", "PRIVATE_FIELD_IN_PUBLIC_PLACEMENT": "Fatal producer-side error raised when a public placement object (`Product.placements[]` in `get_products` or `placements[]` in adagents.json) exposes seller-private operational fields such as `visibility`, `source`, `origin`, or `delivery_mappings`. This is a private-data leak, not an ordinary syntactic mismatch. Consumers that detect it MUST fail closed for that placement and surface this code so monitoring can alarm on the leak specifically instead of burying it under generic schema validation. `error.field` SHOULD point at the offending placement path and `error.details` SHOULD carry `{ placement_id, leaked_fields: [] }` without echoing private field values. Recovery: correctable but seller-side — remove private operational fields from the public placement surface and keep delivery mappings in seller-internal systems.", "FORMAT_PROJECTION_FAILED": "Non-fatal advisory raised when a legacy named format on a product cannot be projected to a canonical-formats `ProductFormatDeclaration` via the resolution order in `v1-canonical-mapping.json` (explicit `canonical` field → format_id_glob → structural match → fail-closed). The product is still valid on the legacy named-format path; only the 3.1+ `format_options` projection failed. Primarily a **consumer-SDK concern** — the seller didn't fail; the consumer-side SDK couldn't project on their behalf. `error.field` MUST point at the offending product (e.g., `products[3].format_ids[0]`); `error.details` SHOULD carry `{ format_id, product_id, resolution_failure: \"no_explicit_canonical\" | \"no_registry_match\" | \"no_structural_match\" }` so buyer SDKs can route remediation (suggest the seller add an explicit `canonical` field, or file a registry PR).\n\n**Surface placement (normative).** SDKs that detect this on consumption MUST augment the response's `errors[]` array with an entry carrying `source: \"sdk\"`, `sdk_id: \"@\"`, `code: \"FORMAT_PROJECTION_FAILED\"`, and the field+details described above. This is the single mandated surface — logger-only is insufficient and a separate lint-output channel is NOT acceptable (AdCP is a multi-hop agent network; warnings need to propagate across hops or each hop has to re-detect locally). Sellers MAY emit this code on their own response when they self-detect a non-projectable format on emit; producer-emitted entries omit `source` (or set `source: \"producer\"`). The response stays 200/success regardless of who emits; this is non-fatal.\n\n**Multi-hop deduplication.** Each hop that detects the same condition SHOULD deduplicate by `(code, field)` rather than re-emit. The existing entry's `sdk_id` identifies which earlier processor saw it first; downstream SDKs SHOULD NOT add a second entry for the same `(code, field)` pair unless they have materially different `error.details` (e.g., a different `resolution_failure` reason from a different registry version).\n\nRecovery: correctable (seller-side action — add explicit `canonical` field on the legacy format file, contribute a v1-canonical-mapping registry entry, or author a 3.1+ `ProductFormatDeclaration` with `v1_format_ref` linking back). See canonical-formats.mdx 'Dual emission and v2↔v1 projection' for the full rules.", "FORMAT_DECLARATION_DIVERGENT": "Non-fatal advisory raised when a product carries BOTH `format_ids` (v1) AND `format_options` (v2) and the two disagree (different canonical, different dimensions, different orientation) after projection. The producer's contract is that both shapes MUST refer to the same underlying declaration; divergence is a producer bug.\n\nEither side MAY emit this code: a SELLER may self-detect on emit (own producer bug; rare), or more commonly a consumer-SDK detects on consumption. SDKs MUST prefer `format_options` (the richer surface) when both are present and MUST surface the divergent product so it's observable rather than silently picked-one-and-dropped-other. Hard-failing the entire `get_products` response is discouraged — it punishes downstream buyers for the producer bug.\n\n**Surface placement (normative).** Same single-surface mandate as `FORMAT_PROJECTION_FAILED`: SDKs that detect this on consumption MUST augment the response's `errors[]` array with an entry carrying `source: \"sdk\"`, `sdk_id: \"@\"`, `code: \"FORMAT_DECLARATION_DIVERGENT\"`, and the field+details described below. Logger-only is insufficient; lint-output channels are NOT acceptable as the surface (the multi-hop agent network needs warnings to propagate across SDK boundaries via the wire response).\n\n`error.field` MUST point at the offending product; `error.details` SHOULD carry `{ product_id, format_ids, format_options_summary, divergence_reason }` so buyer SDKs can flag the producer for follow-up.\n\n**Multi-hop deduplication.** Each hop that detects the same divergence SHOULD deduplicate by `(code, field)` rather than re-emit; the existing entry's `sdk_id` identifies which earlier processor saw it first.\n\nRecovery: correctable but seller-side — buyer can't fix divergent declarations, only flag them.", @@ -506,6 +508,10 @@ "recovery": "correctable", "suggestion": "branch on error.details.reason: for wrong_status, wait for or transition to a status listed under the action's allowed_statuses; for mode_mismatch, this is a flow switch (not a retry against update_media_buy) — follow the mode named in available_actions[].mode (await the seller's webhook for requires_approval); for not_supported_on_product or not_supported_on_buy, do not retry — the action is unavailable on this buy and buyer must select a different product or renegotiate" }, + "PLACEMENT_SELECTION_INVALID": { + "recovery": "correctable", + "suggestion": "branch on error.details.reason, choose a valid complete set from the product's mode: targetable placements, adjust property/collection targeting so every selected placement retains an eligible inventory pair, update incompatible creative assignments atomically, restore mode: default, or choose a replacement product/package" + }, "PRIVATE_FIELD_IN_PUBLIC_PLACEMENT": { "recovery": "correctable", "suggestion": "seller-side fix needed: remove private operational fields (`visibility`, `source`, `origin`, `delivery_mappings`, or similar) from public placement objects. Consumers MUST fail closed for the affected placement and alert operators; do not echo private field values in logs or error details" diff --git a/static/schemas/source/enums/media-buy-valid-action.json b/static/schemas/source/enums/media-buy-valid-action.json index cc2cb1fdae..035c19088c 100644 --- a/static/schemas/source/enums/media-buy-valid-action.json +++ b/static/schemas/source/enums/media-buy-valid-action.json @@ -10,7 +10,7 @@ "update_packages", "sync_creatives" ], - "x-deprecated-enum-values-doc": "Coarse legacy values retained for backwards compatibility with 3.x sellers. Each rolls up to one or more finer-grained values published in 3.1 (see `enumMetadata[].rollup`). Removed in 4.0.", + "x-deprecated-enum-values-doc": "Coarse legacy values retained for backwards compatibility with 3.x sellers. Each rolls up to one or more finer-grained 3.x values (see `enumMetadata[].rollup`). Removed in 4.0.", "enum": [ "pause", "resume", @@ -22,6 +22,7 @@ "decrease_budget", "reallocate_budget", "update_targeting", + "update_placements", "update_pacing", "update_frequency_caps", "replace_creative", @@ -45,6 +46,7 @@ "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.", "reallocate_budget": "Redistribute budget across packages on `packages[].budget` without changing the sum.", "update_targeting": "Update targeting overlays on existing packages.", + "update_placements": "Replace the purchased targetable-placement selection on existing packages. Maps to `packages[].targeting_overlay.placement_selection` and composes atomically with property and collection targeting in the same overlay.", "update_pacing": "Update pacing on existing packages.", "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.", @@ -54,7 +56,7 @@ "remove_packages": "Remove packages from a media buy.", "update_budget": "Coarse legacy action covering any budget change. Retained for backwards compatibility with 3.x sellers that emit this rather than the finer `increase_budget`/`decrease_budget`/`reallocate_budget`. Removed in 4.0. Sellers SHOULD migrate to the finer vocabulary.", "update_dates": "Coarse legacy action covering any flight-date change. Retained for backwards compatibility with 3.x sellers that emit this rather than the finer `extend_flight`/`shorten_flight`/`update_flight_dates`. Removed in 4.0. Sellers SHOULD migrate to the finer vocabulary.", - "update_packages": "Coarse legacy action covering any package update. Retained for backwards compatibility with 3.x sellers that emit this rather than the finer `update_targeting`/`update_pacing`/`update_frequency_caps`/`reallocate_budget`/`remove_packages`. Removed in 4.0. Sellers SHOULD migrate to the finer vocabulary.", + "update_packages": "Coarse legacy action covering any package update. Retained for backwards compatibility with 3.x sellers that emit this rather than the finer `update_targeting`/`update_placements`/`update_pacing`/`update_frequency_caps`/`reallocate_budget`/`remove_packages`. Removed in 4.0. Sellers SHOULD migrate to the finer vocabulary.", "sync_creatives": "Coarse legacy action covering any creative-related change. Retained for backwards compatibility with 3.x sellers that emit this rather than the finer `replace_creative`/`update_creative_assignments`/`remove_creative`. Removed in 4.0. Sellers SHOULD migrate to the finer vocabulary." }, "enumMetadata": { @@ -69,6 +71,7 @@ "decrease_budget": { "update_fields": ["packages[].budget"] }, "reallocate_budget": { "update_fields": ["packages[].budget"] }, "update_targeting": { "update_fields": ["packages[].targeting_overlay", "packages[].keyword_targets_add", "packages[].keyword_targets_remove", "packages[].negative_keywords_add", "packages[].negative_keywords_remove"] }, + "update_placements": { "update_fields": ["packages[].targeting_overlay.placement_selection"] }, "update_pacing": { "update_fields": ["packages[].pacing"] }, "update_frequency_caps": { "update_fields": ["packages[].targeting_overlay.frequency_cap"] }, "replace_creative": { "update_fields": ["packages[].creatives"] }, @@ -78,7 +81,7 @@ "remove_packages": { "update_fields": ["packages[].canceled"] }, "update_budget": { "update_fields": ["packages[].budget"], "rollup": ["increase_budget", "decrease_budget", "reallocate_budget"] }, "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_placements", "update_pacing", "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/media-buy/get-media-buys-response.json b/static/schemas/source/media-buy/get-media-buys-response.json index e28933f840..16da817b32 100644 --- a/static/schemas/source/media-buy/get-media-buys-response.json +++ b/static/schemas/source/media-buy/get-media-buys-response.json @@ -268,11 +268,11 @@ }, "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." + "description": "Complete buyer-requested targeting echoed from the most recent create_media_buy or update_media_buy. Sellers MUST preserve it so buyers can distinguish requested intent from the seller-confirmed targeting_resolution." }, - "demographic_targeting_resolution": { - "$ref": "/schemas/core/demographic-targeting-resolution.json", - "description": "Lossless requested-versus-applied demographic readback. Sellers MUST include this whenever demographic targeting was requested or applied. targeting_overlay.demographics preserves the buyer predicate and determination constraints; this object's applied predicate and applied_bases report the effective booked state." + "targeting_resolution": { + "$ref": "/schemas/core/targeting-resolution.json", + "description": "Complete seller-confirmed applied targeting. Required whenever targeting_overlay is present and may also report seller-applied defaults. Inventory resolution preserves property-placement-collection relationships; demographics preserves exact requested/applied predicates and execution evidence." }, "start_time": { "type": "string", @@ -435,8 +435,96 @@ "package_id" ], "dependencies": { - "params": ["format_kind"] + "params": ["format_kind"], + "targeting_overlay": ["targeting_resolution"] }, + "allOf": [ + { + "if": { + "properties": { + "targeting_overlay": { + "required": ["demographics"] + } + }, + "required": ["targeting_overlay"] + }, + "then": { + "properties": { + "targeting_resolution": { + "required": ["demographics"] + } + } + } + }, + { + "if": { + "properties": { + "targeting_overlay": { + "anyOf": [ + { "required": ["property_list"] }, + { "required": ["property_list_exclude"] }, + { "required": ["collection_list"] }, + { "required": ["collection_list_exclude"] }, + { "required": ["placement_selection"] } + ] + } + }, + "required": ["targeting_overlay"] + }, + "then": { + "properties": { + "targeting_resolution": { + "required": ["inventory"] + } + } + } + }, + { + "if": { + "properties": { + "targeting_overlay": { + "required": ["placement_selection"] + } + }, + "required": ["targeting_overlay"] + }, + "then": { + "properties": { + "targeting_resolution": { + "properties": { + "inventory": { + "required": ["placements"] + } + } + } + } + } + }, + { + "if": { + "properties": { + "targeting_overlay": { + "anyOf": [ + { "required": ["collection_list"] }, + { "required": ["collection_list_exclude"] } + ] + } + }, + "required": ["targeting_overlay"] + }, + "then": { + "properties": { + "targeting_resolution": { + "properties": { + "inventory": { + "required": ["collections"] + } + } + } + } + } + } + ], "additionalProperties": true } }, diff --git a/static/schemas/source/media-buy/package-request.json b/static/schemas/source/media-buy/package-request.json index f70f397654..75cf76f982 100644 --- a/static/schemas/source/media-buy/package-request.json +++ b/static/schemas/source/media-buy/package-request.json @@ -100,7 +100,8 @@ "minItems": 1 }, "targeting_overlay": { - "$ref": "/schemas/core/targeting.json" + "$ref": "/schemas/core/targeting.json", + "description": "Complete buyer-requested targeting. Inventory axes inside this object are resolved jointly: property and collection lists constrain targeting_overlay.placement_selection. Sellers MUST reject an empty or invalid intersection rather than silently dropping an axis. A successful create echoes this requested overlay and returns the applied result in targeting_resolution." }, "measurement_terms": { "$ref": "/schemas/core/measurement-terms.json", diff --git a/static/schemas/source/media-buy/package-update.json b/static/schemas/source/media-buy/package-update.json index 1f0277f69c..1aa70584c9 100644 --- a/static/schemas/source/media-buy/package-update.json +++ b/static/schemas/source/media-buy/package-update.json @@ -71,7 +71,7 @@ }, "targeting_overlay": { "$ref": "/schemas/core/targeting.json", - "description": "Targeting overlay to apply to this package. Uses replacement semantics — the full overlay replaces the previous one. Omit to leave targeting unchanged. For keyword and negative keyword updates, prefer the incremental operations (keyword_targets_add, keyword_targets_remove, negative_keywords_add, negative_keywords_remove) which avoid replacing the full overlay. Sellers SHOULD return a validation error if targeting_overlay.keyword_targets is present in the same request as keyword_targets_add or keyword_targets_remove, and likewise for negative_keywords. If the replacement changes signal_targeting_groups, sellers MAY require a new quote or reject with REQUOTE_REQUIRED when the selected signal, group expression, or pricing_option_id changes the package's priced envelope." + "description": "Complete targeting overlay replacement for this package. Omit to leave all targeting unchanged. Inventory axes are replaced and validated together: property and collection lists constrain placement_selection, and every explicitly selected placement must retain at least one eligible property-placement pair. The seller maps a placement_selection difference to update_placements and other differences to their applicable actions; all required actions must be available before the atomic mutation. For keyword and negative keyword updates, prefer the incremental operations (keyword_targets_add, keyword_targets_remove, negative_keywords_add, negative_keywords_remove). If the replacement changes priced signal, placement, property, collection, or other targeting terms, reject with REQUOTE_REQUIRED." }, "keyword_targets_add": { "type": "array", diff --git a/static/test-vectors/media-buy/package-status-targeting-overlay-echo.json b/static/test-vectors/media-buy/package-status-targeting-overlay-echo.json index c396c13365..9533c343c2 100644 --- a/static/test-vectors/media-buy/package-status-targeting-overlay-echo.json +++ b/static/test-vectors/media-buy/package-status-targeting-overlay-echo.json @@ -1,7 +1,7 @@ { "version": 1, "schema": "/schemas/media-buy/get-media-buys-response.json", - "description": "Positive wire-level vectors for PackageStatus targeting readback in get_media_buys responses, including targeting_overlay and AdCP 3.2 demographic_targeting_resolution. Vectors are dedicated JSON payloads that MUST validate against the bundled schema and whose shape is asserted by SDK code generators. Each vector contains a stable `id` (kebab-case; the stable reference for cross-SDK conformance), a human-readable `description`, the `payload` returned by the seller, and `assertions` — dotted JSON paths that MUST be present with the given value. Downstream tests SHOULD look up vectors by `id`; `description` prose may be revised without notice. Covers the `SHOULD` echo path for plain overlay fields, the `MUST` echo path for sellers claiming the property-lists / collection-lists specialisms, and lossless requested/applied demographic readback.", + "description": "Positive wire-level vectors for PackageStatus targeting readback in get_media_buys responses. targeting_overlay preserves complete buyer intent; targeting_resolution carries the complete seller-applied result, joint inventory evidence, and specialized demographic execution detail. Vectors are dedicated JSON payloads that MUST validate against the bundled schema and whose shape is asserted by SDK code generators.", "vectors": [ { "id": "property-and-collection-list-echo", @@ -37,6 +37,52 @@ "agent_url": "https://governance.pinnacle-agency.example", "list_id": "acme_outdoor_collections_v1" } + }, + "targeting_resolution": { + "applied": { + "property_list": { + "agent_url": "https://governance.pinnacle-agency.example", + "list_id": "acme_outdoor_allowlist_v1" + }, + "collection_list": { + "agent_url": "https://governance.pinnacle-agency.example", + "list_id": "acme_outdoor_collections_v1" + } + }, + "equivalent": true, + "resolved_at": "2026-06-25T12:00:00Z", + "inventory": { + "properties": [ + { + "publisher_domain": "streaming.example", + "selection_type": "all" + } + ], + "collections": [ + { + "publisher_domain": "streaming.example", + "collection_ids": ["outdoor_lifestyle"] + } + ], + "list_snapshots": [ + { + "kind": "property_include", + "agent_url": "https://governance.pinnacle-agency.example", + "list_id": "acme_outdoor_allowlist_v1", + "version": "v1", + "resolved_at": "2026-06-25T12:00:00Z", + "matched_count": 12 + }, + { + "kind": "collection_include", + "agent_url": "https://governance.pinnacle-agency.example", + "list_id": "acme_outdoor_collections_v1", + "version": "v1", + "resolved_at": "2026-06-25T12:00:00Z", + "matched_count": 1 + } + ] + } } } ] @@ -59,6 +105,10 @@ { "path": "media_buys[0].packages[0].targeting_overlay.collection_list.agent_url", "value": "https://governance.pinnacle-agency.example" + }, + { + "path": "media_buys[0].packages[0].targeting_resolution.inventory.properties[0].publisher_domain", + "value": "streaming.example" } ] }, @@ -98,6 +148,22 @@ "unit": "days" } } + }, + "targeting_resolution": { + "applied": { + "geo_countries": ["US", "CA"], + "device_type": ["ctv"], + "frequency_cap": { + "max_impressions": 3, + "per": "households", + "window": { + "interval": 7, + "unit": "days" + } + } + }, + "equivalent": true, + "resolved_at": "2026-06-02T14:22:00Z" } } ] @@ -201,6 +267,56 @@ } ] } + }, + "targeting_resolution": { + "applied": { + "signal_targeting_groups": { + "operator": "all", + "groups": [ + { + "operator": "any", + "signals": [ + { + "signal_ref": { + "scope": "product", + "signal_id": "high_intent_shoppers" + }, + "value_type": "binary", + "value": true, + "pricing_option_id": "signal_cpm_usd_250", + "signal_agent_segment_id": "sig_high_intent_shoppers" + }, + { + "signal_ref": { + "scope": "product", + "signal_id": "loyalty_members" + }, + "value_type": "binary", + "value": true, + "pricing_option_id": "signal_cpm_usd_250", + "signal_agent_segment_id": "sig_loyalty_members" + } + ] + }, + { + "operator": "none", + "signals": [ + { + "signal_ref": { + "scope": "product", + "signal_id": "recent_purchasers" + }, + "value_type": "binary", + "value": true, + "signal_agent_segment_id": "sig_recent_purchasers" + } + ] + } + ] + } + }, + "equivalent": true, + "resolved_at": "2026-08-15T10:00:00Z" } } ] @@ -230,6 +346,108 @@ } ] }, + { + "id": "joint-placement-property-resolution", + "description": "Placement selection is buyer intent inside targeting_overlay; targeting_resolution.inventory returns the effective placement together with the property and collection scopes where it can actually run.", + "payload": { + "status": "completed", + "media_buys": [ + { + "media_buy_id": "mb_joint_inventory_001", + "status": "pending_start", + "currency": "USD", + "total_budget": 12000, + "confirmed_at": "2026-08-20T10:00:00Z", + "revision": 1, + "packages": [ + { + "package_id": "pkg_joint_inventory_video", + "product_id": "prod_multi_property_video", + "budget": 12000, + "currency": "USD", + "targeting_overlay": { + "placement_selection": { + "mode": "selected", + "placement_refs": [ + { + "publisher_domain": "publisher.example", + "placement_id": "premium_video" + } + ] + } + }, + "targeting_resolution": { + "applied": { + "placement_selection": { + "mode": "selected", + "placement_refs": [ + { + "publisher_domain": "publisher.example", + "placement_id": "premium_video" + } + ] + } + }, + "equivalent": true, + "resolved_at": "2026-08-20T10:00:00Z", + "inventory": { + "properties": [ + { + "publisher_domain": "publisher.example", + "selection_type": "by_id", + "property_ids": ["streaming_app"] + } + ], + "placements": [ + { + "placement_ref": { + "publisher_domain": "publisher.example", + "placement_id": "premium_video" + }, + "selection_source": "selected", + "property_scope": [ + { + "publisher_domain": "publisher.example", + "selection_type": "by_id", + "property_ids": ["streaming_app"] + } + ], + "collection_scope": [ + { + "publisher_domain": "publisher.example", + "collection_ids": ["premium_series"] + } + ] + } + ], + "collections": [ + { + "publisher_domain": "publisher.example", + "collection_ids": ["premium_series"] + } + ] + } + } + } + ] + } + ] + }, + "assertions": [ + { + "path": "media_buys[0].packages[0].targeting_overlay.placement_selection.placement_refs[0].placement_id", + "value": "premium_video" + }, + { + "path": "media_buys[0].packages[0].targeting_resolution.inventory.placements[0].property_scope[0].property_ids[0]", + "value": "streaming_app" + }, + { + "path": "media_buys[0].packages[0].targeting_resolution.inventory.placements[0].collection_scope[0].collection_ids[0]", + "value": "premium_series" + } + ] + }, { "id": "demographic-targeting-exact-readback", "description": "AdCP 3.2 — seller echoes the applied canonical demographic predicate and lossless requested/applied continuous-bounds resolution.", @@ -264,24 +482,37 @@ } } }, - "demographic_targeting_resolution": { - "requested": { - "age": { - "min": 21, - "max": 35, - "include_unknown": false - } - }, + "targeting_resolution": { "applied": { - "age": { - "min": 21, - "max": 35, - "include_unknown": false + "demographics": { + "age": { + "min": 21, + "max": 35, + "include_unknown": false + } } }, "equivalent": true, - "execution": { - "type": "continuous_bounds" + "resolved_at": "2026-09-01T10:00:00Z", + "demographics": { + "requested": { + "age": { + "min": 21, + "max": 35, + "include_unknown": false + } + }, + "applied": { + "age": { + "min": 21, + "max": 35, + "include_unknown": false + } + }, + "equivalent": true, + "execution": { + "type": "continuous_bounds" + } } } } @@ -299,11 +530,11 @@ "value": false }, { - "path": "media_buys[0].packages[0].demographic_targeting_resolution.equivalent", + "path": "media_buys[0].packages[0].targeting_resolution.demographics.equivalent", "value": true }, { - "path": "media_buys[0].packages[0].demographic_targeting_resolution.execution.type", + "path": "media_buys[0].packages[0].targeting_resolution.demographics.execution.type", "value": "continuous_bounds" } ] diff --git a/tests/demographic-targeting.test.cjs b/tests/demographic-targeting.test.cjs index 29ee8bd39e..88ce851a18 100644 --- a/tests/demographic-targeting.test.cjs +++ b/tests/demographic-targeting.test.cjs @@ -335,10 +335,10 @@ describe('portable demographic targeting', () => { assert.equal(targeting.properties.demographics.$ref, '/schemas/core/demographic-targeting-intent.json'); assert.equal(product.properties.demographic_targeting.$ref, '/schemas/core/demographic-targeting-capability.json'); assert.equal(capabilities.properties.media_buy.properties.execution.properties.targeting.properties.demographics.properties.supported.type, 'boolean'); - assert.equal(packageSchema.properties.demographic_targeting_resolution.$ref, '/schemas/core/demographic-targeting-resolution.json'); + assert.equal(packageSchema.properties.targeting_resolution.$ref, '/schemas/core/targeting-resolution.json'); assert.equal( - getMediaBuys.properties.media_buys.items.properties.packages.items.properties.demographic_targeting_resolution.$ref, - '/schemas/core/demographic-targeting-resolution.json' + getMediaBuys.properties.media_buys.items.properties.packages.items.properties.targeting_resolution.$ref, + '/schemas/core/targeting-resolution.json' ); }); }); diff --git a/tests/media-buy-targeting-overlay-vectors.test.cjs b/tests/media-buy-targeting-overlay-vectors.test.cjs index ab925dace8..6ebb4e64b4 100644 --- a/tests/media-buy-targeting-overlay-vectors.test.cjs +++ b/tests/media-buy-targeting-overlay-vectors.test.cjs @@ -1,11 +1,11 @@ /** - * Validates positive wire-level test vectors for PackageStatus.targeting_overlay - * echo in static/test-vectors/media-buy/package-status-targeting-overlay-echo.json. + * Validates positive wire-level test vectors for PackageStatus requested + * targeting_overlay and applied targeting_resolution readback. * * Three layers of defense: * - * 1. Schema-shape lock — PackageStatus.properties.targeting_overlay.$ref points - * at /schemas/core/targeting.json. Because PackageStatus and TargetingOverlay + * 1. Schema-shape lock — PackageStatus declares both canonical refs. Because + * PackageStatus, TargetingOverlay, and TargetingResolution * both set additionalProperties:true, payload-level validation alone would * not catch a regeneration that drops the property declaration; this direct * schema read does. @@ -58,7 +58,7 @@ function resolvePath(obj, dottedPath) { const data = JSON.parse(fs.readFileSync(VECTORS_PATH, 'utf8')); -describe('PackageStatus targeting_overlay echo vectors', () => { +describe('PackageStatus targeting request and resolution vectors', () => { let validate; let rootSchema; @@ -85,7 +85,7 @@ describe('PackageStatus targeting_overlay echo vectors', () => { }); }); - it('PackageStatus declares targeting_overlay with the core/targeting.json $ref', () => { + it('PackageStatus declares requested and applied targeting refs', () => { // Direct schema-shape lock: additionalProperties:true on PackageStatus means payload // validation alone does not catch regeneration that drops this declaration. This // assertion is the actual wire-level contract for downstream SDK codegen. @@ -100,6 +100,10 @@ describe('PackageStatus targeting_overlay echo vectors', () => { packageStatusSchema.properties.targeting_overlay.$ref, '/schemas/core/targeting.json' ); + assert.equal( + packageStatusSchema.properties.targeting_resolution.$ref, + '/schemas/core/targeting-resolution.json' + ); }); for (const vector of data.vectors) { @@ -124,9 +128,11 @@ describe('PackageStatus targeting_overlay echo vectors', () => { }); } - it('covers both the specialism MUST and the general SHOULD paths', () => { + it('covers general, list, joint inventory, and demographic targeting paths', () => { const ids = new Set(data.vectors.map(v => v.id)); assert.ok(ids.has('property-and-collection-list-echo'), 'specialism MUST vector required'); assert.ok(ids.has('plain-overlay-fields-echo'), 'general SHOULD vector required'); + assert.ok(ids.has('joint-placement-property-resolution'), 'joint inventory vector required'); + assert.ok(ids.has('demographic-targeting-exact-readback'), 'demographic resolution vector required'); }); }); diff --git a/tests/placement-selection-schema.test.cjs b/tests/placement-selection-schema.test.cjs new file mode 100644 index 0000000000..85d80edd3f --- /dev/null +++ b/tests/placement-selection-schema.test.cjs @@ -0,0 +1,197 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const Ajv = require('ajv'); +const addFormats = require('ajv-formats'); + +const SCHEMA_BASE_DIR = path.join(__dirname, '../static/schemas/source'); + +function schemaPathFromId(schemaId) { + return path.join(SCHEMA_BASE_DIR, schemaId.replace('/schemas/', '')); +} + +async function compile(schemaId) { + const ajv = new Ajv({ + allErrors: true, + strict: false, + discriminator: true, + loadSchema: async (uri) => { + if (!uri.startsWith('/schemas/')) { + throw new Error(`Cannot load external schema: ${uri}`); + } + return JSON.parse(fs.readFileSync(schemaPathFromId(uri), 'utf8')); + } + }); + addFormats(ajv); + return ajv.compileAsync(JSON.parse(fs.readFileSync(schemaPathFromId(schemaId), 'utf8'))); +} + +const selected = { + mode: 'selected', + placement_refs: [ + { + publisher_domain: 'publisher.example', + placement_id: 'home_feed' + }, + { + publisher_domain: 'publisher.example', + placement_id: 'short_video' + } + ] +}; + +const propertyScope = { + publisher_domain: 'publisher.example', + selection_type: 'all' +}; + +const placementOverlay = { + placement_selection: selected +}; + +const placementResolution = { + applied: placementOverlay, + equivalent: true, + resolved_at: '2026-08-01T12:00:00Z', + inventory: { + properties: [propertyScope], + placements: selected.placement_refs.map(placementRef => ({ + placement_ref: placementRef, + selection_source: 'selected', + property_scope: [propertyScope] + })) + } +}; + +test('placement selection discriminates selected and default modes', async () => { + const validate = await compile('/schemas/core/placement-selection.json'); + + assert.equal(validate(selected), true, JSON.stringify(validate.errors, null, 2)); + assert.equal(validate({ mode: 'default' }), true, JSON.stringify(validate.errors, null, 2)); + assert.equal(validate({ mode: 'selected', placement_refs: [] }), false); + assert.equal(validate({ mode: 'selected', placement_refs: [{ placement_id: 'home_feed' }] }), false); + assert.equal(validate({ mode: 'default', placement_refs: selected.placement_refs }), false); + assert.equal(validate({ mode: 'automatic' }), false); +}); + +test('selected placement references reject exact duplicates', async () => { + const validate = await compile('/schemas/core/placement-selection.json'); + const placementRef = selected.placement_refs[0]; + + assert.equal(validate({ + mode: 'selected', + placement_refs: [placementRef, { ...placementRef }] + }), false); +}); + +test('package create, update, and state schemas carry placement selection inside targeting', async () => { + const validateRequest = await compile('/schemas/media-buy/package-request.json'); + const validateUpdate = await compile('/schemas/media-buy/package-update.json'); + const validatePackage = await compile('/schemas/core/package.json'); + + assert.equal(validateRequest({ + product_id: 'social_inventory', + pricing_option_id: 'cpm_fixed', + budget: 1000, + targeting_overlay: placementOverlay + }), true, JSON.stringify(validateRequest.errors, null, 2)); + + assert.equal(validateUpdate({ + package_id: 'pkg_123', + targeting_overlay: { placement_selection: { mode: 'default' } } + }), true, JSON.stringify(validateUpdate.errors, null, 2)); + + assert.equal(validatePackage({ + package_id: 'pkg_123', + targeting_overlay: placementOverlay, + targeting_resolution: placementResolution + }), true, JSON.stringify(validatePackage.errors, null, 2)); + + assert.equal(validatePackage({ + package_id: 'pkg_123', + targeting_overlay: placementOverlay + }), false, 'stored requested targeting requires applied resolution'); +}); + +test('get_media_buys carries requested placement targeting and joint inventory resolution', async () => { + const validate = await compile('/schemas/media-buy/get-media-buys-response.json'); + const response = { + status: 'completed', + media_buys: [ + { + media_buy_id: 'mb_123', + status: 'active', + currency: 'USD', + total_budget: 1000, + confirmed_at: '2026-08-01T12:00:00Z', + revision: 2, + packages: [ + { + package_id: 'pkg_123', + targeting_overlay: placementOverlay, + targeting_resolution: placementResolution + } + ] + } + ] + }; + + assert.equal(validate(response), true, JSON.stringify(validate.errors, null, 2)); +}); + +test('create and update responses distinguish requested and applied placement targeting', async () => { + const validateCreate = await compile('/schemas/media-buy/create-media-buy-response.json'); + const validateUpdate = await compile('/schemas/media-buy/update-media-buy-response.json'); + const packageState = { + package_id: 'pkg_123', + targeting_overlay: placementOverlay, + targeting_resolution: placementResolution + }; + + assert.equal(validateCreate({ + status: 'completed', + media_buy_id: 'mb_123', + confirmed_at: '2026-08-01T12:00:00Z', + revision: 1, + packages: [packageState] + }), true, JSON.stringify(validateCreate.errors, null, 2)); + + assert.equal(validateUpdate({ + status: 'completed', + media_buy_id: 'mb_123', + revision: 2, + affected_packages: [packageState] + }), true, JSON.stringify(validateUpdate.errors, null, 2)); +}); + +test('placement updates have action metadata and a dedicated error code', async () => { + const validateAction = await compile('/schemas/enums/media-buy-valid-action.json'); + const validateError = await compile('/schemas/enums/error-code.json'); + const actionSchema = JSON.parse( + fs.readFileSync(schemaPathFromId('/schemas/enums/media-buy-valid-action.json'), 'utf8') + ); + + assert.equal(validateAction('update_placements'), true, JSON.stringify(validateAction.errors, null, 2)); + assert.deepEqual( + actionSchema.enumMetadata.update_placements.update_fields, + ['packages[].targeting_overlay.placement_selection'] + ); + assert.equal( + actionSchema.enumMetadata.update_packages.rollup.includes('update_placements'), + true + ); + assert.equal(validateError('PLACEMENT_SELECTION_INVALID'), true, JSON.stringify(validateError.errors, null, 2)); +}); + +test('inventory resolution requires a property scope for each effective placement', async () => { + const validate = await compile('/schemas/core/targeting-resolution.json'); + const missingScope = structuredClone(placementResolution); + delete missingScope.inventory.placements[0].property_scope; + const missingPlacements = structuredClone(placementResolution); + delete missingPlacements.inventory.placements; + + assert.equal(validate(placementResolution), true, JSON.stringify(validate.errors, null, 2)); + assert.equal(validate(missingScope), false); + assert.equal(validate(missingPlacements), false); +});