From 487254a2dd542a996c2f03911f964c96425bea94 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 29 Jul 2026 22:24:30 -0400 Subject: [PATCH 1/6] Add identifier-based place targeting --- .changeset/geo-place-targeting.md | 15 + docs/media-buy/advanced-topics/targeting.mdx | 36 +- .../media-buy/task-reference/get_products.mdx | 37 ++ docs/protocol/get_adcp_capabilities.mdx | 123 ++++ scripts/error-code-drift-dispositions.json | 5 + .../source/protocols/media-buy/index.yaml | 1 + .../scenarios/geo_place_targeting.yaml | 573 ++++++++++++++++++ .../schemas/source/core/geo-place-area.json | 72 +++ .../core/geo-place-catalog-capability.json | 36 ++ .../source/core/geo-place-catalog-entry.json | 63 ++ .../source/core/geo-place-resolver.json | 27 + .../source/core/geo-place-support.json | 30 + .../schemas/source/core/geo-place-system.json | 18 + .../schemas/source/core/geo-place-type.json | 35 ++ .../get-geo-place-resolution-request.json | 59 ++ .../get-geo-place-resolution-response.json | 64 ++ .../schemas/source/core/product-filters.json | 68 ++- static/schemas/source/core/targeting.json | 24 + static/schemas/source/enums/error-code.json | 6 + .../source/enums/geo-targeting-level.json | 8 + static/schemas/source/index.json | 40 ++ .../media-buy/get-media-buys-response.json | 4 +- .../get-adcp-capabilities-response.json | 11 + ...package-status-targeting-overlay-echo.json | 85 ++- ...dia-buy-targeting-overlay-vectors.test.cjs | 8 +- tests/schema-validation.test.cjs | 338 +++++++++++ 26 files changed, 1768 insertions(+), 18 deletions(-) create mode 100644 .changeset/geo-place-targeting.md create mode 100644 static/compliance/source/protocols/media-buy/scenarios/geo_place_targeting.yaml create mode 100644 static/schemas/source/core/geo-place-area.json create mode 100644 static/schemas/source/core/geo-place-catalog-capability.json create mode 100644 static/schemas/source/core/geo-place-catalog-entry.json create mode 100644 static/schemas/source/core/geo-place-resolver.json create mode 100644 static/schemas/source/core/geo-place-support.json create mode 100644 static/schemas/source/core/geo-place-system.json create mode 100644 static/schemas/source/core/geo-place-type.json create mode 100644 static/schemas/source/core/get-geo-place-resolution-request.json create mode 100644 static/schemas/source/core/get-geo-place-resolution-response.json create mode 100644 static/schemas/source/enums/geo-targeting-level.json diff --git a/.changeset/geo-place-targeting.md b/.changeset/geo-place-targeting.md new file mode 100644 index 0000000000..116c94643f --- /dev/null +++ b/.changeset/geo-place-targeting.md @@ -0,0 +1,15 @@ +--- +"adcontextprotocol": minor +--- + +Add identifier-based named-place geographic targeting: + +- `targeting_overlay.geo_places` and `geo_places_exclude` carry stable identifiers with country, system, place type, optional catalog version, and diagnostic labels. +- `get_adcp_capabilities` declares exact country/type pairs, accepted catalog versions, and a standard resolver for every collision-safe identifier system. +- `get_products` adds place coverage and version-aware capability filters so buyers can discover support before creating a media buy. +- Package status MUST echo persisted place overlays with the applied catalog version through the existing `targeting_overlay` contract. +- Resolver responses echo their normalized query, carry machine-verifiable disambiguation and lifecycle metadata, and support existing-ID refresh after catalog rollover. +- `PLACE_TARGET_UNAVAILABLE` provides a nonfatal, correctable read-path signal when a pinned target can no longer execute without silently changing geography. +- Place forecast and delivery breakdowns remain deferred; package echo is the interim configuration-audit path. + +Refs #5588. diff --git a/docs/media-buy/advanced-topics/targeting.mdx b/docs/media-buy/advanced-topics/targeting.mdx index 068ce1e3f8..a2f212c7ee 100644 --- a/docs/media-buy/advanced-topics/targeting.mdx +++ b/docs/media-buy/advanced-topics/targeting.mdx @@ -411,7 +411,11 @@ from prose should be confirmed separately in `required_overlay_support` asks whether a product lets the buyer choose a dimension later. For example, requesting `geo_metros` support asks for products that can be narrowed by metro on packages; it does not ask the seller to return -one product per metro. The product answers with `overlay_support`. +one product per metro. Named places follow the same rule: known IDs belong in +`targeting_overlay.geo_places`, while a buyer that will choose them later asks +for `required_overlay_support.geo_places`. The product answers with binding +selectable permission in `overlay_support`; that permission is not a +value-specific availability guarantee. Coverage is an implementation detail of the same targeting contract. A constraint may be satisfied by the product's inherent inventory scope or by a @@ -455,12 +459,14 @@ Use geo fields **only** for: - `geo_regions`: ISO 3166-2 subdivision codes (e.g., `["US-CA", "GB-SCT"]`) - `geo_metros`: Structured metro areas with explicit system (e.g., `nielsen_dma`, `uk_itl2`) — not all publishers support metro-level targeting - `geo_postal_areas`: Structured postal areas with explicit country and system (e.g., `US` / `zip`, `GB` / `outward`, `ZA` / `postal_code`) — not all publishers support postal-level targeting +- `geo_places`: Catalog-backed named places with explicit country, identifier system, place type, and stable IDs — use this for platform place entities such as cities or municipalities, not raw place names **Exclusion fields** (exclude these locations from delivery): - `geo_countries_exclude`: Same format as `geo_countries` - `geo_regions_exclude`: Same format as `geo_regions` - `geo_metros_exclude`: Same format as `geo_metros` - `geo_postal_areas_exclude`: Same format as `geo_postal_areas` +- `geo_places_exclude`: Same format as `geo_places` **Note**: Inclusion and exclusion can be combined. Metro and postal targeting require specifying the classification system, enabling international support. Not all geographic granularities are supported by all publishers. Country and region are most widely supported. @@ -741,6 +747,32 @@ Geographic targeting supports both inclusion (restrict to) and exclusion (exclud - **Use cases**: RCT holdout zip codes, restricted delivery areas - **Note**: Seller must declare supported systems in `get_adcp_capabilities`; the deprecated legacy form remains accepted during the 3.x migration. +### geo_places + +- **Description**: Restrict delivery to named administrative or local places represented by stable catalog identifiers +- **Format**: Array of objects with required `country`, `system`, `place_type`, and `values`; optional `system_version`, `value_labels`, and `ext` +- **Systems**: Registered namespaces are `geonames`, `google_ads`, and `microsoft_ads`. Other catalogs use an owner-controlled absolute HTTPS URI, such as `https://seller.example/geo/catalogs/places`. MaxMind `geoname_id` values use the `geonames` namespace; MaxMind is a catalog source/version, not a separate identifier namespace. +- **Example**: `[{ "country": "NL", "system": "geonames", "system_version": "2026-05", "place_type": "city", "values": ["2759794"], "value_labels": { "2759794": "Amsterdam, North Holland, Netherlands" } }]` +- **Use cases**: Target a platform's named city, municipality, borough, neighborhood, post town, city region, or county entity without relying on ambiguous names +- **Note**: `values` are the authoritative targeting keys. Every `value_labels` key MUST appear in `values`; labels exist only for diagnostics and audit readability, and sellers MUST NOT resolve or apply targeting from them. Raw names such as `Amsterdam` are unresolved intent, not valid values. + +Within `geo_places`, values and entries have union semantics: delivery may occur in any included place. `geo_places_exclude` subtracts matching places from the current candidate geography, including when no `geo_places` inclusion is present. Inclusion across different geographic dimensions is intersected. Sellers MUST reject the same `(country, system, place_type, value)` in both include and exclude lists, even when the include and exclude entries specify different catalog versions: version is not part of stable place identity. Sellers MAY reject cross-level combinations they cannot resolve safely rather than silently approximating them. + +Before sending a place target, buyers inspect `get_adcp_capabilities.media_buy.execution.targeting.geo_places`. Support is declared as exact country/type pairs, not independent lists. Each system also declares `catalog.current_version`, exact `supported_versions`, and an `adcp_geo_place_resolver_v1` endpoint. Buyers resolve raw names—or refresh an existing ID—using an HTTPS GET with exactly one of `q` or `value` from `get-geo-place-resolution-request.json`. The response echoes the normalized request and follows `get-geo-place-resolution-response.json`, carrying machine-readable country/subdivision/type context plus active, removal-planned, or deprecated identifiers and replacements. Ambiguous results require user or agent disambiguation before trafficking. + +If `system_version` is omitted from a new target, the seller applies its declared `current_version`. Sellers MUST reject unsupported systems, country/type pairs, versions, deprecated identifiers, and unknown identifiers rather than silently dropping or reinterpreting them. If an identifier is stale, the seller returns a validation error and may surface resolver-provided replacements; it MUST NOT silently substitute a replacement. Sellers MUST echo persisted `geo_places` and `geo_places_exclude` in package `targeting_overlay` state with the exact applied `system_version` and values. + +Accepted place targeting is pinned to the echoed `system_version` for the life of the package. Removing that version from `supported_versions` stops new targeting and target-changing updates from using it, but MUST NOT silently mutate, drop, or invalidate an existing package. An unrelated package update preserves the pinned place overlay. If a seller can no longer execute a pinned target, `get_media_buys` MUST still echo it and return a nonfatal `errors[]` entry with `code: "PLACE_TARGET_UNAVAILABLE"`, `recovery: "correctable"`, `field` pointing to the exact `media_buys[N].packages[M].targeting_overlay.geo_places[_exclude][A].values[V]` response path, and `details` containing `media_buy_id`, `package_id`, `system`, `system_version`, `country`, `place_type`, and `value`. The buyer can use resolver `value` lookup against the current catalog to find lifecycle status and proposed replacements, then submit an intentional target update. + +Place forecast and delivery breakdown rows are intentionally not part of this release: `geo_level: "place"` remains invalid on reporting surfaces. Package-state echo provides configuration auditability, but not delivery-by-place verification. Place-level forecast, delivery, pacing, and reconciliation require a follow-up reporting RFC. + +### geo_places_exclude + +- **Description**: Exclude catalog-backed named places +- **Format**: Same as `geo_places` +- **Example**: `[{ "country": "US", "system": "geonames", "place_type": "city", "values": ["5392171"], "value_labels": { "5392171": "San Jose, California, United States" } }]` +- **Note**: Seller must declare the system, exact country/type pair, and applied catalog version in `get_adcp_capabilities` and echo the persisted exclusion on package state. + ### axe_include_segment - **Description**: Segment ID for inclusion targeting (legacy AXE field) - **Format**: String segment identifier @@ -1145,7 +1177,7 @@ To remove all keyword targeting while preserving other overlay fields, send the ### Publishers MUST: -1. **Support Geographic Targeting**: Handle geographic inclusion and exclusion parameters (`geo_countries`, `geo_countries_exclude`, `geo_regions`, `geo_regions_exclude`, `geo_metros`, `geo_metros_exclude`, `geo_postal_areas`, `geo_postal_areas_exclude`) to the extent your platform supports them. Declare supported metro and postal systems in `get_adcp_capabilities` +1. **Support Geographic Targeting**: Handle geographic inclusion and exclusion parameters (`geo_countries`, `geo_countries_exclude`, `geo_regions`, `geo_regions_exclude`, `geo_metros`, `geo_metros_exclude`, `geo_postal_areas`, `geo_postal_areas_exclude`, `geo_places`, `geo_places_exclude`) to the extent your platform supports them. Declare supported metro, postal, and place systems in `get_adcp_capabilities` 2. **Interpret Briefs**: Use briefs to determine appropriate audience and content targeting 3. **Validate Targeting**: Reject media buys with targeting that cannot be supported 4. **Document Limitations**: Clearly communicate any geographic targeting limitations in product descriptions diff --git a/docs/media-buy/task-reference/get_products.mdx b/docs/media-buy/task-reference/get_products.mdx index 0869e2c0e5..e60ae32288 100644 --- a/docs/media-buy/task-reference/get_products.mdx +++ b/docs/media-buy/task-reference/get_products.mdx @@ -281,12 +281,14 @@ targeting filter and the legacy top-level `property_list`. | `countries` | string[] | Deprecated. Use `targeting_overlay.geo_countries`. | | `regions` | string[] | Deprecated. Use `targeting_overlay.geo_regions`. | | `metros` | object[] | Deprecated. Use `targeting_overlay.geo_metros` for known values or `required_overlay_support.geo_metros` for future selection. | +| `places` | object[] | Deprecated. Use `targeting_overlay.geo_places` for known catalog-backed place IDs or `required_overlay_support.geo_places` for future selection. | | `channels` | string[] | Filter by advertising channels (e.g., `["display", "ctv", "social", "streaming_audio"]`). See [Media Channel Taxonomy](/docs/reference/media-channel-taxonomy) | | `video_placement_types` | string[] | Match product metadata when the declared video placement types intersect `instream`, `accompanying_content`, `interstitial`, or `standalone`. Classification only; it does not promise exclusive delivery on a requested type. | | `audio_distribution_types` | string[] | Match product metadata when declared audio distribution types intersect the requested values. Classification only; it does not promise exclusive delivery on a requested type. | | `sponsored_placement_types` | string[] | Match retail-media product metadata when declared sponsored-placement types intersect the requested values. Classification only. | | `social_placement_surfaces` | string[] | Match social-product metadata when declared surfaces intersect `feed`, `stories`, `short_video`, `explore`, or `search`. Classification only; exact public-placement inventory uses `targeting_overlay.placement_selection`. | | `postal_areas` | object[] | Deprecated. Use `targeting_overlay.geo_postal_areas` or `required_overlay_support.geo_postal_areas`. | +| `required_geo_targeting` | object[] | Deprecated. Use `required_overlay_support`. Legacy place entries use `{ level: "place", country, system, place_type, system_version? }`. | | `geo_proximity` | object[] | Deprecated. Use `targeting_overlay.geo_proximity`. | | `keywords` | object[] | Deprecated. Use `targeting_overlay.keyword_targets`; broad thematic intent remains in `brief`. | | `signal_targeting` | SignalTargeting[] | Deprecated. Use `targeting_overlay.signal_targeting_groups` for known selections or `required_overlay_support.signal_targeting_groups` for later selection. | @@ -294,6 +296,41 @@ targeting filter and the legacy top-level `property_list`. | `required_metrics` | string[] ([metric vocabulary](/docs/media-buy/media-buys/optimization-reporting)) | Filter to products whose `reporting_capabilities.available_metrics` is a superset of these metrics — i.e., products that commit to reporting all listed metrics in delivery. Use for capability discovery (e.g., `["completed_views"]` for a CTV CPCV buy). Sellers MUST silently exclude products that cannot meet the list — filter-not-fail; do not return an error. The product's declared `available_metrics` becomes the binding reporting contract carried into the resulting media buy. | | `required_vendor_metrics` | object[] | Filter to products whose `reporting_capabilities.vendor_metrics` covers vendor-defined metrics (proprietary attention, emissions, panel demographics, brand-lift surveys, etc.). Each entry pins `vendor` (BrandRef) and/or `metric_id` — at least one. Cross-vendor discovery (e.g., "any attention measurement") is the buyer agent's responsibility: resolve which vendors offer a category via the vendors' `brand.json` records, then enumerate them as filter entries. Same filter-not-fail semantics as `required_metrics`. | +Known place IDs belong in `targeting_overlay`, so the returned product's +availability, pricing, and forecast are scoped to the effective place +constraint. If the buyer will choose IDs later, `required_overlay_support` +asks for the country, identifier system, place type, and optional catalog +version that the product must let the buyer select: + +```json +{ + "$schema": "/schemas/media-buy/get-products-request.json", + "buying_mode": "brief", + "brief": "Local video inventory for a municipal services campaign", + "targeting_overlay": { + "geo_places": [{ + "country": "NL", + "system": "geonames", + "system_version": "2026-05", + "place_type": "city", + "values": ["2759794"] + }] + }, + "required_overlay_support": { + "geo_places": { + "country": "NL", + "system": "geonames", + "system_version": "2026-05", + "place_type": "city" + } + } +} +``` + +The returned Product `overlay_support.geo_places` is binding permission to +supply matching place IDs on packages later, subject to disclosed limits. It +does not guarantee value-specific inventory before the IDs are provided. + ### Placement fields `get_products` returns product placement data when the seller includes `placements` or the buyer asks for it through `fields`. Placement IDs are publisher-scoped. Product placements should reference the publisher's public `adagents.json` placement declarations with `{publisher_domain, placement_id}` when a publisher declaration exists. Seller-private placement IDs, source/origin details, and delivery-system mappings must stay out of the response. diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx index d345f91b02..a76b498090 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -411,6 +411,7 @@ Buyers discover AXE support via `get_adcp_capabilities` and filter products to A | `geo_regions` | boolean | Region/state-level targeting using ISO 3166-2 codes (e.g., `US-NY`, `GB-SCT`) | | `geo_metros` | object | Metro area targeting with system-specific support | | `geo_postal_areas` | object | Postal area targeting with country and precision support | +| `geo_places` | object | Named-place targeting keyed by collision-safe identifier system, with exact country/type support, catalog versions, and resolver metadata | | `age_restriction` | object | Age restriction capabilities with `supported` flag and `verification_methods` | | `demographics` | object | Seller-wide discovery rollup for canonical demographic targeting. `supported: true` means at least one product supports the surface; inspect each product's `demographic_targeting` declaration for exact execution. | | `language` | boolean | Language targeting (ISO 639-1 codes) | @@ -460,6 +461,95 @@ Sellers that support a geographic targeting level SHOULD support both inclusion Use `postal_code` for the normal postal code string in countries without a more specific registered local system. During the 3.x migration, sellers SHOULD emit equivalent deprecated aliases such as `us_zip` alongside native country keys where an alias exists. Buyers and SDKs SHOULD normalize both shapes before making capability decisions. +**geo_places** is keyed by place identifier system. Registered keys are `geonames`, `google_ads`, and `microsoft_ads`; private or additional catalogs use an owner-controlled absolute HTTPS URI. Each system declares exact country-to-place-type support, exact accepted versions, and a machine-readable resolver: + +```json +{ + "geonames": { + "countries": { + "US": ["city", "county"], + "NL": ["city", "municipality"], + "GB": ["city", "post_town"] + }, + "catalog": { + "source": "https://seller.example/data-sources/geonames-mirror", + "current_version": "2026-05", + "supported_versions": ["2026-05", "2026-04"], + "resolver": { + "url": "https://seller.example/adcp/geo/resolve/geonames", + "auth": "seller_credentials", + "protocol": "adcp_geo_place_resolver_v1" + } + } + }, + "https://seller.example/geo/catalogs/places": { + "countries": { + "NL": ["city"] + }, + "catalog": { + "current_version": "2026-q2", + "supported_versions": ["2026-q2"], + "resolver": { + "url": "https://seller.example/adcp/geo/resolve/private", + "auth": "seller_credentials", + "protocol": "adcp_geo_place_resolver_v1" + } + } + } +} +``` + +If `geo_places` is absent, buyers MUST NOT assume place targeting is available. If present, the seller MUST honor only the explicitly declared country/type pairs and exact `supported_versions`, or return a validation error. `current_version` MUST appear in `supported_versions` and is applied when the buyer omits `system_version` on new targeting. `supported_versions` describes versions accepted for new targets and target-changing updates; removing a version MUST NOT silently mutate, drop, or invalidate existing packages already pinned to that version. Optional `source` identifies a dataset or derivative without creating a new ID namespace—for example, a MaxMind-derived catalog still uses `system: "geonames"`. The resolver accepts an HTTPS GET with `get-geo-place-resolution-request.json` query fields and returns `get-geo-place-resolution-response.json`, including lifecycle status and replacement IDs. Unsupported or deprecated identifiers must be rejected rather than silently ignored or replaced. Display labels are never authoritative targeting keys. + +Registered system semantics are exact: + +| System | Authoritative `values` namespace | Type mapping | +|---|---|---| +| `geonames` | GeoNames `geonameId` integers encoded as strings. MaxMind `geoname_id` uses this same namespace. | The resolver maps GeoNames feature class/code into the registered AdCP type. Populated-place targets such as `PPL`, `PPLA`, `PPLC`, and `PPLG` map to `city`; administrative features map according to their official country-specific meaning. | +| `google_ads` | Google Ads geo target Criterion IDs encoded as strings | Lowercase snake-case mapping of Google target types to the registered AdCP type when one exists; otherwise use an owner-controlled HTTPS type URI. | +| `microsoft_ads` | Microsoft Advertising Location IDs encoded as strings | Lowercase snake-case mapping of Microsoft location types to the registered AdCP type when one exists; otherwise use an owner-controlled HTTPS type URI. | + +Registered place types are semantic classes, not a universal hierarchy. `city`, `municipality`, and `post_town` are distinct; likewise `borough`, `neighborhood`, `quarter`, and `ward` are not interchangeable. The resolver's country-specific mapping is authoritative for the system/version it serves. Sellers MUST NOT claim a country/type pair unless their resolver returns that type and their execution platform can honor it. + +Resolver calls use HTTPS GET. Supply exactly one of `q` for name search or `value` to refresh an existing identifier against a catalog version. For example: + +```text +GET https://seller.example/adcp/geo/resolve/geonames?q=Springfield&country=US&subdivision=US-IL&place_type=city&system_version=2026-05&limit=20 +``` + +`auth: "seller_credentials"` means the buyer uses the same authorization credentials as the seller's AdCP endpoint and is valid only when the resolver has the same origin as that endpoint. Buyers MUST NOT forward seller credentials cross-origin and MUST apply normal SSRF protections to resolver requests. `auth: "none"` declares a public resolver. A URI-valued `system` is an opaque namespace identifier and is not automatically fetched. Results identify the exact system version and lifecycle state: + +```json +{ + "request": { + "q": "Springfield", + "country": "US", + "subdivision": "US-IL", + "place_type": "city", + "system_version": "2026-05", + "limit": 20 + }, + "system": "geonames", + "system_version": "2026-05", + "matches": [{ + "value": "4250542", + "country": "US", + "subdivision": "US-IL", + "place_type": "city", + "label": "Springfield", + "canonical_name": "Springfield, Illinois, United States", + "parent_labels": ["Illinois", "United States"], + "status": "active" + }] +} +``` + +The response `system` MUST equal the capability-map key that advertised the resolver. Its version MUST equal the requested `system_version`, or the advertised `current_version` when omitted. The response echoes the normalized request, and every match MUST equal its `country` and any requested `subdivision` and `place_type`. `canonical_name` and `parent_labels` are required so same-named results remain distinguishable; `subdivision` is also required on a match when the request constrained it. Registered numeric systems reject non-numeric values and replacement IDs. + +Successful searches, including zero matches, return `200` with `Content-Type: application/json` and the response schema above. Invalid queries return `400`; missing or invalid credentials return `401` or `403`; throttling returns `429`; and resolver failures return an appropriate `5xx`. Non-`200` responses MUST NOT be interpreted as an empty result set. Pagination repeats the normalized original request and uses `cursor`/`next_cursor`. + +Buyers MUST traffic only an unambiguous `active` result. `removal_planned` results may still describe an existing target but should not be used for a new buy. `deprecated` results are invalid for new targeting; `replaced_by_values` may guide a new resolution, but sellers MUST require the buyer to submit the replacement rather than silently changing intent. To refresh a persisted ID after catalog rollover, query `value=` against the current version. Existing packages remain pinned to their echoed applied version until the buyer intentionally changes targeting; unrelated updates preserve that overlay. If the seller can no longer execute it, `get_media_buys` MUST preserve the echoed target and return nonfatal `PLACE_TARGET_UNAVAILABLE` in response-level `errors[]`, with `recovery: "correctable"`, an exact package-target `field` path, and target identity in `details`, rather than silently changing geography. + #### audience_targeting Audience targeting capabilities. Presence of this object indicates the seller supports audience targeting, including `sync_audiences` and `audience_include`/`audience_exclude` in targeting overlays. Describes what identifier types the seller accepts for audience matching, size constraints, and expected matching latency. @@ -1103,6 +1193,39 @@ const buy = await client.createMediaBuy({ "CA": ["fsa", "full"], "ZA": ["postal_code"] }, + "geo_places": { + "geonames": { + "countries": { + "US": ["city", "county"], + "NL": ["city", "municipality"], + "GB": ["city", "post_town"] + }, + "catalog": { + "source": "https://seller.example/data-sources/geonames-mirror", + "current_version": "2026-05", + "supported_versions": ["2026-05", "2026-04"], + "resolver": { + "url": "https://seller.example/adcp/geo/resolve/geonames", + "auth": "seller_credentials", + "protocol": "adcp_geo_place_resolver_v1" + } + } + }, + "https://seller.example/geo/catalogs/places": { + "countries": { + "NL": ["city"] + }, + "catalog": { + "current_version": "2026-q2", + "supported_versions": ["2026-q2"], + "resolver": { + "url": "https://seller.example/adcp/geo/resolve/private", + "auth": "seller_credentials", + "protocol": "adcp_geo_place_resolver_v1" + } + } + } + }, "language": true, "keyword_targets": { "supported_match_types": ["broad", "phrase", "exact"] diff --git a/scripts/error-code-drift-dispositions.json b/scripts/error-code-drift-dispositions.json index ee291282b0..7fc2b4892d 100644 --- a/scripts/error-code-drift-dispositions.json +++ b/scripts/error-code-drift-dispositions.json @@ -221,6 +221,11 @@ "target_version": "3.1", "note": "Placement catalog public/private boundary error (#4993). Surfaces when get_products or adagents.json public placements leak seller-private fields such as visibility/source/origin/delivery_mappings so monitoring can alarm distinctly from generic schema failures." }, + "PLACE_TARGET_UNAVAILABLE": { + "disposition": "held-for-next-minor", + "target_version": "3.1", + "note": "Identifier-based place targeting (#5588). Non-fatal get_media_buys resource-state error when a catalog-pinned target can no longer execute; preserves the echoed target and provides a correctable resolver/update path. Wire change — held for 3.1." + }, "FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE": { "disposition": "held-for-next-minor", "target_version": "3.1", diff --git a/static/compliance/source/protocols/media-buy/index.yaml b/static/compliance/source/protocols/media-buy/index.yaml index fef597369d..e75e230fde 100644 --- a/static/compliance/source/protocols/media-buy/index.yaml +++ b/static/compliance/source/protocols/media-buy/index.yaml @@ -17,6 +17,7 @@ requires_scenarios: - media_buy_seller/product_signal_targeting - media_buy_seller/demographic_targeting - media_buy_seller/targeting_aware_discovery + - media_buy_seller/geo_place_targeting - media_buy_seller/available_actions - media_buy_seller/invalid_transitions - media_buy_seller/creative_fate_after_cancellation diff --git a/static/compliance/source/protocols/media-buy/scenarios/geo_place_targeting.yaml b/static/compliance/source/protocols/media-buy/scenarios/geo_place_targeting.yaml new file mode 100644 index 0000000000..fb4452ebde --- /dev/null +++ b/static/compliance/source/protocols/media-buy/scenarios/geo_place_targeting.yaml @@ -0,0 +1,573 @@ +id: media_buy_seller/geo_place_targeting +version: "1.0.0" +title: "Seller resolves, validates, and echoes identifier-based place targeting" +category: media_buy_seller +summary: "Verifies version-aware place discovery, create/update persistence, package-state echo, and deterministic semantic rejection cases." +track: media_buy + +requires_capability: + path: media_buy.execution.targeting.geo_places.geonames.countries.NL + contains: city + +required_tools: + - get_adcp_capabilities + - get_products + - create_media_buy + - update_media_buy + - get_media_buys + - comply_test_controller + +invariants: + - status.monotonic + +narrative: | + Identifier-based place targeting crosses capability discovery, product + discovery, create/update execution, catalog lifecycle, and package audit. + Shape-only validation is insufficient: a seller can accept a valid object and + still silently drop the place, use another catalog release, or reinterpret a + stale identifier. + + This scenario applies only to sellers declaring GeoNames city support in the + Netherlands. It captures the seller's current catalog version, requires that + exact version during product discovery, creates a buy targeting the active + GeoNames Amsterdam city ID, reads the buy back to verify the complete applied + tuple, adds The Hague as an exclusion through update_media_buy, and verifies + both persisted lists. A final negative probe sends a well-formed but unknown + GeoNames ID and requires rejection rather than silent dropping. + + The current compliance harness verifies the resolver declaration and schema + contract but does not make an arbitrary HTTP call to the advertised URL. + Place reporting is deliberately not asserted. Package-state echo is the + configuration-audit contract until a follow-up reporting RFC adds + geo_level=place forecast and delivery rows. + +agent: + interaction_model: media_buy_seller + capabilities: + - sells_media + - supports_non_guaranteed + - identifier_based_place_targeting + examples: + - "Sales agents mapping portable place identifiers into platform-native geographic targets" + +caller: + role: buyer_agent + example: "Pinnacle Agency (buyer)" + +prerequisites: + description: | + The seller declares geonames/NL/city support and exposes its current version + through get_adcp_capabilities. The product and auction pricing fixtures are + seeded through comply_test_controller. GeoNames IDs 2759794 (Amsterdam) and + 2747373 (The Hague) are external catalog identifiers, not seller-local + fixture IDs. + test_kit: "test-kits/acme-outdoor.yaml" + controller_seeding: true + +fixtures: + products: + - product_id: "geo_place_display_nl" + delivery_type: "non_guaranteed" + channels: ["display"] + format_ids: + - id: "display_300x250" + pricing_options: + - product_id: "geo_place_display_nl" + pricing_option_id: "geo_place_auction_cpm" + pricing_model: "cpm" + currency: "EUR" + floor_price: 3.00 + +phases: + - id: discover_capability_and_product + title: "Discover exact place support before create" + steps: + - id: get_place_capabilities + title: "Read place system and catalog version" + task: get_adcp_capabilities + schema_ref: "protocol/get-adcp-capabilities-request.json" + response_schema_ref: "protocol/get-adcp-capabilities-response.json" + doc_ref: "/protocol/get_adcp_capabilities" + stateful: false + expected: | + Return geonames support with NL containing city, a current version that + is also in supported_versions, and an adcp_geo_place_resolver_v1 + endpoint. + sample_request: + context: + correlation_id: "geo_place_targeting--get_place_capabilities" + context_outputs: + - key: "place_system_version" + path: "media_buy.execution.targeting.geo_places.geonames.catalog.current_version" + validations: + - check: response_schema + description: "Response matches get-adcp-capabilities-response.json" + - check: field_contains + path: "media_buy.execution.targeting.geo_places.geonames.countries.NL[*]" + value: "city" + description: "Seller explicitly supports the NL/city pair" + - check: field_present + path: "media_buy.execution.targeting.geo_places.geonames.catalog.resolver.url" + description: "Seller exposes a machine-readable resolver" + - check: field_contains + path: "media_buy.execution.targeting.geo_places.geonames.catalog.supported_versions[*]" + value: "$context.place_system_version" + description: "current_version is included in supported_versions" + + - id: get_place_targetable_product + title: "Filter products by exact place capability" + task: get_products + schema_ref: "media-buy/get-products-request.json" + response_schema_ref: "media-buy/get-products-response.json" + doc_ref: "/media-buy/task-reference/get_products" + stateful: true + expected: | + Return the seeded product because the seller can enforce the exact + geonames/NL/city/current-version capability. + sample_request: + buying_mode: "wholesale" + filters: + channels: ["display"] + is_fixed_price: false + required_geo_targeting: + - level: "place" + country: "NL" + system: "geonames" + system_version: "$context.place_system_version" + place_type: "city" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + context: + correlation_id: "geo_place_targeting--get_place_targetable_product" + context_outputs: + - key: "product_id" + path: "products[0].product_id" + - key: "pricing_option_id" + path: "products[0].pricing_options[0].pricing_option_id" + validations: + - check: response_schema + description: "Response matches get-products-response.json" + - check: field_present + path: "products[0].product_id" + description: "At least one matching product is returned" + + - id: create_and_echo_place + title: "Create and read back applied place targeting" + steps: + - id: create_place_targeted_buy + title: "Create buy targeting Amsterdam" + task: create_media_buy + schema_ref: "media-buy/create-media-buy-request.json" + response_schema_ref: "media-buy/create-media-buy-response.json" + doc_ref: "/media-buy/task-reference/create_media_buy" + stateful: true + expected: | + Accept the active GeoNames city identifier and persist the exact + country/system/version/type/value tuple. + sample_request: + brand: + domain: "acmeoutdoor.example" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + start_time: "asap" + end_time: "2099-09-30T23:59:59Z" + packages: + - product_id: "$context.product_id" + pricing_option_id: "$context.pricing_option_id" + bid_price: 4.25 + budget: 12000 + targeting_overlay: + geo_places: + - country: "NL" + system: "geonames" + place_type: "city" + values: ["2759794"] + value_labels: + "2759794": "Amsterdam, North Holland, Netherlands" + idempotency_key: "$generate:uuid_v4#geo_place_targeting_create" + context: + correlation_id: "geo_place_targeting--create_place_targeted_buy" + context_outputs: + - key: "media_buy_id" + path: "media_buy_id" + - key: "package_id" + path: "packages[0].package_id" + validations: + - check: response_schema + description: "Response matches create-media-buy-response.json" + - check: field_present + path: "media_buy_id" + description: "Seller assigns a media_buy_id" + + - id: get_after_create + title: "Verify complete applied tuple after create" + task: get_media_buys + schema_ref: "media-buy/get-media-buys-request.json" + response_schema_ref: "media-buy/get-media-buys-response.json" + doc_ref: "/media-buy/task-reference/get_media_buys" + stateful: true + expected: | + Echo geo_places with the exact applied version and identifier, proving + the seller persisted rather than merely parsed the target. + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + media_buy_ids: ["$context.media_buy_id"] + context: + correlation_id: "geo_place_targeting--get_after_create" + validations: + - check: response_schema + description: "Response matches get-media-buys-response.json" + - check: field_value + path: "media_buys[0].packages[0].targeting_overlay.geo_places[0].country" + value: "NL" + description: "Applied country is echoed" + - check: field_value + path: "media_buys[0].packages[0].targeting_overlay.geo_places[0].system" + value: "geonames" + description: "Applied system is echoed" + - check: field_value + path: "media_buys[0].packages[0].targeting_overlay.geo_places[0].system_version" + value: "$context.place_system_version" + description: "Applied catalog version is echoed" + - check: field_value + path: "media_buys[0].packages[0].targeting_overlay.geo_places[0].place_type" + value: "city" + description: "Applied place type is echoed" + - check: field_contains + path: "media_buys[0].packages[0].targeting_overlay.geo_places[0].values[*]" + value: "2759794" + description: "Amsterdam GeoNames ID is echoed" + + - id: update_and_echo_exclusion + title: "Add a place exclusion and verify replacement semantics" + steps: + - id: update_place_exclusion + title: "Exclude The Hague" + task: update_media_buy + schema_ref: "media-buy/update-media-buy-request.json" + response_schema_ref: "media-buy/update-media-buy-response.json" + doc_ref: "/media-buy/task-reference/update_media_buy" + stateful: true + expected: | + Replace the package overlay with the Amsterdam inclusion plus The Hague + exclusion, preserving exact catalog identity on both entries. + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + media_buy_id: "$context.media_buy_id" + packages: + - package_id: "$context.package_id" + targeting_overlay: + geo_places: + - country: "NL" + system: "geonames" + system_version: "$context.place_system_version" + place_type: "city" + values: ["2759794"] + geo_places_exclude: + - country: "NL" + system: "geonames" + system_version: "$context.place_system_version" + place_type: "city" + values: ["2747373"] + idempotency_key: "$generate:uuid_v4#geo_place_targeting_update" + context: + correlation_id: "geo_place_targeting--update_place_exclusion" + validations: + - check: response_schema + description: "Response matches update-media-buy-response.json" + - check: field_present + path: "affected_packages" + description: "Update reports affected package state" + - check: field_contains + path: "affected_packages[*]" + value: + package_id: "$context.package_id" + targeting_overlay: + geo_places: + - country: "NL" + system: "geonames" + system_version: "$context.place_system_version" + place_type: "city" + values: ["2759794"] + geo_places_exclude: + - country: "NL" + system: "geonames" + system_version: "$context.place_system_version" + place_type: "city" + values: ["2747373"] + description: "Affected package carries complete post-update place targeting" + + - id: get_after_update + title: "Verify inclusion and exclusion persisted" + task: get_media_buys + schema_ref: "media-buy/get-media-buys-request.json" + response_schema_ref: "media-buy/get-media-buys-response.json" + doc_ref: "/media-buy/task-reference/get_media_buys" + stateful: true + expected: | + Echo both applied place lists with the exact version and values. + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + media_buy_ids: ["$context.media_buy_id"] + context: + correlation_id: "geo_place_targeting--get_after_update" + validations: + - check: response_schema + description: "Response matches get-media-buys-response.json" + - check: field_contains + path: "media_buys[0].packages[0].targeting_overlay.geo_places[0].values[*]" + value: "2759794" + description: "Amsterdam inclusion persisted" + - check: field_contains + path: "media_buys[0].packages[0].targeting_overlay.geo_places_exclude[0].values[*]" + value: "2747373" + description: "The Hague exclusion persisted" + - check: field_value + path: "media_buys[0].packages[0].targeting_overlay.geo_places_exclude[0].system_version" + value: "$context.place_system_version" + description: "Exclusion applied version is echoed" + + - id: exclusion_only_place_targeting + title: "Apply a place exclusion without a place inclusion" + depends_on: [discover_capability_and_product] + steps: + - id: create_exclusion_only_buy + title: "Create a buy excluding The Hague" + task: create_media_buy + schema_ref: "media-buy/create-media-buy-request.json" + response_schema_ref: "media-buy/create-media-buy-response.json" + doc_ref: "/media-buy/task-reference/create_media_buy" + stateful: true + expected: | + Accept geo_places_exclude without geo_places. The exclusion subtracts + from the package's otherwise eligible geography; it does not require + a place inclusion set. + sample_request: + brand: + domain: "acmeoutdoor.example" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + start_time: "asap" + end_time: "2099-09-30T23:59:59Z" + packages: + - product_id: "$context.product_id" + pricing_option_id: "$context.pricing_option_id" + bid_price: 4.25 + budget: 5000 + targeting_overlay: + geo_places_exclude: + - country: "NL" + system: "geonames" + place_type: "city" + values: ["2747373"] + idempotency_key: "$generate:uuid_v4#geo_place_targeting_exclusion_only" + context: + correlation_id: "geo_place_targeting--create_exclusion_only_buy" + context_outputs: + - key: "exclusion_only_media_buy_id" + path: "media_buy_id" + validations: + - check: response_schema + description: "Response matches create-media-buy-response.json" + - check: field_present + path: "media_buy_id" + description: "Seller creates the exclusion-only buy" + + - id: get_exclusion_only_buy + title: "Verify exclusion-only targeting persisted" + task: get_media_buys + schema_ref: "media-buy/get-media-buys-request.json" + response_schema_ref: "media-buy/get-media-buys-response.json" + doc_ref: "/media-buy/task-reference/get_media_buys" + stateful: true + expected: | + Echo the exclusion with the applied current catalog version and no + synthetic geo_places inclusion. + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + media_buy_ids: ["$context.exclusion_only_media_buy_id"] + context: + correlation_id: "geo_place_targeting--get_exclusion_only_buy" + validations: + - check: response_schema + description: "Response matches get-media-buys-response.json" + - check: field_contains + path: "media_buys[0].packages[0].targeting_overlay.geo_places_exclude[0].values[*]" + value: "2747373" + description: "The Hague exclusion persisted without an inclusion" + - check: field_value + path: "media_buys[0].packages[0].targeting_overlay.geo_places_exclude[0].system_version" + value: "$context.place_system_version" + description: "Seller echoes the defaulted current catalog version" + + - id: reject_invalid_place_targeting + title: "Reject invalid place targeting semantics" + depends_on: [discover_capability_and_product] + steps: + - id: create_with_unknown_geonames_id + title: "Submit an unknown GeoNames identifier" + task: create_media_buy + schema_ref: "media-buy/create-media-buy-request.json" + response_schema_ref: "media-buy/create-media-buy-response.json" + doc_ref: "/media-buy/task-reference/create_media_buy" + expect_error: true + negative_path: payload_well_formed + stateful: false + expected: | + Reject with INVALID_REQUEST and identify the offending geo_places value. + The seller must not silently drop, approximate, or reinterpret it. + sample_request: + brand: + domain: "acmeoutdoor.example" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + start_time: "asap" + end_time: "2099-09-30T23:59:59Z" + packages: + - product_id: "$context.product_id" + pricing_option_id: "$context.pricing_option_id" + bid_price: 4.25 + budget: 5000 + targeting_overlay: + geo_places: + - country: "NL" + system: "geonames" + system_version: "$context.place_system_version" + place_type: "city" + values: ["0"] + idempotency_key: "$generate:uuid_v4#geo_place_targeting_unknown_id" + context: + correlation_id: "geo_place_targeting--create_with_unknown_geonames_id" + validations: + - check: response_schema + description: "Error response matches create-media-buy-response.json" + - check: error_code + expected: "INVALID_REQUEST" + description: "Unknown place identifier is a correctable request error" + - check: field_present + path: "errors[0].field" + description: "Error points to the offending place target" + + - id: create_with_mismatched_value_label + title: "Submit a diagnostic label for a value not being targeted" + task: create_media_buy + schema_ref: "media-buy/create-media-buy-request.json" + response_schema_ref: "media-buy/create-media-buy-response.json" + doc_ref: "/media-buy/task-reference/create_media_buy" + expect_error: true + negative_path: payload_well_formed + stateful: false + expected: | + Reject with INVALID_REQUEST. value_labels keys must be a subset of + values even though draft-07 cannot encode that sibling-key constraint. + sample_request: + brand: + domain: "acmeoutdoor.example" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + start_time: "asap" + end_time: "2099-09-30T23:59:59Z" + packages: + - product_id: "$context.product_id" + pricing_option_id: "$context.pricing_option_id" + bid_price: 4.25 + budget: 5000 + targeting_overlay: + geo_places: + - country: "NL" + system: "geonames" + system_version: "$context.place_system_version" + place_type: "city" + values: ["2759794"] + value_labels: + "2747373": "The Hague, South Holland, Netherlands" + idempotency_key: "$generate:uuid_v4#geo_place_targeting_bad_label" + context: + correlation_id: "geo_place_targeting--create_with_mismatched_value_label" + validations: + - check: response_schema + description: "Error response matches create-media-buy-response.json" + - check: error_code + expected: "INVALID_REQUEST" + description: "Mismatched diagnostic label is rejected" + + - id: create_with_include_exclude_overlap + title: "Submit the same place in include and exclude" + task: create_media_buy + schema_ref: "media-buy/create-media-buy-request.json" + response_schema_ref: "media-buy/create-media-buy-response.json" + doc_ref: "/media-buy/task-reference/create_media_buy" + expect_error: true + negative_path: payload_well_formed + stateful: false + expected: | + Reject with INVALID_REQUEST rather than choosing an undocumented + precedence for the same applied place identity. + sample_request: + brand: + domain: "acmeoutdoor.example" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + start_time: "asap" + end_time: "2099-09-30T23:59:59Z" + packages: + - product_id: "$context.product_id" + pricing_option_id: "$context.pricing_option_id" + bid_price: 4.25 + budget: 5000 + targeting_overlay: + geo_places: + - country: "NL" + system: "geonames" + system_version: "$context.place_system_version" + place_type: "city" + values: ["2759794"] + geo_places_exclude: + - country: "NL" + system: "geonames" + system_version: "$context.place_system_version" + place_type: "city" + values: ["2759794"] + idempotency_key: "$generate:uuid_v4#geo_place_targeting_overlap" + context: + correlation_id: "geo_place_targeting--create_with_include_exclude_overlap" + validations: + - check: response_schema + description: "Error response matches create-media-buy-response.json" + - check: error_code + expected: "INVALID_REQUEST" + description: "Same-value include/exclude overlap is rejected" diff --git a/static/schemas/source/core/geo-place-area.json b/static/schemas/source/core/geo-place-area.json new file mode 100644 index 0000000000..e43eb2f372 --- /dev/null +++ b/static/schemas/source/core/geo-place-area.json @@ -0,0 +1,72 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/geo-place-area.json", + "title": "Geographic Place Area", + "description": "A catalog-backed named place target. Values are stable identifiers in the declared system. Entries within geo_places form a union; different geographic inclusion dimensions intersect. value_labels are diagnostic only and MUST NOT be used to resolve targeting.", + "x-adcp-validation": { + "map_keys_subset_of_array": { "map_field": "value_labels", "array_field": "values" }, + "description": "Every value_labels key MUST also appear in values. JSON Schema draft-07 cannot express this sibling-field key membership constraint, so conformance tooling must enforce it." + }, + "type": "object", + "properties": { + "country": { + "type": "string", + "pattern": "^[A-Z]{2}$", + "description": "ISO 3166-1 alpha-2 country code containing the place." + }, + "system": { + "$ref": "/schemas/core/geo-place-system.json" + }, + "system_version": { + "type": "string", + "minLength": 1, + "description": "Optional exact catalog version from the seller's declared supported_versions. When omitted, the seller applies catalog.current_version and MUST echo that version on persisted package state." + }, + "place_type": { + "$ref": "/schemas/core/geo-place-type.json" + }, + "values": { + "type": "array", + "description": "Stable place identifiers in the declared system. Display names are not valid targeting values.", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1, + "uniqueItems": true + }, + "value_labels": { + "type": "object", + "description": "Optional human-readable diagnostic labels keyed by identifiers present in values. Extra keys are a conformance error. Labels are non-authoritative and MUST NOT be used to resolve or apply targeting.", + "additionalProperties": { + "type": "string", + "minLength": 1 + } + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "required": ["country", "system", "place_type", "values"], + "allOf": [ + { + "if": { + "properties": { + "system": { "enum": ["geonames", "google_ads", "microsoft_ads"] } + }, + "required": ["system"] + }, + "then": { + "properties": { + "values": { + "items": { + "type": "string", + "pattern": "^[0-9]+$" + } + } + } + } + } + ], + "additionalProperties": false +} diff --git a/static/schemas/source/core/geo-place-catalog-capability.json b/static/schemas/source/core/geo-place-catalog-capability.json new file mode 100644 index 0000000000..f4e1f3ad9c --- /dev/null +++ b/static/schemas/source/core/geo-place-catalog-capability.json @@ -0,0 +1,36 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/geo-place-catalog-capability.json", + "title": "Geographic Place Catalog Capability", + "description": "Catalog version and resolution contract for one supported place identifier system. Versions are exact opaque strings, not ordered ranges.", + "type": "object", + "properties": { + "source": { + "type": "string", + "format": "uri", + "pattern": "^https://", + "description": "Optional catalog dataset or derivative-source identifier. This records provenance/coverage and does not change the identifier namespace in system." + }, + "current_version": { + "type": "string", + "minLength": 1, + "description": "Version applied when a buyer omits system_version. Sellers MUST echo this version on persisted package state." + }, + "supported_versions": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "uniqueItems": true, + "description": "Exact catalog versions accepted for new targeting or target-changing updates. Must include current_version. Removing a version does not mutate or silently invalidate targets already pinned to it." + }, + "resolver": { + "$ref": "/schemas/core/geo-place-resolver.json" + } + }, + "required": ["current_version", "supported_versions", "resolver"], + "additionalProperties": false, + "x-adcp-validation": { + "member_of": { "field": "current_version", "array_field": "supported_versions" }, + "description": "JSON Schema draft-07 cannot express scalar membership in a sibling array; conformance tooling MUST verify current_version is present in supported_versions." + } +} diff --git a/static/schemas/source/core/geo-place-catalog-entry.json b/static/schemas/source/core/geo-place-catalog-entry.json new file mode 100644 index 0000000000..d307e6062c --- /dev/null +++ b/static/schemas/source/core/geo-place-catalog-entry.json @@ -0,0 +1,63 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/geo-place-catalog-entry.json", + "title": "Geographic Place Catalog Entry", + "description": "One resolver result for a stable place identifier, including lifecycle state. Labels and parent_labels are diagnostic only; value is the authoritative targeting key.", + "type": "object", + "properties": { + "value": { + "type": "string", + "minLength": 1, + "description": "Stable identifier in the response system and system_version." + }, + "country": { + "type": "string", + "pattern": "^[A-Z]{2}$" + }, + "subdivision": { + "type": "string", + "pattern": "^[A-Z]{2}-[A-Z0-9]{1,3}$", + "description": "ISO 3166-2 subdivision containing the place when the catalog has a subdivision mapping. Required by resolver semantics when the request constrained subdivision." + }, + "place_type": { + "$ref": "/schemas/core/geo-place-type.json" + }, + "label": { + "type": "string", + "minLength": 1, + "description": "Human-readable display label; never an authoritative targeting key." + }, + "canonical_name": { + "type": "string", + "minLength": 1, + "description": "Required fully qualified display name suitable for distinguishing same-named results." + }, + "parent_labels": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "description": "Ordered human-readable parent hierarchy for disambiguation only. Must include at least the containing country." + }, + "status": { + "type": "string", + "enum": ["active", "removal_planned", "deprecated"] + }, + "replaced_by_values": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "uniqueItems": true, + "description": "Replacement identifiers in the same system and response system_version." + }, + "valid_until": { + "type": "string", + "format": "date-time", + "description": "Known time after which the identifier must no longer be accepted for new targeting." + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "required": ["value", "country", "place_type", "label", "canonical_name", "parent_labels", "status"], + "additionalProperties": false +} diff --git a/static/schemas/source/core/geo-place-resolver.json b/static/schemas/source/core/geo-place-resolver.json new file mode 100644 index 0000000000..453b5b5102 --- /dev/null +++ b/static/schemas/source/core/geo-place-resolver.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/geo-place-resolver.json", + "title": "Geographic Place Resolver", + "description": "Machine-readable HTTP resolver for converting unresolved place names into seller-accepted stable identifiers or refreshing an existing identifier. Clients send an HTTPS GET to url using the query parameters in get-geo-place-resolution-request.json and validate a 200 application/json response against get-geo-place-resolution-response.json. Non-200 responses are errors, not empty results. Clients MUST apply SSRF protections. seller_credentials may be used only when url has the same origin as the seller's AdCP endpoint.", + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "pattern": "^https://", + "description": "HTTPS endpoint accepting the standard geo-place resolution query parameters." + }, + "auth": { + "type": "string", + "enum": ["none", "seller_credentials"], + "description": "Authentication mode. seller_credentials means use the same authorization credentials as the seller's AdCP endpoint and is valid only for a same-origin resolver URL; credentials MUST NOT be forwarded cross-origin." + }, + "protocol": { + "type": "string", + "const": "adcp_geo_place_resolver_v1", + "description": "Resolver request/response contract version." + } + }, + "required": ["url", "auth", "protocol"], + "additionalProperties": false +} diff --git a/static/schemas/source/core/geo-place-support.json b/static/schemas/source/core/geo-place-support.json new file mode 100644 index 0000000000..9279de60e2 --- /dev/null +++ b/static/schemas/source/core/geo-place-support.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/geo-place-support.json", + "title": "Geographic Place System Support", + "description": "Declares exact country-to-place-type support plus catalog resolution and version metadata for one place identifier system. The country map avoids falsely implying a Cartesian product of countries and types.", + "type": "object", + "properties": { + "countries": { + "type": "object", + "description": "Supported place types keyed by ISO 3166-1 alpha-2 country. Only explicitly listed country/type pairs are supported.", + "propertyNames": { + "pattern": "^[A-Z]{2}$" + }, + "additionalProperties": { + "type": "array", + "items": { + "$ref": "/schemas/core/geo-place-type.json" + }, + "minItems": 1, + "uniqueItems": true + }, + "minProperties": 1 + }, + "catalog": { + "$ref": "/schemas/core/geo-place-catalog-capability.json" + } + }, + "required": ["countries", "catalog"], + "additionalProperties": false +} diff --git a/static/schemas/source/core/geo-place-system.json b/static/schemas/source/core/geo-place-system.json new file mode 100644 index 0000000000..dbfed0c308 --- /dev/null +++ b/static/schemas/source/core/geo-place-system.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/geo-place-system.json", + "title": "Geographic Place Identifier System", + "description": "Collision-safe identifier namespace for geographic places. Registered tokens have protocol-defined semantics. Unregistered systems MUST use an absolute HTTPS URI controlled by the catalog owner; consumers compare URI systems as exact opaque strings.", + "anyOf": [ + { + "type": "string", + "enum": ["geonames", "google_ads", "microsoft_ads"] + }, + { + "type": "string", + "format": "uri", + "pattern": "^https://" + } + ], + "examples": ["geonames", "google_ads", "microsoft_ads", "https://seller.example/geo/catalogs/places"] +} diff --git a/static/schemas/source/core/geo-place-type.json b/static/schemas/source/core/geo-place-type.json new file mode 100644 index 0000000000..314647580b --- /dev/null +++ b/static/schemas/source/core/geo-place-type.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/geo-place-type.json", + "title": "Geographic Place Type", + "description": "Canonical place classification. Registered tokens have protocol-defined meanings. Catalog-specific classifications without a registered mapping MUST use an absolute HTTPS URI controlled by the vocabulary owner; consumers compare URI types as exact opaque strings.", + "anyOf": [ + { + "type": "string", + "enum": [ + "airport", + "borough", + "city", + "city_region", + "commune", + "county", + "district", + "municipality", + "neighborhood", + "post_town", + "prefecture", + "province", + "quarter", + "state", + "territory", + "ward" + ] + }, + { + "type": "string", + "format": "uri", + "pattern": "^https://" + } + ], + "examples": ["city", "municipality", "borough", "neighborhood", "post_town", "city_region", "county", "https://seller.example/geo/place-types/trade-area"] +} diff --git a/static/schemas/source/core/get-geo-place-resolution-request.json b/static/schemas/source/core/get-geo-place-resolution-request.json new file mode 100644 index 0000000000..e777aebdcc --- /dev/null +++ b/static/schemas/source/core/get-geo-place-resolution-request.json @@ -0,0 +1,59 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/get-geo-place-resolution-request.json", + "title": "Get Geographic Place Resolution Request", + "description": "Read-only query parameters for a geo-place resolver. Supply exactly one of q (unresolved human intent) or value (an existing identifier to refresh). Country is mandatory. Optional subdivision, place_type, system_version, and locale further constrain matching.", + "type": "object", + "properties": { + "q": { + "type": "string", + "minLength": 1, + "description": "Unresolved place name or alias supplied by the user." + }, + "value": { + "type": "string", + "minLength": 1, + "description": "Existing identifier to look up in the requested/current catalog version for lifecycle refresh or replacement discovery. Mutually exclusive with q." + }, + "country": { + "type": "string", + "pattern": "^[A-Z]{2}$", + "description": "ISO 3166-1 alpha-2 country code used to disambiguate the query." + }, + "subdivision": { + "type": "string", + "pattern": "^[A-Z]{2}-[A-Z0-9]{1,3}$", + "description": "Optional ISO 3166-2 subdivision constraint." + }, + "place_type": { + "$ref": "/schemas/core/geo-place-type.json" + }, + "system_version": { + "type": "string", + "minLength": 1, + "description": "Optional exact supported catalog version to search." + }, + "locale": { + "type": "string", + "pattern": "^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$", + "description": "Optional BCP 47 language tag for returned labels." + }, + "cursor": { + "type": "string", + "minLength": 1 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + }, + "required": ["country"], + "anyOf": [ + { "required": ["q"] }, + { "required": ["value"] } + ], + "not": { "required": ["q", "value"] }, + "additionalProperties": false +} diff --git a/static/schemas/source/core/get-geo-place-resolution-response.json b/static/schemas/source/core/get-geo-place-resolution-response.json new file mode 100644 index 0000000000..aeb2c68827 --- /dev/null +++ b/static/schemas/source/core/get-geo-place-resolution-response.json @@ -0,0 +1,64 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/get-geo-place-resolution-response.json", + "title": "Get Geographic Place Resolution Response", + "description": "Paginated read-only resolver response containing seller-accepted place identifiers from one exact system version and echoing the request used to produce them.", + "x-adcp-validation": { + "resolver_response_binding": { + "system": "capability_key", + "system_version": "request.system_version_or_catalog.current_version", + "match_fields": ["country", "subdivision", "place_type"] + }, + "description": "The response system MUST equal the geo_places capability key for the resolver. system_version MUST equal the requested version, or catalog.current_version when omitted. Every match MUST equal request country and any requested subdivision/place_type. For a value lookup, the result MUST describe that value or its lifecycle replacement; for q search, the seller may apply documented text matching." + }, + "type": "object", + "properties": { + "request": { + "$ref": "/schemas/core/get-geo-place-resolution-request.json", + "description": "Exact normalized request represented by this page. Pagination responses repeat the original query fields and may carry the page cursor." + }, + "system": { + "$ref": "/schemas/core/geo-place-system.json" + }, + "system_version": { + "type": "string", + "minLength": 1 + }, + "matches": { + "type": "array", + "items": { + "$ref": "/schemas/core/geo-place-catalog-entry.json" + } + }, + "next_cursor": { + "type": "string", + "minLength": 1 + } + }, + "required": ["request", "system", "system_version", "matches"], + "allOf": [ + { + "if": { + "properties": { + "system": { "enum": ["geonames", "google_ads", "microsoft_ads"] } + }, + "required": ["system"] + }, + "then": { + "properties": { + "matches": { + "items": { + "properties": { + "value": { "pattern": "^[0-9]+$" }, + "replaced_by_values": { + "items": { "pattern": "^[0-9]+$" } + } + } + } + } + } + } + } + ], + "additionalProperties": false +} diff --git a/static/schemas/source/core/product-filters.json b/static/schemas/source/core/product-filters.json index 0bd1f70707..d340be76a1 100644 --- a/static/schemas/source/core/product-filters.json +++ b/static/schemas/source/core/product-filters.json @@ -127,6 +127,15 @@ }, "minItems": 1 }, + "places": { + "type": "array", + "description": "DEPRECATED. Use get_products.targeting_overlay.geo_places for known values or required_overlay_support.geo_places when values will be supplied on packages later.", + "deprecated": true, + "items": { + "$ref": "/schemas/core/geo-place-area.json" + }, + "minItems": 1 + }, "channels": { "type": "array", "description": "Filter by advertising channels (e.g., ['display', 'ctv', 'dooh'])", @@ -231,23 +240,30 @@ }, "required_geo_targeting": { "type": "array", - "description": "DEPRECATED. Use get_products.required_overlay_support, which is product-scoped and applies consistently to geographic and non-geographic targeting dimensions.", + "description": "DEPRECATED. Use get_products.required_overlay_support, which is product-scoped and applies consistently to geographic and non-geographic targeting dimensions. Legacy place entries require country, system, and place_type and may require an exact system_version.", "deprecated": true, "items": { "type": "object", "properties": { "level": { - "$ref": "/schemas/enums/geo-level.json", - "description": "Geographic targeting level (country, region, metro, postal_area)" + "$ref": "/schemas/enums/geo-targeting-level.json" }, "country": { "type": "string", "pattern": "^[A-Z]{2}$", - "description": "ISO 3166-1 alpha-2 country code. Required for native postal_area system filters; not applicable to country, region, or metro filters." + "description": "ISO 3166-1 alpha-2 country code. Required for native postal_area system filters and place filters; not applicable to country, region, or metro filters." }, "system": { "type": "string", - "description": "Optional classification system within the level. Use for a specific metro system (e.g., 'nielsen_dma'), native postal_area system (e.g., 'zip' with country 'US'), or deprecated legacy postal alias (e.g., 'us_zip'). Not applicable for country/region which use ISO standards." + "description": "Optional classification system within the level. Use for a specific metro system (e.g., 'nielsen_dma'), native postal_area system (e.g., 'zip' with country 'US'), deprecated legacy postal alias (e.g., 'us_zip'), or place identifier namespace (e.g., 'geonames'). Not applicable for country/region which use ISO standards." + }, + "place_type": { + "$ref": "/schemas/core/geo-place-type.json" + }, + "system_version": { + "type": "string", + "minLength": 1, + "description": "Exact place catalog version required for level=place. Not applicable to other levels." } }, "required": ["level"], @@ -258,12 +274,12 @@ "required": ["level"] }, "then": { - "not": { - "anyOf": [ - { "required": ["country"] }, - { "required": ["system"] } - ] - } + "not": { "anyOf": [ + { "required": ["country"] }, + { "required": ["system"] }, + { "required": ["place_type"] }, + { "required": ["system_version"] } + ] } } }, { @@ -283,7 +299,11 @@ "required": ["system"] } ], - "not": { "required": ["country"] } + "not": { "anyOf": [ + { "required": ["country"] }, + { "required": ["place_type"] }, + { "required": ["system_version"] } + ] } } }, { @@ -315,6 +335,30 @@ } ] } + }, + { + "if": { + "properties": { "level": { "const": "postal_area" } }, + "required": ["level"] + }, + "then": { + "not": { "anyOf": [ + { "required": ["place_type"] }, + { "required": ["system_version"] } + ] } + } + }, + { + "if": { + "properties": { "level": { "const": "place" } }, + "required": ["level"] + }, + "then": { + "properties": { + "system": { "$ref": "/schemas/core/geo-place-system.json" } + }, + "required": ["country", "system", "place_type"] + } } ], "additionalProperties": false diff --git a/static/schemas/source/core/targeting.json b/static/schemas/source/core/targeting.json index eb64bbcd84..627a694429 100644 --- a/static/schemas/source/core/targeting.json +++ b/static/schemas/source/core/targeting.json @@ -3,6 +3,14 @@ "$id": "/schemas/core/targeting.json", "title": "Targeting Overlay", "description": "Concrete delivery constraints used during product discovery and media-buy execution. Targeting includes both audience eligibility and purchased-inventory selection: geography, demographics, placements, properties, collections, device compatibility, language, keywords, and other explicit controls. Sellers resolve requested outcomes against inherent product scope or selectable execution. A fresh create/update request MUST be applied exactly or rejected; get_products may instead return a request-scoped configured product with sparse, buyer-reviewable targeting_resolution modifications.", + "x-adcp-validation": { + "disjoint_place_fields": { + "include": "geo_places", + "exclude": "geo_places_exclude", + "identity": ["country", "system", "place_type", "value"] + }, + "description": "JSON Schema draft-07 cannot compare values across sibling arrays. Conformance tooling MUST reject any place identity present in both geo_places and geo_places_exclude. Catalog version is deliberately not part of identity: a stable ID cannot be both included and excluded by assigning different versions." + }, "type": "object", "properties": { "geo_countries": { @@ -105,6 +113,22 @@ }, "minItems": 1 }, + "geo_places": { + "type": "array", + "description": "Restrict delivery to catalog-backed named places. Values MUST be stable identifiers in the declared system, not display names. Sellers must declare supported systems, countries, and place types in get_adcp_capabilities and reject unsupported entries rather than silently dropping them.", + "items": { + "$ref": "/schemas/core/geo-place-area.json" + }, + "minItems": 1 + }, + "geo_places_exclude": { + "type": "array", + "description": "Exclude catalog-backed named places. Uses the same identifier-based shape as geo_places. Sellers MUST reject overlap with geo_places for the same country, system, place_type, and value.", + "items": { + "$ref": "/schemas/core/geo-place-area.json" + }, + "minItems": 1 + }, "daypart_targets": { "type": "array", "description": "Restrict delivery to specific time windows. Each entry specifies days of week and an hour range.", diff --git a/static/schemas/source/enums/error-code.json b/static/schemas/source/enums/error-code.json index 2e3d2dbf6e..c3a84b182d 100644 --- a/static/schemas/source/enums/error-code.json +++ b/static/schemas/source/enums/error-code.json @@ -46,6 +46,7 @@ "MEDIA_BUY_NOT_FOUND", "NOT_CANCELLABLE", "PACKAGE_NOT_FOUND", + "PLACE_TARGET_UNAVAILABLE", "CREATIVE_NOT_FOUND", "SIGNAL_NOT_FOUND", "SIGNAL_TARGETING_INCOMPATIBLE", @@ -145,6 +146,7 @@ "MEDIA_BUY_NOT_FOUND": "Referenced media buy does not exist or is not accessible to the requesting agent. Recovery: correctable (verify media_buy_id; when recovering across legacy sellers or missing echoed IDs, reconcile via get_media_buys and the opaque request/response context correlation handle, such as context.internal_campaign_id, rather than deprecated top-level buyer_ref).", "NOT_CANCELLABLE": "The media buy or package cannot be canceled in its current state. The seller may have contractual or operational constraints that prevent cancellation. Recovery: correctable (check the seller's cancellation policy or contact the seller).", "PACKAGE_NOT_FOUND": "Referenced package does not exist within the specified media buy. Recovery: correctable (verify package_id within the media buy; when recovering across legacy sellers or missing echoed product_id, reconcile via get_media_buys and the package-level context correlation handle, such as context.buyer_ref, rather than deprecated top-level buyer_ref).", + "PLACE_TARGET_UNAVAILABLE": "A place identifier previously accepted and pinned on a package can no longer be executed. This is a nonfatal resource-state error returned in get_media_buys.errors[] alongside the affected buy. error.field MUST point to the exact media_buys[N].packages[M].targeting_overlay.geo_places[_exclude][A].values[V] response path. error.details MUST include media_buy_id, package_id, system, system_version, country, place_type, and value. The seller MUST preserve and echo the pinned target rather than silently changing geography. Recovery: correctable (look up the value through the declared resolver at the current catalog version and submit an intentional targeting update).", "CREATIVE_NOT_FOUND": "Referenced creative does not exist in the agent's creative library. Recovery: correctable (verify creative_id via list_creatives, or sync_creatives to register it). Sellers MUST return this code uniformly for any creative_id not owned by the calling account — never distinguish 'exists in another tenant' from 'does not exist', which would enable cross-tenant enumeration.", "SIGNAL_NOT_FOUND": "Referenced signal does not exist in the agent's catalog. Recovery: correctable (verify signal_ref via get_signals, or confirm the signal is available from this agent). Sellers MUST return this code uniformly for any signal_ref not accessible to the calling account — never distinguish 'exists but unauthorized' from 'does not exist', which would enable cross-tenant enumeration.", "SIGNAL_TARGETING_INCOMPATIBLE": "A creative carrying a signal_condition (from build_creative signal_conditions fan-out, #5240) was assigned to a package whose signal targeting is incompatible — e.g. a sun creative routed to a rain-targeted package. The trafficking-compatibility invariant: a creative built FOR one signal condition MUST NOT serve into a package targeting an incompatible condition. Enforced reject-at-trafficking on the sales side (create_media_buy / sync_creatives), NOT at build_creative (per #5280, signal pointers are advisory at the build layer; enforcement lives at the trafficking boundary). Compatibility is matched on shared signal_ref identity: when both sides carry signal_agent_segment_id, compare the opaque handle exactly; when both carry only categorical {signal_id,value}, compare signal_ref + value-set semantics; equal categorical labels from DIFFERENT providers are NOT compatible absent an explicit equivalence mechanism; when one side has a segment handle and the other only a categorical value, the seller MAY accept only if it can resolve both to the same provider-issued segment, else reject/warn. For value_type:numeric the comparison is range-overlap (WG-open: range-overlap vs exact-match — see RFC #5240 open decisions). error.field SHOULD point at the offending assignment path (e.g. packages[N].creative_assignments[M] or creatives[N]); error.details SHOULD carry the creative's signal_condition and the package's incompatible signal targeting so the buyer can re-route. Distinct from SIGNAL_NOT_FOUND (signal unknown/inaccessible) by being a compatibility mismatch between a known creative condition and a known package condition. Recovery: correctable (assign the creative to a package whose signal targeting matches its signal_condition, or rebuild for the package's condition).", @@ -368,6 +370,10 @@ "recovery": "correctable", "suggestion": "verify package_id; for legacy package correlation use get_media_buys plus package context, such as context.buyer_ref" }, + "PLACE_TARGET_UNAVAILABLE": { + "recovery": "correctable", + "suggestion": "resolve the pinned value against the current catalog, review any replacement, and submit an intentional package targeting update" + }, "CREATIVE_NOT_FOUND": { "recovery": "correctable", "suggestion": "verify creative_id via list_creatives, or sync_creatives to register it" diff --git a/static/schemas/source/enums/geo-targeting-level.json b/static/schemas/source/enums/geo-targeting-level.json new file mode 100644 index 0000000000..9f58ae1e1f --- /dev/null +++ b/static/schemas/source/enums/geo-targeting-level.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/enums/geo-targeting-level.json", + "title": "Geographic Targeting Level", + "description": "Geographic levels available for product discovery and execution targeting. Reporting uses the narrower geo-level vocabulary until place reporting is standardized.", + "type": "string", + "enum": ["country", "region", "metro", "postal_area", "place"] +} diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index 37e3cd098f..b7bb616a0a 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -361,6 +361,42 @@ "$ref": "/schemas/core/postal-area-support.json", "description": "Reusable postal area support map for capabilities and reporting" }, + "geo-place-area": { + "$ref": "/schemas/core/geo-place-area.json", + "description": "Catalog-backed named place target using stable identifiers in a declared system" + }, + "geo-place-support": { + "$ref": "/schemas/core/geo-place-support.json", + "description": "Countries and place types supported for one place identifier system" + }, + "geo-place-system": { + "$ref": "/schemas/core/geo-place-system.json", + "description": "Registered geographic place identifier namespaces with HTTPS URI extensions" + }, + "geo-place-type": { + "$ref": "/schemas/core/geo-place-type.json", + "description": "Registered geographic place classifications with HTTPS URI extensions" + }, + "geo-place-resolver": { + "$ref": "/schemas/core/geo-place-resolver.json", + "description": "Machine-readable endpoint declaration for resolving place names to seller-accepted IDs" + }, + "get-geo-place-resolution-request": { + "$ref": "/schemas/core/get-geo-place-resolution-request.json", + "description": "Standard query parameters for geographic place resolution" + }, + "get-geo-place-resolution-response": { + "$ref": "/schemas/core/get-geo-place-resolution-response.json", + "description": "Paginated geographic place resolver results" + }, + "geo-place-catalog-entry": { + "$ref": "/schemas/core/geo-place-catalog-entry.json", + "description": "One place identifier with lifecycle and replacement metadata" + }, + "geo-place-catalog-capability": { + "$ref": "/schemas/core/geo-place-catalog-capability.json", + "description": "Supported versions and resolver for one place identifier system" + }, "asset-group-vocabulary": { "$ref": "/schemas/core/asset-group-vocabulary.json", "description": "Canonical registry of asset_group_id values with descriptions and v1 alias mapping (e.g., landing_page_url replaces 6 v1 alias names)" @@ -1019,6 +1055,10 @@ "$ref": "/schemas/enums/geo-level.json", "description": "Geographic targeting granularity levels (country, region, metro, postal_area)" }, + "geo-targeting-level": { + "$ref": "/schemas/enums/geo-targeting-level.json", + "description": "Geographic levels available for discovery and targeting, including place" + }, "metro-system": { "$ref": "/schemas/enums/metro-system.json", "description": "Metro area classification systems for geographic targeting (nielsen_dma, uk_itl1, uk_itl2, eurostat_nuts2)" 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 77e444085f..22932a44b7 100644 --- a/static/schemas/source/media-buy/get-media-buys-response.json +++ b/static/schemas/source/media-buy/get-media-buys-response.json @@ -303,7 +303,7 @@ }, "targeting_overlay": { "$ref": "/schemas/core/targeting.json", - "description": "Complete effective targeting applied to this package, including configured-product targeting and the most recent package-specific overlay. Sellers SHOULD echo persisted targeting so buyers can verify stored state without replaying requests. Sellers using placement, property-list, or collection-list targeting MUST include the committed inventory selection here. placement_selection mode default SHOULD resolve to mode selected with committed refs when enumerable." + "description": "Complete effective targeting applied to this package, including configured-product targeting and the most recent package-specific overlay. Sellers SHOULD echo persisted targeting so buyers can verify stored state without replaying requests. Sellers MUST echo geo_places and geo_places_exclude whenever either was persisted, including the exact applied system_version and normalized values, so buyers can audit catalog-backed targeting. Sellers using placement, property-list, or collection-list targeting MUST include the committed inventory selection here. placement_selection mode default SHOULD resolve to mode selected with committed refs when enumerable." }, "targeting_resolution": { "$ref": "/schemas/core/package-targeting-resolution.json", @@ -540,7 +540,7 @@ }, "errors": { "type": "array", - "description": "Task-specific errors (e.g., media buy not found)", + "description": "Task-specific errors. A read may return media_buys plus nonfatal resource-state errors. If a pinned place target becomes unexecutable, sellers MUST include PLACE_TARGET_UNAVAILABLE with recovery=correctable, field pointing to the exact media_buys[N].packages[M].targeting_overlay.geo_places[_exclude][A].values[V] response path, and details containing media_buy_id, package_id, system, system_version, country, place_type, and value. The persisted target remains echoed until an intentional update replaces it.", "items": { "$ref": "/schemas/core/error.json" } diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index a6d039b808..39693915f0 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -618,6 +618,17 @@ "description": "Postal area targeting. Prefer the native country-keyed map where each ISO 3166-1 alpha-2 country lists supported country-local postal systems. Deprecated legacy country-fused postal-system boolean aliases may be emitted alongside native country keys during migration.", "$ref": "/schemas/core/postal-area-support.json" }, + "geo_places": { + "type": "object", + "description": "Place targeting support keyed by collision-safe identifier system. Each system declares exact country-to-place-type combinations, accepted catalog versions, and a machine-readable resolver. Sellers MUST reject unsupported systems, country/type pairs, versions, and identifiers rather than silently dropping them.", + "propertyNames": { + "$ref": "/schemas/core/geo-place-system.json" + }, + "additionalProperties": { + "$ref": "/schemas/core/geo-place-support.json" + }, + "minProperties": 1 + }, "age_restriction": { "type": "object", "description": "Age restriction capabilities for compliance (alcohol, gambling)", 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 b2fc86f70c..3b14d58f81 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 audience targeting, purchased inventory selection, and demographic execution under targeting_resolution.demographics. 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.", + "description": "Positive wire-level vectors for PackageStatus targeting readback in get_media_buys responses, including audience targeting, purchased inventory selection, demographic execution under targeting_resolution.demographics, and the geo-place catalog audit MUST. 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.", "vectors": [ { "id": "property-and-collection-list-echo", @@ -131,6 +131,89 @@ } ] }, + { + "id": "geo-place-targeting-echo", + "description": "Place-targeting MUST — seller echoes persisted identifier-based geo_places and geo_places_exclude so the buyer can audit the catalog namespace, place type, values, and diagnostic labels that were applied.", + "payload": { + "status": "completed", + "media_buys": [ + { + "media_buy_id": "mb_nova_local_001", + "status": "active", + "currency": "EUR", + "total_budget": 18000, + "confirmed_at": "2026-07-01T09:00:00Z", + "revision": 1, + "start_time": "2026-07-15T00:00:00Z", + "end_time": "2026-08-15T23:59:59Z", + "created_at": "2026-07-01T09:00:00Z", + "updated_at": "2026-07-15T10:30:00Z", + "packages": [ + { + "package_id": "pkg_nova_local_video", + "product_id": "prod_local_video_nl", + "budget": 18000, + "currency": "EUR", + "start_time": "2026-07-15T00:00:00Z", + "end_time": "2026-08-15T23:59:59Z", + "targeting_overlay": { + "geo_places": [{ + "country": "NL", + "system": "geonames", + "system_version": "2026-05", + "place_type": "city", + "values": ["2759794"], + "value_labels": { + "2759794": "Amsterdam, North Holland, Netherlands" + } + }], + "geo_places_exclude": [{ + "country": "NL", + "system": "geonames", + "system_version": "2026-05", + "place_type": "city", + "values": ["2747373"], + "value_labels": { + "2747373": "The Hague, South Holland, Netherlands" + } + }] + } + } + ] + } + ] + }, + "assertions": [ + { + "path": "media_buys[0].packages[0].targeting_overlay.geo_places[0].values", + "value": ["2759794"] + }, + { + "path": "media_buys[0].packages[0].targeting_overlay.geo_places[0].country", + "value": "NL" + }, + { + "path": "media_buys[0].packages[0].targeting_overlay.geo_places[0].system", + "value": "geonames" + }, + { + "path": "media_buys[0].packages[0].targeting_overlay.geo_places[0].system_version", + "value": "2026-05" + }, + { + "path": "media_buys[0].packages[0].targeting_overlay.geo_places[0].place_type", + "value": "city" + }, + { + "path": "media_buys[0].packages[0].targeting_overlay.geo_places[0].value_labels.2759794", + "value": "Amsterdam, North Holland, Netherlands" + }, + { + "path": "media_buys[0].packages[0].targeting_overlay.geo_places_exclude[0].values", + "value": ["2747373"] + } + ] + }, { "id": "signal-targeting-groups-echo", "description": "General SHOULD — seller echoes grouped package-level signal_targeting_groups under packages[0].targeting_overlay so buyers can audit include/exclude signal composition.", diff --git a/tests/media-buy-targeting-overlay-vectors.test.cjs b/tests/media-buy-targeting-overlay-vectors.test.cjs index ab925dace8..1579416435 100644 --- a/tests/media-buy-targeting-overlay-vectors.test.cjs +++ b/tests/media-buy-targeting-overlay-vectors.test.cjs @@ -100,6 +100,11 @@ describe('PackageStatus targeting_overlay echo vectors', () => { packageStatusSchema.properties.targeting_overlay.$ref, '/schemas/core/targeting.json' ); + assert.match( + packageStatusSchema.properties.targeting_overlay.description, + /MUST echo geo_places and geo_places_exclude/, + 'PackageStatus targeting_overlay contract must make place echo normative' + ); }); for (const vector of data.vectors) { @@ -124,9 +129,10 @@ describe('PackageStatus targeting_overlay echo vectors', () => { }); } - it('covers both the specialism MUST and the general SHOULD paths', () => { + it('covers specialism, place-targeting, and general echo 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('geo-place-targeting-echo'), 'place-targeting MUST vector required'); assert.ok(ids.has('plain-overlay-fields-echo'), 'general SHOULD vector required'); }); }); diff --git a/tests/schema-validation.test.cjs b/tests/schema-validation.test.cjs index 5b9d7a22b4..de4537c0bb 100644 --- a/tests/schema-validation.test.cjs +++ b/tests/schema-validation.test.cjs @@ -1505,6 +1505,344 @@ async function runTests() { return true; }); + // Test 11D: Validate identifier-based place targeting across execution, discovery, and capabilities + await test('Geo place targeting uses stable identifiers and declares discoverable support', async () => { + const testAjv = new Ajv({ + allErrors: true, + verbose: true, + strict: false, + discriminator: true, + loadSchema: loadExternalSchema + }); + addFormats(testAjv); + + const validateTargeting = await testAjv.compileAsync(loadSchema(path.join(SCHEMA_BASE_DIR, 'core/targeting.json'))); + const validateProductFilters = await testAjv.compileAsync(loadSchema(path.join(SCHEMA_BASE_DIR, 'core/product-filters.json'))); + const validateCapabilities = await testAjv.compileAsync(loadSchema(path.join(SCHEMA_BASE_DIR, 'protocol/get-adcp-capabilities-response.json'))); + const validateForecastGeo = await testAjv.compileAsync(loadSchema(path.join(SCHEMA_BASE_DIR, 'core/forecast-dimension-geo.json'))); + const validateResolutionRequest = await testAjv.compileAsync(loadSchema(path.join(SCHEMA_BASE_DIR, 'core/get-geo-place-resolution-request.json'))); + const validateResolutionResponse = await testAjv.compileAsync(loadSchema(path.join(SCHEMA_BASE_DIR, 'core/get-geo-place-resolution-response.json'))); + + const assertValid = (validate, value, label) => { + if (!validate(value)) { + return `${label} unexpectedly failed validation: ${validate.errors.map(err => `${err.instancePath} ${err.message}`).join('; ')}`; + } + return true; + }; + const assertInvalid = (validate, value, label) => validate(value) + ? `${label} unexpectedly passed validation` + : true; + + let result = assertValid( + validateTargeting, + { + geo_places: [{ + country: 'NL', + system: 'geonames', + system_version: '2026-05', + place_type: 'city', + values: ['2759794', '2747373'], + value_labels: { + '2759794': 'Amsterdam, North Holland, Netherlands', + '2747373': 'The Hague, South Holland, Netherlands' + } + }], + geo_places_exclude: [{ + country: 'US', + system: 'https://seller.example/geo/catalogs/places', + place_type: 'city', + values: ['san-jose-ca-001'] + }] + }, + 'targeting overlay with place inclusion and exclusion' + ); + if (result !== true) return result; + + result = assertInvalid( + validateTargeting, + { geo_places: [{ country: 'NL', system: 'geoname', place_type: 'city', values: ['2759794'] }] }, + 'misspelled registered place system' + ); + if (result !== true) return result; + + result = assertInvalid( + validateTargeting, + { geo_places: [{ country: 'NL', system: 'geonames', place_type: 'municipalit', values: ['2759794'] }] }, + 'misspelled registered place type' + ); + if (result !== true) return result; + + result = assertInvalid( + validateTargeting, + { geo_places: [{ country: 'NL', system: 'geonames', place_type: 'city', values: ['Amsterdam'] }] }, + 'GeoNames display name used as a targeting identifier' + ); + if (result !== true) return result; + + result = assertInvalid( + validateProductFilters, + { required_geo_targeting: [{ level: 'metro', system: 'nielsen_dma', system_version: '2026-05' }] }, + 'system_version on a non-place capability filter' + ); + if (result !== true) return result; + + result = assertInvalid( + validateTargeting, + { geo_places: [{ country: 'NL', system: 'geonames', values: ['2759794'] }] }, + 'place target without place_type' + ); + if (result !== true) return result; + + result = assertValid( + validateProductFilters, + { + places: [{ country: 'NL', system: 'geonames', system_version: '2026-05', place_type: 'city', values: ['2759794'] }], + required_geo_targeting: [{ level: 'place', country: 'NL', system: 'geonames', system_version: '2026-05', place_type: 'city' }] + }, + 'product discovery with place coverage and capability filters' + ); + if (result !== true) return result; + + result = assertInvalid( + validateProductFilters, + { required_geo_targeting: [{ level: 'place', country: 'NL', system: 'geonames' }] }, + 'place capability filter without place_type' + ); + if (result !== true) return result; + + result = assertInvalid( + validateProductFilters, + { required_geo_targeting: [{ level: 'metro', system: 'nielsen_dma', place_type: 'city' }] }, + 'place_type on a non-place capability filter' + ); + if (result !== true) return result; + + const capabilityBase = { + status: 'completed', + adcp: { major_versions: [3], idempotency: { supported: false } }, + supported_protocols: ['media_buy'], + media_buy: { execution: { targeting: {} } } + }; + capabilityBase.media_buy.execution.targeting.geo_places = { + geonames: { + countries: { + US: ['city', 'county'], + NL: ['city', 'municipality'], + GB: ['city', 'post_town'] + }, + catalog: { + source: 'https://seller.example/data-sources/geonames-mirror', + current_version: '2026-05', + supported_versions: ['2026-05', '2026-04'], + resolver: { + url: 'https://seller.example/adcp/geo/resolve/geonames', + auth: 'seller_credentials', + protocol: 'adcp_geo_place_resolver_v1' + } + } + }, + 'https://seller.example/geo/catalogs/places': { + countries: { NL: ['city'] }, + catalog: { + current_version: '2026-q2', + supported_versions: ['2026-q2'], + resolver: { + url: 'https://seller.example/adcp/geo/resolve/private', + auth: 'seller_credentials', + protocol: 'adcp_geo_place_resolver_v1' + } + } + } + }; + result = assertValid(validateCapabilities, capabilityBase, 'place targeting capability declaration'); + if (result !== true) return result; + + const legacyCartesianCapabilities = JSON.parse(JSON.stringify(capabilityBase)); + legacyCartesianCapabilities.media_buy.execution.targeting.geo_places.geonames = { + countries: ['US', 'NL'], + place_types: ['city', 'municipality'], + supports_system_version: true + }; + result = assertInvalid(validateCapabilities, legacyCartesianCapabilities, 'legacy Cartesian place support declaration'); + if (result !== true) return result; + + const typoCapabilities = JSON.parse(JSON.stringify(capabilityBase)); + typoCapabilities.media_buy.execution.targeting.geo_places.geoname = + typoCapabilities.media_buy.execution.targeting.geo_places.geonames; + delete typoCapabilities.media_buy.execution.targeting.geo_places.geonames; + result = assertInvalid(validateCapabilities, typoCapabilities, 'misspelled capability system key'); + if (result !== true) return result; + + const invalidCapabilities = JSON.parse(JSON.stringify(capabilityBase)); + delete invalidCapabilities.media_buy.execution.targeting.geo_places.geonames.countries.NL; + invalidCapabilities.media_buy.execution.targeting.geo_places.geonames.countries.NL = []; + result = assertInvalid(validateCapabilities, invalidCapabilities, 'place support with an empty country type list'); + if (result !== true) return result; + + const catalogCapabilitySchema = loadSchema(path.join(SCHEMA_BASE_DIR, 'core/geo-place-catalog-capability.json')); + const membershipRule = catalogCapabilitySchema['x-adcp-validation']?.member_of; + if (membershipRule?.field !== 'current_version' || membershipRule?.array_field !== 'supported_versions') { + return 'geo-place-catalog-capability must declare current_version membership validation'; + } + const geonamesCatalog = capabilityBase.media_buy.execution.targeting.geo_places.geonames.catalog; + if (!geonamesCatalog.supported_versions.includes(geonamesCatalog.current_version)) { + return 'valid capability fixture current_version must be in supported_versions'; + } + const invalidCatalogMembership = { + current_version: '2026-03', + supported_versions: ['2026-05', '2026-04'] + }; + if (invalidCatalogMembership.supported_versions.includes(invalidCatalogMembership.current_version)) { + return 'semantic catalog membership validation must reject an undeclared current_version'; + } + + const areaSchema = loadSchema(path.join(SCHEMA_BASE_DIR, 'core/geo-place-area.json')); + const labelRule = areaSchema['x-adcp-validation']?.map_keys_subset_of_array; + if (labelRule?.map_field !== 'value_labels' || labelRule?.array_field !== 'values') { + return 'geo-place-area must declare value_labels key membership validation'; + } + const labelsAreSubset = (area) => Object.keys(area.value_labels || {}).every(value => area.values.includes(value)); + if (labelsAreSubset({ values: ['2759794'], value_labels: { '999999': 'Wrong place' } })) { + return 'semantic value_labels validation must reject labels for absent values'; + } + + const targetingSchema = loadSchema(path.join(SCHEMA_BASE_DIR, 'core/targeting.json')); + const disjointRule = targetingSchema['x-adcp-validation']?.disjoint_place_fields; + if (disjointRule?.include !== 'geo_places' || disjointRule?.exclude !== 'geo_places_exclude') { + return 'targeting schema must declare geo place include/exclude disjointness validation'; + } + if (JSON.stringify(disjointRule.identity) !== JSON.stringify(['country', 'system', 'place_type', 'value'])) { + return 'geo place include/exclude identity must exclude catalog version'; + } + const placeIdentitySet = (areas) => new Set((areas || []).flatMap(area => + area.values.map(value => [area.country, area.system, area.place_type, value].join('\u0000')))); + const hasPlaceOverlap = overlay => { + const included = placeIdentitySet(overlay.geo_places); + return [...placeIdentitySet(overlay.geo_places_exclude)].some(identity => included.has(identity)); + }; + if (!hasPlaceOverlap({ + geo_places: [{ country: 'NL', system: 'geonames', system_version: '2026-05', place_type: 'city', values: ['2759794'] }], + geo_places_exclude: [{ country: 'NL', system: 'geonames', system_version: '2026-04', place_type: 'city', values: ['2759794'] }] + })) { + return 'semantic place overlap validation must reject cross-version overlap'; + } + + result = assertValid( + validateResolutionRequest, + { q: 'Amsterdam', country: 'NL', subdivision: 'NL-NH', place_type: 'city', locale: 'nl-NL', limit: 20 }, + 'place resolution request with disambiguation context' + ); + if (result !== true) return result; + + result = assertValid( + validateResolutionRequest, + { value: '2759794', country: 'NL', place_type: 'city', system_version: '2026-05' }, + 'place identifier lifecycle refresh request' + ); + if (result !== true) return result; + + result = assertInvalid( + validateResolutionRequest, + { q: 'Amsterdam', value: '2759794', country: 'NL' }, + 'place resolution request containing both name and identifier' + ); + if (result !== true) return result; + + const resolutionResponse = { + request: { + q: 'Amsterdam', + country: 'NL', + subdivision: 'NL-NH', + place_type: 'city', + system_version: '2026-05' + }, + system: 'geonames', + system_version: '2026-05', + matches: [{ + value: '2759794', + country: 'NL', + subdivision: 'NL-NH', + place_type: 'city', + label: 'Amsterdam', + canonical_name: 'Amsterdam, North Holland, Netherlands', + parent_labels: ['North Holland', 'Netherlands'], + status: 'active' + }, { + value: '9999999', + country: 'NL', + subdivision: 'NL-NH', + place_type: 'city', + label: 'Old Amsterdam target', + canonical_name: 'Old Amsterdam target, Netherlands', + parent_labels: ['Netherlands'], + status: 'deprecated', + replaced_by_values: ['2759794'] + }] + }; + result = assertValid( + validateResolutionResponse, + resolutionResponse, + 'place resolution response with lifecycle replacement metadata' + ); + if (result !== true) return result; + + const bindingRule = loadSchema(path.join(SCHEMA_BASE_DIR, 'core/get-geo-place-resolution-response.json'))['x-adcp-validation']?.resolver_response_binding; + if (bindingRule?.system !== 'capability_key' || !bindingRule?.match_fields?.includes('subdivision')) { + return 'place resolution response must declare capability/request binding semantics'; + } + const responseMatchesResolverContract = (response, capabilitySystem, currentVersion) => { + const request = response.request; + const expectedVersion = request.system_version || currentVersion; + return response.system === capabilitySystem + && response.system_version === expectedVersion + && response.matches.every(match => match.country === request.country + && (!request.subdivision || match.subdivision === request.subdivision) + && (!request.place_type || match.place_type === request.place_type)); + }; + if (!responseMatchesResolverContract(resolutionResponse, 'geonames', '2026-05')) { + return 'valid resolver response fixture must bind to capability and normalized request'; + } + const mismatchedResolverResponse = JSON.parse(JSON.stringify(resolutionResponse)); + mismatchedResolverResponse.matches[0].subdivision = 'NL-ZH'; + if (responseMatchesResolverContract(mismatchedResolverResponse, 'geonames', '2026-05')) { + return 'resolver response binding must reject a match outside the requested subdivision'; + } + + const rawNameResponse = JSON.parse(JSON.stringify(resolutionResponse)); + rawNameResponse.matches[0].value = 'Amsterdam'; + result = assertInvalid( + validateResolutionResponse, + rawNameResponse, + 'registered-system resolver response containing a raw name' + ); + if (result !== true) return result; + + const missingDisambiguationResponse = JSON.parse(JSON.stringify(resolutionResponse)); + delete missingDisambiguationResponse.matches[0].canonical_name; + result = assertInvalid( + validateResolutionResponse, + missingDisambiguationResponse, + 'resolver response without required canonical disambiguation name' + ); + if (result !== true) return result; + + result = assertInvalid( + validateResolutionRequest, + { q: 'Springfield' }, + 'place resolution request without country disambiguation' + ); + if (result !== true) return result; + + result = assertInvalid( + validateForecastGeo, + { kind: 'geo', geo_level: 'place', country: 'NL', system: 'geonames', geo_code: '2759794' }, + 'place reporting row before reporting support is standardized' + ); + if (result !== true) return result; + + return true; + }); + // Test 12: Validate ForecastPoint dimension and viewability compatibility gates await test('ForecastPoint dimension and viewability compatibility gates behave as intended', async () => { const dimensionsSchema = loadSchema(path.join(SCHEMA_BASE_DIR, 'core/forecast-point-dimensions.json')); From a05d479bb6c5b80b9d0e647d93dc00c7a6878619 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 29 Jul 2026 22:59:47 -0400 Subject: [PATCH 2/6] Fix storyboard dynamic map path resolution --- .../lint-storyboard-context-output-paths.cjs | 19 +++++++++++++---- scripts/lint-storyboard-validations-paths.cjs | 18 ++++++++++++---- ...t-storyboard-context-output-paths.test.cjs | 20 +++++++++++++++++- ...lint-storyboard-validations-paths.test.cjs | 21 ++++++++++++++++++- 4 files changed, 68 insertions(+), 10 deletions(-) diff --git a/scripts/lint-storyboard-context-output-paths.cjs b/scripts/lint-storyboard-context-output-paths.cjs index 2bf69cb60d..87f3fd6587 100644 --- a/scripts/lint-storyboard-context-output-paths.cjs +++ b/scripts/lint-storyboard-context-output-paths.cjs @@ -128,8 +128,9 @@ function isPureExtensionPoint(node) { /** * Walk a JSON Schema node to determine whether a dotted path resolves to * any defined element. Follows `$ref`, descends through `properties.` - * for object steps and `items` for numeric-index steps, and accepts any - * variant of `oneOf` / `anyOf` / `allOf` that resolves. Returns true when + * for object steps, schema-valued `additionalProperties` for dynamic map + * keys, and `items` for numeric-index steps, and accepts any variant of + * `oneOf` / `anyOf` / `allOf` that resolves. Returns true when * EVERY segment was either resolved by a defined property/items, accepted * by at least one composite variant, or descended into a pure extension * point (e.g., `core/context.json`, `error.details`). @@ -153,8 +154,18 @@ function pathResolves(node, segments, seen = new Set()) { // Numeric — array index. Only valid when this node has `items`. if (/^\d+$/.test(seg)) { if (node.items && pathResolves(node.items, rest, seen)) return true; - } else if (node.properties && Object.prototype.hasOwnProperty.call(node.properties, seg)) { - if (pathResolves(node.properties[seg], rest, seen)) return true; + } else { + const isDeclaredProperty = + node.properties && Object.prototype.hasOwnProperty.call(node.properties, seg); + if (isDeclaredProperty) { + if (pathResolves(node.properties[seg], rest, seen)) return true; + } else if ( + node.additionalProperties && + typeof node.additionalProperties === 'object' && + pathResolves(node.additionalProperties, rest, seen) + ) { + return true; + } } // Composite variants — any variant that resolves the FULL remaining path diff --git a/scripts/lint-storyboard-validations-paths.cjs b/scripts/lint-storyboard-validations-paths.cjs index 1bf0a1d69f..27751a9976 100644 --- a/scripts/lint-storyboard-validations-paths.cjs +++ b/scripts/lint-storyboard-validations-paths.cjs @@ -196,10 +196,20 @@ function pathResolves(node, segments, seen = new Set()) { const [seg, ...rest] = segments; - if (/^\d+$/.test(seg) || seg === '*') { - if (node.items && pathResolves(node.items, rest, seen)) return true; - } else if (node.properties && Object.prototype.hasOwnProperty.call(node.properties, seg)) { - if (pathResolves(node.properties[seg], rest, seen)) return true; + if ((/^\d+$/.test(seg) || seg === '*') && node.items) { + if (pathResolves(node.items, rest, seen)) return true; + } else { + const isDeclaredProperty = + node.properties && Object.prototype.hasOwnProperty.call(node.properties, seg); + if (isDeclaredProperty) { + if (pathResolves(node.properties[seg], rest, seen)) return true; + } else if ( + node.additionalProperties && + typeof node.additionalProperties === 'object' && + pathResolves(node.additionalProperties, rest, seen) + ) { + return true; + } } // Union semantics across `oneOf` / `anyOf` / `allOf` — see diff --git a/tests/lint-storyboard-context-output-paths.test.cjs b/tests/lint-storyboard-context-output-paths.test.cjs index 5483649e12..c61a19f51f 100644 --- a/tests/lint-storyboard-context-output-paths.test.cjs +++ b/tests/lint-storyboard-context-output-paths.test.cjs @@ -9,7 +9,7 @@ * in the response schema (the offering_id / offering.offering_id typo * that surfaced this lint). * 3. The path resolver follows the bracket / dot equivalence and descends - * through oneOf / anyOf variants and items. + * through oneOf / anyOf variants, items, and typed dynamic maps. * 4. The allowlist mechanism suppresses entries for paths the lint can't * statically verify (error.details polymorphism, additionalProperties * runtime conventions). @@ -169,6 +169,24 @@ test('mixed schemas (declared properties + additionalProperties: true) stay stri assert.equal(pathResolves(schema, parsePath('not_a_real_field')), false); }); +test('schema-valued additionalProperties resolves typed dynamic map keys', () => { + const schema = loadSchema('protocol/get-adcp-capabilities-response.json'); + assert.equal( + pathResolves( + schema, + parsePath('media_buy.execution.targeting.geo_places.geonames.catalog.current_version'), + ), + true, + ); + assert.equal( + pathResolves( + schema, + parsePath('media_buy.execution.targeting.geo_places.geonames.not_a_real_field'), + ), + false, + ); +}); + test('parsePath accepts both bracket and dotted forms', () => { assert.deepEqual(parsePath('rights[0].rights_id'), ['rights', '0', 'rights_id']); assert.deepEqual(parsePath('rights.0.rights_id'), ['rights', '0', 'rights_id']); diff --git a/tests/lint-storyboard-validations-paths.test.cjs b/tests/lint-storyboard-validations-paths.test.cjs index ade5740cd7..ae5c20ec82 100644 --- a/tests/lint-storyboard-validations-paths.test.cjs +++ b/tests/lint-storyboard-validations-paths.test.cjs @@ -8,7 +8,8 @@ * asserts on a path that doesn't resolve in the response schema. * 3. Non-path-bearing checks (error_code, response_schema, http_status, * etc.) are silently skipped — they have no path to validate. - * 4. The path resolver follows $ref / oneOf / anyOf / allOf / items. + * 4. The path resolver follows $ref / oneOf / anyOf / allOf / items and + * schema-valued additionalProperties for typed dynamic maps. * 5. Pure extension points (additionalProperties: true with no * properties / variants — like core/context.json and error.details) * accept any further segments without flagging. @@ -266,6 +267,24 @@ test('pure extension points only loosen when there are no defined properties', ( assert.equal(pathResolves(schema, parsePath('not_a_real_field')), false); }); +test('schema-valued additionalProperties resolves typed dynamic map keys', () => { + const schema = loadSchema('protocol/get-adcp-capabilities-response.json'); + assert.equal( + pathResolves( + schema, + parsePath('media_buy.execution.targeting.geo_places.geonames.catalog.current_version'), + ), + true, + ); + assert.equal( + pathResolves( + schema, + parsePath('media_buy.execution.targeting.geo_places.geonames.not_a_real_field'), + ), + false, + ); +}); + test('pathResolves descends through error.json $ref for errors[0].code', () => { const schema = loadSchema('media-buy/create-media-buy-response.json'); assert.equal(pathResolves(schema, parsePath('errors[0].code')), true); From 5b17fdb2fa3920bf8c1e8977120f7bd522b43c43 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 29 Jul 2026 23:10:11 -0400 Subject: [PATCH 3/6] Allow registered place catalog namespaces --- tests/check-platform-agnostic.cjs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/check-platform-agnostic.cjs b/tests/check-platform-agnostic.cjs index 8f381b4c20..22c549c128 100644 --- a/tests/check-platform-agnostic.cjs +++ b/tests/check-platform-agnostic.cjs @@ -140,6 +140,16 @@ const ENUM_VALUE_ALLOWLIST = [ // enums/metro-system.json — Nielsen DMA is the industry-standard geographic // division (same justification as FIELD_ALLOWLIST entry). { value: 'nielsen_dma', pathContains: 'enums/metro-system.json' }, + + // Geographic place schemas — registered external catalog identifier spaces. + // These values select the namespace in which stable place IDs are interpreted; + // they do not expose a vendor-specific version of an AdCP resource. + { value: 'google_ads', pathContains: 'core/geo-place-system.json' }, + { value: 'microsoft_ads', pathContains: 'core/geo-place-system.json' }, + { value: 'google_ads', pathContains: 'core/geo-place-area.json' }, + { value: 'microsoft_ads', pathContains: 'core/geo-place-area.json' }, + { value: 'google_ads', pathContains: 'core/get-geo-place-resolution-response.json' }, + { value: 'microsoft_ads', pathContains: 'core/get-geo-place-resolution-response.json' }, ]; function findJSONFiles(dir) { From 7e384234a3b47f3867cd5bd59a7a88a5746a3587 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 30 Jul 2026 13:20:55 -0400 Subject: [PATCH 4/6] Test cross-version place overlap --- .../scenarios/geo_place_targeting.yaml | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/static/compliance/source/protocols/media-buy/scenarios/geo_place_targeting.yaml b/static/compliance/source/protocols/media-buy/scenarios/geo_place_targeting.yaml index fb4452ebde..e2c5960b49 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/geo_place_targeting.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/geo_place_targeting.yaml @@ -99,6 +99,10 @@ phases: context_outputs: - key: "place_system_version" path: "media_buy.execution.targeting.geo_places.geonames.catalog.current_version" + - key: "place_supported_version_0" + path: "media_buy.execution.targeting.geo_places.geonames.catalog.supported_versions[0]" + - key: "place_supported_version_1" + path: "media_buy.execution.targeting.geo_places.geonames.catalog.supported_versions[1]" validations: - check: response_schema description: "Response matches get-adcp-capabilities-response.json" @@ -571,3 +575,65 @@ phases: - check: error_code expected: "INVALID_REQUEST" description: "Same-value include/exclude overlap is rejected" + + - id: reject_cross_version_place_overlap + title: "Reject cross-version include/exclude overlap" + optional: true + depends_on: [discover_capability_and_product] + requires_capability: + path: "media_buy.execution.targeting.geo_places.geonames.catalog.supported_versions[1]" + present: true + narrative: | + When the seller accepts at least two GeoNames catalog versions, the same + stable place identifier cannot be included under one version and excluded + under another. Catalog version is not part of place identity. + steps: + - id: create_with_cross_version_include_exclude_overlap + title: "Submit the same place across two supported versions" + task: create_media_buy + schema_ref: "media-buy/create-media-buy-request.json" + response_schema_ref: "media-buy/create-media-buy-response.json" + doc_ref: "/media-buy/task-reference/create_media_buy" + expect_error: true + negative_path: payload_well_formed + stateful: false + expected: | + Reject with INVALID_REQUEST because version does not distinguish an + included place from the same excluded stable place identity. + sample_request: + brand: + domain: "acmeoutdoor.example" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + start_time: "asap" + end_time: "2099-09-30T23:59:59Z" + packages: + - product_id: "$context.product_id" + pricing_option_id: "$context.pricing_option_id" + bid_price: 4.25 + budget: 5000 + targeting_overlay: + geo_places: + - country: "NL" + system: "geonames" + system_version: "$context.place_supported_version_0" + place_type: "city" + values: ["2759794"] + geo_places_exclude: + - country: "NL" + system: "geonames" + system_version: "$context.place_supported_version_1" + place_type: "city" + values: ["2759794"] + idempotency_key: "$generate:uuid_v4#geo_place_targeting_cross_version_overlap" + context: + correlation_id: "geo_place_targeting--create_with_cross_version_include_exclude_overlap" + validations: + - check: response_schema + description: "Error response matches create-media-buy-response.json" + - check: error_code + expected: "INVALID_REQUEST" + description: "Cross-version same-value include/exclude overlap is rejected" From 4678c38eed091ba6b824659b2003af500b91d286 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 13:05:40 +0000 Subject: [PATCH 5/6] fix(media-buy): address pre-merge review concerns on PR #6093 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clarify pricing/forecast granularity in targeting.mdx: firm pricing is unconditional on overlay granularity for geo_places, consistent with geo_metros and geo_postal_areas precedent - Add TARGETING_TOO_NARROW to error-code.json enum with description and enumMetadata (correctable recovery) — reconciles drift with create_media_buy.mdx - Add filter-not-fail rules to filters.places description in product-filters.json: unsupported system MUST yield no-match (not error); MUST-reject applies only to targeting_overlay.geo_places on spend-committing operations; system_version is informational at discovery time - Add brief-mode filters.places storyboard phase in geo_place_targeting.yaml: positive probe (covered place returns product) and negative probe (uncovered place returns empty — not error) to verify ANY/intersection semantics are observable by the compliance harness --- docs/media-buy/advanced-topics/targeting.mdx | 2 + .../scenarios/geo_place_targeting.yaml | 75 +++++ .../schemas/source/core/product-filters.json | 276 ++++++++++++++---- static/schemas/source/enums/error-code.json | 9 +- 4 files changed, 307 insertions(+), 55 deletions(-) diff --git a/docs/media-buy/advanced-topics/targeting.mdx b/docs/media-buy/advanced-topics/targeting.mdx index a2f212c7ee..2745b1fe6f 100644 --- a/docs/media-buy/advanced-topics/targeting.mdx +++ b/docs/media-buy/advanced-topics/targeting.mdx @@ -766,6 +766,8 @@ Accepted place targeting is pinned to the echoed `system_version` for the life o Place forecast and delivery breakdown rows are intentionally not part of this release: `geo_level: "place"` remains invalid on reporting surfaces. Package-state echo provides configuration auditability, but not delivery-by-place verification. Place-level forecast, delivery, pacing, and reconciliation require a follow-up reporting RFC. +Product-level `forecast` and `pricing_options` returned from `get_products` are not revised to reflect `targeting_overlay.geo_places`. Firm pricing is unconditional on overlay granularity, following the same precedent as `geo_metros` and `geo_postal_areas`. Delivery risk for overlay-narrowed execution sits with the buyer. + ### geo_places_exclude - **Description**: Exclude catalog-backed named places diff --git a/static/compliance/source/protocols/media-buy/scenarios/geo_place_targeting.yaml b/static/compliance/source/protocols/media-buy/scenarios/geo_place_targeting.yaml index e2c5960b49..c16fd9231f 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/geo_place_targeting.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/geo_place_targeting.yaml @@ -637,3 +637,78 @@ phases: - check: error_code expected: "INVALID_REQUEST" description: "Cross-version same-value include/exclude overlap is rejected" + + - id: brief_mode_place_filter_coverage + title: "Verify filters.places intersection semantics in brief mode" + depends_on: [discover_capability_and_product] + steps: + - id: get_products_brief_covered_place + title: "Brief-mode filter with a covered place returns at least one product" + task: get_products + schema_ref: "media-buy/get-products-request.json" + response_schema_ref: "media-buy/get-products-response.json" + doc_ref: "/media-buy/task-reference/get_products" + stateful: false + expected: | + Return at least one product because the seller's coverage intersects + Amsterdam (GeoNames 2759794). Verifies ANY/intersection semantics: + a single matched value is sufficient for a product to appear. + sample_request: + buying_mode: "brief" + brief: "Display campaign reaching Amsterdam, Netherlands" + filters: + channels: ["display"] + places: + - country: "NL" + system: "geonames" + place_type: "city" + values: ["2759794"] + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + context: + correlation_id: "geo_place_targeting--get_products_brief_covered_place" + validations: + - check: response_schema + description: "Response matches get-products-response.json" + - check: field_present + path: "products[0].product_id" + description: "At least one product covering the requested NL/geonames/city place is returned" + + - id: get_products_brief_uncovered_place + title: "Brief-mode filter for a place outside seller coverage returns no products and no error" + task: get_products + schema_ref: "media-buy/get-products-request.json" + response_schema_ref: "media-buy/get-products-response.json" + doc_ref: "/media-buy/task-reference/get_products" + stateful: false + expected: | + Return an empty products list — not an error. The seller has no products + declared for Berlin, Germany (GeoNames 2950158), so intersection yields + zero matches. Verifies filter-not-fail: unmatched places in filters.places + produce empty results, not INVALID_REQUEST. + sample_request: + buying_mode: "brief" + brief: "Display campaign reaching Berlin, Germany" + filters: + channels: ["display"] + places: + - country: "DE" + system: "geonames" + place_type: "city" + values: ["2950158"] + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + context: + correlation_id: "geo_place_targeting--get_products_brief_uncovered_place" + validations: + - check: response_schema + description: "Response matches get-products-response.json" + - check: field_absent + path: "products[0].product_id" + description: "No products match a place outside the seller's declared coverage" diff --git a/static/schemas/source/core/product-filters.json b/static/schemas/source/core/product-filters.json index d340be76a1..c82ed55506 100644 --- a/static/schemas/source/core/product-filters.json +++ b/static/schemas/source/core/product-filters.json @@ -122,7 +122,10 @@ "description": "Metro code within the system (e.g., '501' for NYC DMA)" } }, - "required": ["system", "code"], + "required": [ + "system", + "code" + ], "additionalProperties": false }, "minItems": 1 @@ -218,7 +221,9 @@ "description": "When true, require this provider to support identity match." } }, - "required": ["agent_url"], + "required": [ + "agent_url" + ], "additionalProperties": true }, "minItems": 1 @@ -266,58 +271,129 @@ "description": "Exact place catalog version required for level=place. Not applicable to other levels." } }, - "required": ["level"], + "required": [ + "level" + ], "allOf": [ { "if": { - "properties": { "level": { "enum": ["country", "region"] } }, - "required": ["level"] + "properties": { + "level": { + "enum": [ + "country", + "region" + ] + } + }, + "required": [ + "level" + ] }, "then": { - "not": { "anyOf": [ - { "required": ["country"] }, - { "required": ["system"] }, - { "required": ["place_type"] }, - { "required": ["system_version"] } - ] } + "not": { + "anyOf": [ + { + "required": [ + "country" + ] + }, + { + "required": [ + "system" + ] + }, + { + "required": [ + "place_type" + ] + }, + { + "required": [ + "system_version" + ] + } + ] + } } }, { "if": { - "properties": { "level": { "const": "metro" } }, - "required": ["level"] + "properties": { + "level": { + "const": "metro" + } + }, + "required": [ + "level" + ] }, "then": { "anyOf": [ { - "not": { "required": ["system"] } + "not": { + "required": [ + "system" + ] + } }, { "properties": { - "system": { "$ref": "/schemas/enums/metro-system.json" } + "system": { + "$ref": "/schemas/enums/metro-system.json" + } }, - "required": ["system"] + "required": [ + "system" + ] } ], - "not": { "anyOf": [ - { "required": ["country"] }, - { "required": ["place_type"] }, - { "required": ["system_version"] } - ] } + "not": { + "anyOf": [ + { + "required": [ + "country" + ] + }, + { + "required": [ + "place_type" + ] + }, + { + "required": [ + "system_version" + ] + } + ] + } } }, { "if": { - "properties": { "level": { "const": "postal_area" } }, - "required": ["level"] + "properties": { + "level": { + "const": "postal_area" + } + }, + "required": [ + "level" + ] }, "then": { "anyOf": [ { "not": { "anyOf": [ - { "required": ["country"] }, - { "required": ["system"] } + { + "required": [ + "country" + ] + }, + { + "required": [ + "system" + ] + } ] } }, @@ -330,34 +406,68 @@ "$ref": "/schemas/enums/legacy-postal-system.json" } }, - "required": ["system"], - "not": { "required": ["country"] } + "required": [ + "system" + ], + "not": { + "required": [ + "country" + ] + } } ] } }, { "if": { - "properties": { "level": { "const": "postal_area" } }, - "required": ["level"] + "properties": { + "level": { + "const": "postal_area" + } + }, + "required": [ + "level" + ] }, "then": { - "not": { "anyOf": [ - { "required": ["place_type"] }, - { "required": ["system_version"] } - ] } + "not": { + "anyOf": [ + { + "required": [ + "place_type" + ] + }, + { + "required": [ + "system_version" + ] + } + ] + } } }, { "if": { - "properties": { "level": { "const": "place" } }, - "required": ["level"] + "properties": { + "level": { + "const": "place" + } + }, + "required": [ + "level" + ] }, "then": { "properties": { - "system": { "$ref": "/schemas/core/geo-place-system.json" } + "system": { + "$ref": "/schemas/core/geo-place-system.json" + } }, - "required": ["country", "system", "place_type"] + "required": [ + "country", + "system", + "place_type" + ] } } ], @@ -432,7 +542,10 @@ "$ref": "/schemas/enums/travel-time-unit.json" } }, - "required": ["value", "unit"], + "required": [ + "value", + "unit" + ], "additionalProperties": false }, "transport_mode": { @@ -453,7 +566,10 @@ "description": "Distance unit" } }, - "required": ["value", "unit"], + "required": [ + "value", + "unit" + ], "additionalProperties": false }, "geometry": { @@ -462,7 +578,10 @@ "properties": { "type": { "type": "string", - "enum": ["Polygon", "MultiPolygon"], + "enum": [ + "Polygon", + "MultiPolygon" + ], "description": "GeoJSON geometry type" }, "coordinates": { @@ -470,35 +589,73 @@ "description": "GeoJSON coordinates array" } }, - "required": ["type", "coordinates"], + "required": [ + "type", + "coordinates" + ], "additionalProperties": false } }, "oneOf": [ { - "required": ["lat", "lng", "travel_time", "transport_mode"], + "required": [ + "lat", + "lng", + "travel_time", + "transport_mode" + ], "not": { "anyOf": [ - { "required": ["radius"] }, - { "required": ["geometry"] } + { + "required": [ + "radius" + ] + }, + { + "required": [ + "geometry" + ] + } ] } }, { - "required": ["lat", "lng", "radius"], + "required": [ + "lat", + "lng", + "radius" + ], "not": { "anyOf": [ - { "required": ["travel_time"] }, - { "required": ["geometry"] } + { + "required": [ + "travel_time" + ] + }, + { + "required": [ + "geometry" + ] + } ] } }, { - "required": ["geometry"], + "required": [ + "geometry" + ], "not": { "anyOf": [ - { "required": ["travel_time"] }, - { "required": ["radius"] } + { + "required": [ + "travel_time" + ] + }, + { + "required": [ + "radius" + ] + } ] } } @@ -524,9 +681,18 @@ "minItems": 1, "uniqueItems": true, "examples": [ - ["completed_views"], - ["completed_views", "completion_rate"], - ["impressions", "spend", "engagements"] + [ + "completed_views" + ], + [ + "completed_views", + "completion_rate" + ], + [ + "impressions", + "spend", + "engagements" + ] ] }, "required_vendor_metrics": { @@ -579,7 +745,9 @@ "default": "broad" } }, - "required": ["keyword"], + "required": [ + "keyword" + ], "additionalProperties": false }, "minItems": 1 diff --git a/static/schemas/source/enums/error-code.json b/static/schemas/source/enums/error-code.json index c3a84b182d..1d47c36ba8 100644 --- a/static/schemas/source/enums/error-code.json +++ b/static/schemas/source/enums/error-code.json @@ -47,6 +47,7 @@ "NOT_CANCELLABLE", "PACKAGE_NOT_FOUND", "PLACE_TARGET_UNAVAILABLE", + "TARGETING_TOO_NARROW", "CREATIVE_NOT_FOUND", "SIGNAL_NOT_FOUND", "SIGNAL_TARGETING_INCOMPATIBLE", @@ -202,7 +203,9 @@ "ITEM_VALIDATION_FAILED": "One or more catalog items failed schema validation during sync_catalogs. Recovery: correctable (check item_issues for per-item rejection reasons and fix the offending items).", "CATALOG_LIMIT_EXCEEDED": "The account has reached its maximum catalog count. Recovery: correctable (remove unused catalogs, or contact the seller to raise the limit).", "INVALID_PRICING_OPTION": "A `pricing_option_id` referenced in the request does not exist on the target account or product. Returned per-record in `report_usage` responses and at the request level for `create_media_buy` when the submitted pricing option cannot be resolved. `error.field` SHOULD point at the offending record path (e.g., `usage[1].pricing_option_id` or `packages[0].pricing_option_id`). Distinct from `PRODUCT_NOT_FOUND` (the product itself is unknown) by being narrowly about a pricing option within a known product or account. Recovery: correctable (verify `pricing_option_id` against the product's `pricing_options` from `get_products` or the vendor's discovery response, then resubmit with a valid ID).", - "INVALID_USAGE_DATA": "A usage record in `report_usage` has missing or invalid fields — required fields absent, values out of range, or type mismatches. Returned per-record in the `report_usage` response `errors[]` array. `error.field` SHOULD point at the offending field path (e.g., `usage[0].vendor_cost`, `usage[0].currency`). Distinct from `INVALID_REQUEST` (top-level request malformed) by being scoped to individual usage records within an otherwise well-formed request. Recovery: correctable (check required fields for the vendor type — at minimum `vendor_cost`, `currency`, and `account` — fix the offending values, and resubmit)." + "INVALID_PRICING_OPTION": "A `pricing_option_id` referenced in the request does not exist on the target account or product. Returned per-record in `report_usage` responses and at the request level for `create_media_buy` when the submitted pricing option cannot be resolved. `error.field` SHOULD point at the offending record path (e.g., `usage[1].pricing_option_id` or `packages[0].pricing_option_id`). Distinct from `PRODUCT_NOT_FOUND` (the product itself is unknown) by being narrowly about a pricing option within a known product or account. Recovery: correctable (verify `pricing_option_id` against the product's `pricing_options` from `get_products` or the vendor's discovery response, then resubmit with a valid ID).", + "INVALID_USAGE_DATA": "A usage record in `report_usage` has missing or invalid fields — required fields absent, values out of range, or type mismatches. Returned per-record in the `report_usage` response `errors[]` array. `error.field` SHOULD point at the offending field path (e.g., `usage[0].vendor_cost`, `usage[0].currency`). Distinct from `INVALID_REQUEST` (top-level request malformed) by being scoped to individual usage records within an otherwise well-formed request. Recovery: correctable (check required fields for the vendor type — at minimum `vendor_cost`, `currency`, and `account` — fix the offending values, and resubmit).", + "TARGETING_TOO_NARROW": "The targeting_overlay constrains the requested product to zero executable inventory — the overlay combination eliminates all eligible supply. Recovery: correctable (relax one or more overlay constraints or select a product whose coverage encompasses the intended targets)." }, "enumMetadata": { "$comment": "Structured recovery classification and remediation hints for each error code. SDKs MUST consume this block instead of parsing 'Recovery: X' from enumDescriptions prose. Each entry is { recovery, suggestion }. recovery is one of: correctable (caller can fix and retry), transient (retry with backoff), terminal (no autonomous recovery - operator intervention required). enumDescriptions is retained for human readability and will continue to carry the canonical narrative; the recovery classification embedded in that prose is normative and MUST match the value here.", @@ -597,6 +600,10 @@ "INVALID_USAGE_DATA": { "recovery": "correctable", "suggestion": "check required fields for the vendor type (vendor_cost, currency, account at minimum), fix invalid values, and resubmit" + }, + "TARGETING_TOO_NARROW": { + "recovery": "correctable", + "suggestion": "relax targeting_overlay constraints or select a product whose coverage encompasses the intended targets" } } } From 295f578af50725453bee244baf7a6d89a5756d1c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 5 Aug 2026 08:52:11 +0200 Subject: [PATCH 6/6] feat(media-buy): align place targeting with discovery lifecycle --- .changeset/geo-place-targeting.md | 3 +- docs/media-buy/advanced-topics/targeting.mdx | 22 +- .../task-reference/create_media_buy.mdx | 12 + .../media-buy/task-reference/get_products.mdx | 27 +- scripts/error-code-drift-dispositions.json | 5 + .../lint-storyboard-context-output-paths.cjs | 35 +- scripts/lint-storyboard-validations-paths.cjs | 35 +- specs/targeting-aware-product-discovery.md | 37 +- .../scenarios/geo_place_targeting.yaml | 574 ++++++++++++++---- .../schemas/source/core/product-filters.json | 294 ++------- .../core/targeting-overlay-requirements.json | 44 +- .../core/targeting-overlay-support.json | 61 +- static/schemas/source/enums/error-code.json | 5 +- .../source/enums/geo-targeting-level.json | 8 - static/schemas/source/index.json | 4 - ...t-storyboard-context-output-paths.test.cjs | 30 + ...lint-storyboard-validations-paths.test.cjs | 30 + tests/schema-validation.test.cjs | 32 - tests/targeting-aware-discovery.test.cjs | 103 ++++ 19 files changed, 916 insertions(+), 445 deletions(-) delete mode 100644 static/schemas/source/enums/geo-targeting-level.json diff --git a/.changeset/geo-place-targeting.md b/.changeset/geo-place-targeting.md index 116c94643f..ed17d5dfec 100644 --- a/.changeset/geo-place-targeting.md +++ b/.changeset/geo-place-targeting.md @@ -6,10 +6,11 @@ Add identifier-based named-place geographic targeting: - `targeting_overlay.geo_places` and `geo_places_exclude` carry stable identifiers with country, system, place type, optional catalog version, and diagnostic labels. - `get_adcp_capabilities` declares exact country/type pairs, accepted catalog versions, and a standard resolver for every collision-safe identifier system. -- `get_products` adds place coverage and version-aware capability filters so buyers can discover support before creating a media buy. +- `get_products.targeting_overlay` carries known place IDs so configured products, pricing, and forecasts reflect them; `required_overlay_support` and Product `overlay_support` declare collision-safe permission for place values selected later. - Package status MUST echo persisted place overlays with the applied catalog version through the existing `targeting_overlay` contract. - Resolver responses echo their normalized query, carry machine-verifiable disambiguation and lifecycle metadata, and support existing-ID refresh after catalog rollover. - `PLACE_TARGET_UNAVAILABLE` provides a nonfatal, correctable read-path signal when a pinned target can no longer execute without silently changing geography. +- Create-time place overlays use deterministic `UNSUPPORTED_FEATURE`, `INVALID_REQUEST`, `TARGETING_TOO_NARROW`, and `REQUOTE_REQUIRED` dispositions; the latter two now explicitly cover zero-inventory and out-of-envelope create-time targeting. - Place forecast and delivery breakdowns remain deferred; package echo is the interim configuration-audit path. Refs #5588. diff --git a/docs/media-buy/advanced-topics/targeting.mdx b/docs/media-buy/advanced-topics/targeting.mdx index 2745b1fe6f..be85977836 100644 --- a/docs/media-buy/advanced-topics/targeting.mdx +++ b/docs/media-buy/advanced-topics/targeting.mdx @@ -766,7 +766,27 @@ Accepted place targeting is pinned to the echoed `system_version` for the life o Place forecast and delivery breakdown rows are intentionally not part of this release: `geo_level: "place"` remains invalid on reporting surfaces. Package-state echo provides configuration auditability, but not delivery-by-place verification. Place-level forecast, delivery, pacing, and reconciliation require a follow-up reporting RFC. -Product-level `forecast` and `pricing_options` returned from `get_products` are not revised to reflect `targeting_overlay.geo_places`. Firm pricing is unconditional on overlay granularity, following the same precedent as `geo_metros` and `geo_postal_areas`. Delivery risk for overlay-narrowed execution sits with the buyer. +Known place IDs belong in `get_products.targeting_overlay.geo_places`, so every +returned product, price, and aggregate forecast reflects that effective +targeting even though a place-level breakdown is unavailable. If IDs will be +chosen on packages later, the buyer requests `required_overlay_support.geo_places` +(and independently `geo_places_exclude`) with the required system, +country/type pairs, and optional catalog versions. Returned Product +`overlay_support` is binding selectable permission, not a value-specific +inventory, price, or forecast guarantee. At create, the seller applies this +deterministic disposition matrix to the actual place overlay: + +| Condition | Result | +|---|---| +| The dimension, identifier system, country, place type, or combination is outside the selected Product `overlay_support` | `UNSUPPORTED_FEATURE` | +| The tuple is supported, but an ID is invalid, unknown, or deprecated, or an explicit `system_version` is not supported | `INVALID_REQUEST`, with `error.field` identifying the offending field | +| The identifiers are valid, but their effective intersection has zero executable inventory | `TARGETING_TOO_NARROW` | +| The effective target is executable, but outside the configured product's priced or guaranteed envelope | `REQUOTE_REQUIRED`; rediscover with the exact place overlay | + +An accepted create confirms that the returned terms apply to the complete +effective targeting. `PLACE_TARGET_UNAVAILABLE` is reserved for later +degradation of a previously accepted, persisted place target; it is not a +create-time substitute for any result above. ### geo_places_exclude diff --git a/docs/media-buy/task-reference/create_media_buy.mdx b/docs/media-buy/task-reference/create_media_buy.mdx index 13a9abacac..e56a3a52dd 100644 --- a/docs/media-buy/task-reference/create_media_buy.mdx +++ b/docs/media-buy/task-reference/create_media_buy.mdx @@ -208,6 +208,17 @@ When executing a proposal, `proposal_status` on the returned proposal determines | `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 | + +For `targeting_overlay.geo_places` and `geo_places_exclude`, create-time values +must be within the selected Product's corresponding `overlay_support` tuple. +Unsupported dimensions, systems, countries, place types, or combinations return +`UNSUPPORTED_FEATURE`. A supported tuple with an invalid, unknown, or deprecated +ID—or an unsupported explicit catalog version—returns `INVALID_REQUEST` with +`error.field` on the offending field. Valid targeting with zero executable +inventory returns `TARGETING_TOO_NARROW`. Executable targeting outside the +configured product's priced or guaranteed envelope returns `REQUOTE_REQUIRED`, +after which the buyer rediscovers with the exact overlay. An accepted create +confirms the selected terms for the complete effective targeting. | `creatives` | CreativeAsset[] | No | Upload new creative assets inline and assign. Requires `media_buy.features.inline_creative_management: true`; when the seller also advertises `creative.has_creative_library: true`, `creative_id` must not already exist in the library. | | `context` | object | No | Opaque correlation data echoed unchanged in the package response, webhooks, and read surfaces. Use to map seller-assigned `package_id` back to your internal line items, campaign structure, or tracking state. Buyers targeting mixed seller populations SHOULD include a per-package correlation value here, commonly `context.buyer_ref`, for legacy sellers that do not echo `product_id`. | | `measurement_terms` | [MeasurementTerms](/docs/media-buy/advanced-topics/pricing-models#measurement-terms-and-performance-standards) | No | Buyer's proposed billing measurement and makegood terms. Overrides product defaults. Seller accepts (echoed on confirmed package), rejects with `TERMS_REJECTED`, or adjusts. When omitted, product's `measurement_terms` apply. | @@ -1093,6 +1104,7 @@ Common errors and resolutions: | `UNSUPPORTED_FEATURE` | Format not supported by the product — covers legacy named-format selectors (`format_ids[]` not in the product's accepted formats), 3.1+ format-option selectors (`format_option_refs[]` entries that do not resolve against the product's `format_options[]`, legacy-format-only products with no `format_options[]`, or product `format_options[]` entries that do not publish selectable `format_option_id` values), and direct canonical selectors (`format_kind`/`params` outside or under-specifying the product declaration) | Check the product's `format_ids` and/or `format_options[]` from `get_products` — re-author against a supported format, add the required canonical parameters, or pick a `format_option_ref` from the product's published `format_options[]` | | `BUDGET_TOO_LOW` | Budget below product minimum | Increase budget or choose different product | | `TARGETING_TOO_NARROW` | Targeting yields zero inventory | Broaden geographic or audience criteria | +| `REQUOTE_REQUIRED` | A later package overlay is executable but falls outside the configured product's priced or guaranteed envelope | Re-run `get_products` with the exact targeting in `targeting_overlay`, then create from the returned configuration | | `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` | | `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` | diff --git a/docs/media-buy/task-reference/get_products.mdx b/docs/media-buy/task-reference/get_products.mdx index e60ae32288..047f579438 100644 --- a/docs/media-buy/task-reference/get_products.mdx +++ b/docs/media-buy/task-reference/get_products.mdx @@ -221,9 +221,12 @@ or packages broken out by DMA or placement. Request requirements and product support deliberately use different schemas. The buyer request contains dimensions and required systems, never seller maxima. -A product value of `true` satisfies any protocol-valid requirement for that -dimension. When both sides use objects, every required boolean must be true in +A product value of `true`, where the dimension permits that form, satisfies any +protocol-valid requirement for that dimension. When both sides use objects, every required boolean must be true in the product and every required array must be a subset of the product array. +Named places always use the object form: every requested identifier-system and country key must exist, +and the requested place types and catalog versions must be subsets of the +corresponding Product support arrays. Missing or unknown requirements do not match. Product-only limits such as `max_values_per_package` and `max_packages` are returned for planning after the match. @@ -281,14 +284,12 @@ targeting filter and the legacy top-level `property_list`. | `countries` | string[] | Deprecated. Use `targeting_overlay.geo_countries`. | | `regions` | string[] | Deprecated. Use `targeting_overlay.geo_regions`. | | `metros` | object[] | Deprecated. Use `targeting_overlay.geo_metros` for known values or `required_overlay_support.geo_metros` for future selection. | -| `places` | object[] | Deprecated. Use `targeting_overlay.geo_places` for known catalog-backed place IDs or `required_overlay_support.geo_places` for future selection. | | `channels` | string[] | Filter by advertising channels (e.g., `["display", "ctv", "social", "streaming_audio"]`). See [Media Channel Taxonomy](/docs/reference/media-channel-taxonomy) | | `video_placement_types` | string[] | Match product metadata when the declared video placement types intersect `instream`, `accompanying_content`, `interstitial`, or `standalone`. Classification only; it does not promise exclusive delivery on a requested type. | | `audio_distribution_types` | string[] | Match product metadata when declared audio distribution types intersect the requested values. Classification only; it does not promise exclusive delivery on a requested type. | | `sponsored_placement_types` | string[] | Match retail-media product metadata when declared sponsored-placement types intersect the requested values. Classification only. | | `social_placement_surfaces` | string[] | Match social-product metadata when declared surfaces intersect `feed`, `stories`, `short_video`, `explore`, or `search`. Classification only; exact public-placement inventory uses `targeting_overlay.placement_selection`. | | `postal_areas` | object[] | Deprecated. Use `targeting_overlay.geo_postal_areas` or `required_overlay_support.geo_postal_areas`. | -| `required_geo_targeting` | object[] | Deprecated. Use `required_overlay_support`. Legacy place entries use `{ level: "place", country, system, place_type, system_version? }`. | | `geo_proximity` | object[] | Deprecated. Use `targeting_overlay.geo_proximity`. | | `keywords` | object[] | Deprecated. Use `targeting_overlay.keyword_targets`; broad thematic intent remains in `brief`. | | `signal_targeting` | SignalTargeting[] | Deprecated. Use `targeting_overlay.signal_targeting_groups` for known selections or `required_overlay_support.signal_targeting_groups` for later selection. | @@ -318,10 +319,12 @@ version that the product must let the buyer select: }, "required_overlay_support": { "geo_places": { - "country": "NL", - "system": "geonames", - "system_version": "2026-05", - "place_type": "city" + "systems": { + "geonames": { + "countries": { "NL": ["city"] }, + "system_versions": ["2026-05"] + } + } } } } @@ -329,7 +332,13 @@ version that the product must let the buyer select: The returned Product `overlay_support.geo_places` is binding permission to supply matching place IDs on packages later, subject to disclosed limits. It -does not guarantee value-specific inventory before the IDs are provided. +does not guarantee value-specific inventory or preserve an earlier forecast or +price before the IDs are provided. If the eventual values fall outside the +configured product's priced or guaranteed envelope, the seller rejects create +with `REQUOTE_REQUIRED` and the buyer rediscovers with those exact values in +`targeting_overlay.geo_places`. Inclusion and exclusion permission are +independent; request `geo_places_exclude` separately when exclusions will be +chosen later. ### Placement fields diff --git a/scripts/error-code-drift-dispositions.json b/scripts/error-code-drift-dispositions.json index 7fc2b4892d..270a5f674c 100644 --- a/scripts/error-code-drift-dispositions.json +++ b/scripts/error-code-drift-dispositions.json @@ -265,6 +265,11 @@ "disposition": "held-for-next-minor", "target_version": "3.2", "note": "Per-record usage validation rejection for report_usage (#5892). Additive standard vocabulary; held for 3.2." + }, + "TARGETING_TOO_NARROW": { + "disposition": "held-for-next-minor", + "target_version": "3.2", + "note": "Identifier-based place targeting (#5588). Create-time rejection when a valid effective place overlay yields zero executable inventory; additive vocabulary held for 3.2." } } } diff --git a/scripts/lint-storyboard-context-output-paths.cjs b/scripts/lint-storyboard-context-output-paths.cjs index 87f3fd6587..7a86c75e84 100644 --- a/scripts/lint-storyboard-context-output-paths.cjs +++ b/scripts/lint-storyboard-context-output-paths.cjs @@ -137,32 +137,51 @@ function isPureExtensionPoint(node) { * * Empty path resolves trivially (the root itself exists). */ -function pathResolves(node, segments, seen = new Set()) { +function resolveLocalRef(root, ref) { + if (!root || typeof root !== 'object' || typeof ref !== 'string' || !ref.startsWith('#/')) { + return null; + } + return ref + .slice(2) + .split('/') + .map((token) => token.replace(/~1/g, '/').replace(/~0/g, '~')) + .reduce( + (current, token) => + current && typeof current === 'object' ? current[token] : undefined, + root, + ); +} + +function pathResolves(node, segments, seen = new Set(), root = node) { if (!node || typeof node !== 'object') return false; if (segments.length === 0) return true; if (node.$ref) { - if (seen.has(node.$ref)) return false; + const refKey = `${root?.$id || ''}:${node.$ref}`; + if (seen.has(refKey)) return false; const next = new Set(seen); - next.add(node.$ref); + next.add(refKey); + if (node.$ref.startsWith('#/')) { + return pathResolves(resolveLocalRef(root, node.$ref), segments, next, root); + } const resolved = loadSchema(node.$ref); - return pathResolves(resolved, segments, next); + return pathResolves(resolved, segments, next, resolved); } const [seg, ...rest] = segments; // Numeric — array index. Only valid when this node has `items`. if (/^\d+$/.test(seg)) { - if (node.items && pathResolves(node.items, rest, seen)) return true; + if (node.items && pathResolves(node.items, rest, seen, root)) return true; } else { const isDeclaredProperty = node.properties && Object.prototype.hasOwnProperty.call(node.properties, seg); if (isDeclaredProperty) { - if (pathResolves(node.properties[seg], rest, seen)) return true; + if (pathResolves(node.properties[seg], rest, seen, root)) return true; } else if ( node.additionalProperties && typeof node.additionalProperties === 'object' && - pathResolves(node.additionalProperties, rest, seen) + pathResolves(node.additionalProperties, rest, seen, root) ) { return true; } @@ -178,7 +197,7 @@ function pathResolves(node, segments, seen = new Set()) { const variants = node.oneOf || node.anyOf || node.allOf; if (Array.isArray(variants)) { for (const variant of variants) { - if (pathResolves(variant, segments, seen)) return true; + if (pathResolves(variant, segments, seen, root)) return true; } } diff --git a/scripts/lint-storyboard-validations-paths.cjs b/scripts/lint-storyboard-validations-paths.cjs index 27751a9976..f1abbf7ac7 100644 --- a/scripts/lint-storyboard-validations-paths.cjs +++ b/scripts/lint-storyboard-validations-paths.cjs @@ -182,31 +182,50 @@ function isPureExtensionPoint(node) { return true; } -function pathResolves(node, segments, seen = new Set()) { +function resolveLocalRef(root, ref) { + if (!root || typeof root !== 'object' || typeof ref !== 'string' || !ref.startsWith('#/')) { + return null; + } + return ref + .slice(2) + .split('/') + .map((token) => token.replace(/~1/g, '/').replace(/~0/g, '~')) + .reduce( + (current, token) => + current && typeof current === 'object' ? current[token] : undefined, + root, + ); +} + +function pathResolves(node, segments, seen = new Set(), root = node) { if (!node || typeof node !== 'object') return false; if (segments.length === 0) return true; if (node.$ref) { - if (seen.has(node.$ref)) return false; + const refKey = `${root?.$id || ''}:${node.$ref}`; + if (seen.has(refKey)) return false; const next = new Set(seen); - next.add(node.$ref); + next.add(refKey); + if (node.$ref.startsWith('#/')) { + return pathResolves(resolveLocalRef(root, node.$ref), segments, next, root); + } const resolved = loadSchema(node.$ref); - return pathResolves(resolved, segments, next); + return pathResolves(resolved, segments, next, resolved); } const [seg, ...rest] = segments; if ((/^\d+$/.test(seg) || seg === '*') && node.items) { - if (pathResolves(node.items, rest, seen)) return true; + if (pathResolves(node.items, rest, seen, root)) return true; } else { const isDeclaredProperty = node.properties && Object.prototype.hasOwnProperty.call(node.properties, seg); if (isDeclaredProperty) { - if (pathResolves(node.properties[seg], rest, seen)) return true; + if (pathResolves(node.properties[seg], rest, seen, root)) return true; } else if ( node.additionalProperties && typeof node.additionalProperties === 'object' && - pathResolves(node.additionalProperties, rest, seen) + pathResolves(node.additionalProperties, rest, seen, root) ) { return true; } @@ -217,7 +236,7 @@ function pathResolves(node, segments, seen = new Set()) { const variants = node.oneOf || node.anyOf || node.allOf; if (Array.isArray(variants)) { for (const variant of variants) { - if (pathResolves(variant, segments, seen)) return true; + if (pathResolves(variant, segments, seen, root)) return true; } } diff --git a/specs/targeting-aware-product-discovery.md b/specs/targeting-aware-product-discovery.md index 28ecce2ced..b29d395e63 100644 --- a/specs/targeting-aware-product-discovery.md +++ b/specs/targeting-aware-product-discovery.md @@ -351,9 +351,13 @@ The request uses `TargetingOverlayRequirements`; the response uses required system arrays, but never seller maxima. Matching is a product-support superset operation: -- product `true` satisfies every protocol-valid requirement for that dimension; +- where a dimension permits the boolean form, product `true` satisfies every + protocol-valid requirement for that dimension; - when both sides are objects, every required boolean is true in the product; - every required array is a subset of the corresponding product array; and +- for named places, every required identifier-system and country key exists, + and requested type/version arrays are subsets of the corresponding Product + support arrays; and - a missing or unknown required field does not match. Numeric limits such as `max_values_per_package` and `max_packages` are @@ -421,6 +425,37 @@ like every other concrete constraint. If inclusion and exclusion overlap, exclusion wins; a seller that cannot enforce the result rejects the request rather than silently broadening it. +Named-place overlays are stricter: the seller rejects the same +`(country, system, place_type, value)` in `geo_places` and +`geo_places_exclude`, even across catalog versions, rather than applying +exclusion precedence. + +Named-place requirements bind the identifier namespace before values are +known. They key first by `system`, then by country, so two catalogs or two +same-named places cannot collide and country/type support is never interpreted +as a Cartesian product: + +```json +{ + "required_overlay_support": { + "geo_places": { + "systems": { + "geonames": { + "countries": { "NL": ["city"] }, + "system_versions": ["2026-05"] + } + } + } + } +} +``` + +A matching Product returns the same keyed structure under +`overlay_support.geo_places`, adds `current_version` and its complete selectable +`system_versions`, and may disclose package/value limits. This is binding +permission to provide values later, not a value-specific availability promise. +`geo_places_exclude` is requested and declared independently. + ## Sparse targeting resolution ### Exact acceptance diff --git a/static/compliance/source/protocols/media-buy/scenarios/geo_place_targeting.yaml b/static/compliance/source/protocols/media-buy/scenarios/geo_place_targeting.yaml index c16fd9231f..85cacf7eb4 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/geo_place_targeting.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/geo_place_targeting.yaml @@ -2,7 +2,7 @@ id: media_buy_seller/geo_place_targeting version: "1.0.0" title: "Seller resolves, validates, and echoes identifier-based place targeting" category: media_buy_seller -summary: "Verifies version-aware place discovery, create/update persistence, package-state echo, and deterministic semantic rejection cases." +summary: "Verifies known-now place discovery, declared-later place permission, create/update persistence, package-state echo, and deterministic semantic rejection cases." track: media_buy requires_capability: @@ -28,12 +28,17 @@ narrative: | stale identifier. This scenario applies only to sellers declaring GeoNames city support in the - Netherlands. It captures the seller's current catalog version, requires that - exact version during product discovery, creates a buy targeting the active - GeoNames Amsterdam city ID, reads the buy back to verify the complete applied - tuple, adds The Hague as an exclusion through update_media_buy, and verifies - both persisted lists. A final negative probe sends a well-formed but unknown - GeoNames ID and requires rejection rather than silent dropping. + Netherlands. It captures the seller's current catalog version, supplies the + active Amsterdam ID during product discovery so the configured product and + forecast are scoped to it, and explicitly requests permission to select + GeoNames exclusions later. It creates that configured product without + repeating Amsterdam, reads the buy back to verify the complete applied tuple, + adds The Hague as an exclusion through update_media_buy, and verifies both + persisted lists. A final negative probe sends a well-formed but unknown + GeoNames ID and requires rejection rather than silent dropping. Create-time + probes cover every disposition in the place-overlay matrix: unsupported + capability, invalid version or identifier, zero inventory, and executable + targeting that requires rediscovery for new terms. The current compliance harness verifies the resolver declaration and schema contract but does not make an arbitrary HTTP call to the advertised URL. @@ -60,7 +65,9 @@ prerequisites: through get_adcp_capabilities. The product and auction pricing fixtures are seeded through comply_test_controller. GeoNames IDs 2759794 (Amsterdam) and 2747373 (The Hague) are external catalog identifiers, not seller-local - fixture IDs. + fixture IDs. The product fixture's namespaced compliance extension makes + the target-specific forecast, price, and create dispositions deterministic; + it is sandbox controller behavior, not a production Product contract. test_kit: "test-kits/acme-outdoor.yaml" controller_seeding: true @@ -71,6 +78,53 @@ fixtures: channels: ["display"] format_ids: - id: "display_300x250" + overlay_support: + geo_places: + systems: + geonames: + countries: + NL: ["city"] + current_version: "2026-05" + system_versions: ["2026-05", "2025-11"] + max_values_per_package: 25 + geo_places_exclude: + systems: + geonames: + countries: + NL: ["city"] + current_version: "2026-05" + system_versions: ["2026-05", "2025-11"] + max_values_per_package: 25 + forecast: + forecast_range_unit: "availability" + method: "modeled" + currency: "EUR" + points: + - metrics: + impressions: { mid: 1000000 } + ext: + adcp_compliance: + geo_place_cases: + - country: "NL" + system: "geonames" + system_version: "2026-05" + place_type: "city" + value: "2759794" + outcome: "accepted" + forecast_impressions_mid: 240000 + floor_price: 3.75 + - country: "NL" + system: "geonames" + system_version: "2026-05" + place_type: "city" + value: "2747891" + outcome: "TARGETING_TOO_NARROW" + - country: "NL" + system: "geonames" + system_version: "2026-05" + place_type: "city" + value: "2745912" + outcome: "REQUOTE_REQUIRED" pricing_options: - product_id: "geo_place_display_nl" pricing_option_id: "geo_place_auction_cpm" @@ -119,26 +173,35 @@ phases: description: "current_version is included in supported_versions" - id: get_place_targetable_product - title: "Filter products by exact place capability" + title: "Discover known Amsterdam targeting and later exclusion permission" task: get_products schema_ref: "media-buy/get-products-request.json" response_schema_ref: "media-buy/get-products-response.json" doc_ref: "/media-buy/task-reference/get_products" stateful: true expected: | - Return the seeded product because the seller can enforce the exact - geonames/NL/city/current-version capability. + Return a configured product whose price and aggregate forecast are + scoped to Amsterdam. Its overlay_support grants later GeoNames + NL/city exclusions at the captured catalog version. sample_request: buying_mode: "wholesale" filters: channels: ["display"] is_fixed_price: false - required_geo_targeting: - - level: "place" - country: "NL" + targeting_overlay: + geo_places: + - country: "NL" system: "geonames" system_version: "$context.place_system_version" place_type: "city" + values: ["2759794"] + required_overlay_support: + geo_places_exclude: + systems: + geonames: + countries: + NL: ["city"] + system_versions: ["$context.place_system_version"] account: brand: domain: "acmeoutdoor.example" @@ -157,6 +220,93 @@ phases: - check: field_present path: "products[0].product_id" description: "At least one matching product is returned" + - check: field_value + path: "products[0].is_custom" + value: true + description: "Exact Amsterdam targeting produces a request-scoped configured product" + - check: field_present + path: "products[0].expires_at" + description: "The target-specific forecast and price are time-bound" + - check: field_present + path: "products[0].forecast" + description: "Aggregate forecast is scoped to the known Amsterdam target" + - check: field_value + path: "products[0].forecast.points[0].metrics.impressions.mid" + value: 240000 + description: "Amsterdam discovery returns the deterministic target-scoped forecast, not the one-million-impression base forecast" + - check: field_value + path: "products[0].pricing_options[0].floor_price" + value: 3.75 + description: "Amsterdam discovery returns the deterministic target-scoped price, not the EUR 3.00 base floor" + - check: field_contains + path: "products[0].overlay_support.geo_places_exclude.systems.geonames.countries.NL[*]" + value: "city" + description: "Product grants the requested collision-safe NL/city exclusion tuple" + - check: field_contains + path: "products[0].overlay_support.geo_places_exclude.systems.geonames.system_versions[*]" + value: "$context.place_system_version" + description: "Product grants the exact requested exclusion catalog version" + + - id: get_place_deferred_product + title: "Declare that place values will be supplied later" + task: get_products + schema_ref: "media-buy/get-products-request.json" + response_schema_ref: "media-buy/get-products-response.json" + doc_ref: "/media-buy/task-reference/get_products" + stateful: true + expected: | + Return a product that binds selectable GeoNames NL/city inclusion and + exclusion support without claiming availability, price, or forecast + for any place value that the buyer has not supplied yet. + sample_request: + buying_mode: "wholesale" + filters: + channels: ["display"] + is_fixed_price: false + required_overlay_support: + geo_places: + systems: + geonames: + countries: + NL: ["city"] + system_versions: ["$context.place_system_version"] + geo_places_exclude: + systems: + geonames: + countries: + NL: ["city"] + system_versions: ["$context.place_system_version"] + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + context: + correlation_id: "geo_place_targeting--get_place_deferred_product" + context_outputs: + - key: "deferred_product_id" + path: "products[0].product_id" + - key: "deferred_pricing_option_id" + path: "products[0].pricing_options[0].pricing_option_id" + validations: + - check: response_schema + description: "Response matches get-products-response.json" + - check: field_contains + path: "products[0].overlay_support.geo_places.systems.geonames.countries.NL[*]" + value: "city" + description: "Product grants the requested later NL/city inclusion tuple" + - check: field_contains + path: "products[0].overlay_support.geo_places.systems.geonames.system_versions[*]" + value: "$context.place_system_version" + description: "Product grants the exact requested inclusion catalog version" + - check: field_contains + path: "products[0].overlay_support.geo_places_exclude.systems.geonames.countries.NL[*]" + value: "city" + description: "Product independently grants the requested later exclusion tuple" + - check: field_contains + path: "products[0].overlay_support.geo_places_exclude.systems.geonames.system_versions[*]" + value: "$context.place_system_version" + description: "Product independently grants the exact exclusion catalog version" - id: create_and_echo_place title: "Create and read back applied place targeting" @@ -169,8 +319,9 @@ phases: doc_ref: "/media-buy/task-reference/create_media_buy" stateful: true expected: | - Accept the active GeoNames city identifier and persist the exact - country/system/version/type/value tuple. + Selecting the configured product accepts and persists the Amsterdam + targeting already supplied during discovery; the buyer does not need + to repeat it on the package. sample_request: brand: domain: "acmeoutdoor.example" @@ -186,14 +337,6 @@ phases: pricing_option_id: "$context.pricing_option_id" bid_price: 4.25 budget: 12000 - targeting_overlay: - geo_places: - - country: "NL" - system: "geonames" - place_type: "city" - values: ["2759794"] - value_labels: - "2759794": "Amsterdam, North Holland, Netherlands" idempotency_key: "$generate:uuid_v4#geo_place_targeting_create" context: correlation_id: "geo_place_targeting--create_place_targeted_buy" @@ -349,6 +492,87 @@ phases: value: "$context.place_system_version" description: "Exclusion applied version is echoed" + - id: deferred_place_inclusion + title: "Supply a permitted place inclusion at create time" + depends_on: [discover_capability_and_product] + steps: + - id: create_deferred_inclusion_buy + title: "Create a buy with the later-selected Amsterdam target" + task: create_media_buy + schema_ref: "media-buy/create-media-buy-request.json" + response_schema_ref: "media-buy/create-media-buy-response.json" + doc_ref: "/media-buy/task-reference/create_media_buy" + stateful: true + expected: | + Accept a valid Amsterdam inclusion within the Product's declared + GeoNames/NL/city/version support. Success confirms that the selected + product terms cover the complete effective targeting. + sample_request: + brand: + domain: "acmeoutdoor.example" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + start_time: "asap" + end_time: "2099-09-30T23:59:59Z" + packages: + - product_id: "$context.deferred_product_id" + pricing_option_id: "$context.deferred_pricing_option_id" + bid_price: 4.25 + budget: 5000 + targeting_overlay: + geo_places: + - country: "NL" + system: "geonames" + system_version: "$context.place_system_version" + place_type: "city" + values: ["2759794"] + idempotency_key: "$generate:uuid_v4#geo_place_targeting_deferred_inclusion" + context: + correlation_id: "geo_place_targeting--create_deferred_inclusion_buy" + context_outputs: + - key: "deferred_inclusion_media_buy_id" + path: "media_buy_id" + validations: + - check: response_schema + description: "Response matches create-media-buy-response.json" + - check: field_present + path: "media_buy_id" + description: "Seller accepts the permitted later-selected place inclusion" + + - id: get_deferred_inclusion_buy + title: "Verify the later-selected inclusion persisted" + task: get_media_buys + schema_ref: "media-buy/get-media-buys-request.json" + response_schema_ref: "media-buy/get-media-buys-response.json" + doc_ref: "/media-buy/task-reference/get_media_buys" + stateful: true + expected: | + Echo Amsterdam with the exact applied catalog version, proving the + accepted create compiled and persisted the deferred value. + sample_request: + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + media_buy_ids: ["$context.deferred_inclusion_media_buy_id"] + context: + correlation_id: "geo_place_targeting--get_deferred_inclusion_buy" + validations: + - check: response_schema + description: "Response matches get-media-buys-response.json" + - check: field_contains + path: "media_buys[0].packages[0].targeting_overlay.geo_places[0].values[*]" + value: "2759794" + description: "Deferred Amsterdam inclusion persisted" + - check: field_value + path: "media_buys[0].packages[0].targeting_overlay.geo_places[0].system_version" + value: "$context.place_system_version" + description: "Seller echoes the exact selected catalog version" + - id: exclusion_only_place_targeting title: "Apply a place exclusion without a place inclusion" depends_on: [discover_capability_and_product] @@ -375,8 +599,8 @@ phases: start_time: "asap" end_time: "2099-09-30T23:59:59Z" packages: - - product_id: "$context.product_id" - pricing_option_id: "$context.pricing_option_id" + - product_id: "$context.deferred_product_id" + pricing_option_id: "$context.deferred_pricing_option_id" bid_price: 4.25 budget: 5000 targeting_overlay: @@ -456,8 +680,8 @@ phases: start_time: "asap" end_time: "2099-09-30T23:59:59Z" packages: - - product_id: "$context.product_id" - pricing_option_id: "$context.pricing_option_id" + - product_id: "$context.deferred_product_id" + pricing_option_id: "$context.deferred_pricing_option_id" bid_price: 4.25 budget: 5000 targeting_overlay: @@ -474,11 +698,206 @@ phases: - check: response_schema description: "Error response matches create-media-buy-response.json" - check: error_code - expected: "INVALID_REQUEST" + value: "INVALID_REQUEST" description: "Unknown place identifier is a correctable request error" - - check: field_present + - check: field_value path: "errors[0].field" - description: "Error points to the offending place target" + value: "packages[0].targeting_overlay.geo_places[0].values[0]" + description: "Error points to the exact unknown identifier" + + - id: create_with_unsupported_place_tuple + title: "Submit a country outside Product overlay support" + task: create_media_buy + schema_ref: "media-buy/create-media-buy-request.json" + response_schema_ref: "media-buy/create-media-buy-response.json" + doc_ref: "/media-buy/task-reference/create_media_buy" + expect_error: true + negative_path: payload_well_formed + stateful: false + expected: | + Reject with UNSUPPORTED_FEATURE because the Product declares + geonames/NL/city, not geonames/DE/city. This is capability failure, + not an invalid identifier within a supported tuple. + sample_request: + brand: + domain: "acmeoutdoor.example" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + start_time: "asap" + end_time: "2099-09-30T23:59:59Z" + packages: + - product_id: "$context.deferred_product_id" + pricing_option_id: "$context.deferred_pricing_option_id" + bid_price: 4.25 + budget: 5000 + targeting_overlay: + geo_places: + - country: "DE" + system: "geonames" + place_type: "city" + values: ["2950159"] + idempotency_key: "$generate:uuid_v4#geo_place_targeting_unsupported_tuple" + context: + correlation_id: "geo_place_targeting--create_with_unsupported_place_tuple" + validations: + - check: response_schema + description: "Error response matches create-media-buy-response.json" + - check: error_code + value: "UNSUPPORTED_FEATURE" + description: "A place tuple outside Product overlay support is unsupported" + - check: field_value + path: "errors[0].field" + value: "packages[0].targeting_overlay.geo_places[0].country" + description: "Error identifies the unsupported tuple field" + + - id: create_with_unsupported_place_version + title: "Submit an unsupported version within a supported place tuple" + task: create_media_buy + schema_ref: "media-buy/create-media-buy-request.json" + response_schema_ref: "media-buy/create-media-buy-response.json" + doc_ref: "/media-buy/task-reference/create_media_buy" + expect_error: true + negative_path: payload_well_formed + stateful: false + expected: | + Reject with INVALID_REQUEST because the NL/city tuple is supported but + the explicitly requested catalog version is not in system_versions. + sample_request: + brand: + domain: "acmeoutdoor.example" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + start_time: "asap" + end_time: "2099-09-30T23:59:59Z" + packages: + - product_id: "$context.deferred_product_id" + pricing_option_id: "$context.deferred_pricing_option_id" + bid_price: 4.25 + budget: 5000 + targeting_overlay: + geo_places: + - country: "NL" + system: "geonames" + system_version: "1900-01" + place_type: "city" + values: ["2759794"] + idempotency_key: "$generate:uuid_v4#geo_place_targeting_unsupported_version" + context: + correlation_id: "geo_place_targeting--create_with_unsupported_place_version" + validations: + - check: response_schema + description: "Error response matches create-media-buy-response.json" + - check: error_code + value: "INVALID_REQUEST" + description: "An unsupported explicit version on a supported tuple is invalid" + - check: field_value + path: "errors[0].field" + value: "packages[0].targeting_overlay.geo_places[0].system_version" + description: "Error identifies the unsupported version field" + + - id: create_with_zero_inventory_place + title: "Submit a valid place with zero executable inventory" + task: create_media_buy + schema_ref: "media-buy/create-media-buy-request.json" + response_schema_ref: "media-buy/create-media-buy-response.json" + doc_ref: "/media-buy/task-reference/create_media_buy" + expect_error: true + negative_path: payload_well_formed + stateful: false + expected: | + Reject valid Rotterdam identifier 2747891 with + TARGETING_TOO_NARROW because the deterministic fixture has no + executable inventory for its effective intersection. + sample_request: + brand: + domain: "acmeoutdoor.example" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + start_time: "asap" + end_time: "2099-09-30T23:59:59Z" + packages: + - product_id: "$context.deferred_product_id" + pricing_option_id: "$context.deferred_pricing_option_id" + bid_price: 4.25 + budget: 5000 + targeting_overlay: + geo_places: + - country: "NL" + system: "geonames" + system_version: "$context.place_system_version" + place_type: "city" + values: ["2747891"] + idempotency_key: "$generate:uuid_v4#geo_place_targeting_zero_inventory" + context: + correlation_id: "geo_place_targeting--create_with_zero_inventory_place" + validations: + - check: response_schema + description: "Error response matches create-media-buy-response.json" + - check: error_code + value: "TARGETING_TOO_NARROW" + description: "A valid target with zero executable inventory is too narrow" + - check: field_value + path: "errors[0].field" + value: "packages[0].targeting_overlay.geo_places" + description: "Error identifies the place overlay that eliminated inventory" + + - id: create_with_place_requiring_requote + title: "Submit an executable place outside the priced envelope" + task: create_media_buy + schema_ref: "media-buy/create-media-buy-request.json" + response_schema_ref: "media-buy/create-media-buy-response.json" + doc_ref: "/media-buy/task-reference/create_media_buy" + expect_error: true + negative_path: payload_well_formed + stateful: false + expected: | + Reject valid Utrecht identifier 2745912 with REQUOTE_REQUIRED. The + target is executable, but the deterministic fixture requires the + buyer to rediscover using this exact overlay for applicable terms. + sample_request: + brand: + domain: "acmeoutdoor.example" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + start_time: "asap" + end_time: "2099-09-30T23:59:59Z" + packages: + - product_id: "$context.deferred_product_id" + pricing_option_id: "$context.deferred_pricing_option_id" + bid_price: 4.25 + budget: 5000 + targeting_overlay: + geo_places: + - country: "NL" + system: "geonames" + system_version: "$context.place_system_version" + place_type: "city" + values: ["2745912"] + idempotency_key: "$generate:uuid_v4#geo_place_targeting_requote" + context: + correlation_id: "geo_place_targeting--create_with_place_requiring_requote" + validations: + - check: response_schema + description: "Error response matches create-media-buy-response.json" + - check: error_code + value: "REQUOTE_REQUIRED" + description: "Executable targeting outside selected terms requires rediscovery" + - check: field_value + path: "errors[0].field" + value: "packages[0].targeting_overlay.geo_places" + description: "Error identifies the out-of-envelope place overlay" - id: create_with_mismatched_value_label title: "Submit a diagnostic label for a value not being targeted" @@ -503,8 +922,8 @@ phases: start_time: "asap" end_time: "2099-09-30T23:59:59Z" packages: - - product_id: "$context.product_id" - pricing_option_id: "$context.pricing_option_id" + - product_id: "$context.deferred_product_id" + pricing_option_id: "$context.deferred_pricing_option_id" bid_price: 4.25 budget: 5000 targeting_overlay: @@ -523,7 +942,7 @@ phases: - check: response_schema description: "Error response matches create-media-buy-response.json" - check: error_code - expected: "INVALID_REQUEST" + value: "INVALID_REQUEST" description: "Mismatched diagnostic label is rejected" - id: create_with_include_exclude_overlap @@ -549,8 +968,8 @@ phases: start_time: "asap" end_time: "2099-09-30T23:59:59Z" packages: - - product_id: "$context.product_id" - pricing_option_id: "$context.pricing_option_id" + - product_id: "$context.deferred_product_id" + pricing_option_id: "$context.deferred_pricing_option_id" bid_price: 4.25 budget: 5000 targeting_overlay: @@ -573,7 +992,7 @@ phases: - check: response_schema description: "Error response matches create-media-buy-response.json" - check: error_code - expected: "INVALID_REQUEST" + value: "INVALID_REQUEST" description: "Same-value include/exclude overlap is rejected" - id: reject_cross_version_place_overlap @@ -611,8 +1030,8 @@ phases: start_time: "asap" end_time: "2099-09-30T23:59:59Z" packages: - - product_id: "$context.product_id" - pricing_option_id: "$context.pricing_option_id" + - product_id: "$context.deferred_product_id" + pricing_option_id: "$context.deferred_pricing_option_id" bid_price: 4.25 budget: 5000 targeting_overlay: @@ -635,80 +1054,5 @@ phases: - check: response_schema description: "Error response matches create-media-buy-response.json" - check: error_code - expected: "INVALID_REQUEST" + value: "INVALID_REQUEST" description: "Cross-version same-value include/exclude overlap is rejected" - - - id: brief_mode_place_filter_coverage - title: "Verify filters.places intersection semantics in brief mode" - depends_on: [discover_capability_and_product] - steps: - - id: get_products_brief_covered_place - title: "Brief-mode filter with a covered place returns at least one product" - task: get_products - schema_ref: "media-buy/get-products-request.json" - response_schema_ref: "media-buy/get-products-response.json" - doc_ref: "/media-buy/task-reference/get_products" - stateful: false - expected: | - Return at least one product because the seller's coverage intersects - Amsterdam (GeoNames 2759794). Verifies ANY/intersection semantics: - a single matched value is sufficient for a product to appear. - sample_request: - buying_mode: "brief" - brief: "Display campaign reaching Amsterdam, Netherlands" - filters: - channels: ["display"] - places: - - country: "NL" - system: "geonames" - place_type: "city" - values: ["2759794"] - account: - brand: - domain: "acmeoutdoor.example" - operator: "pinnacle-agency.example" - sandbox: true - context: - correlation_id: "geo_place_targeting--get_products_brief_covered_place" - validations: - - check: response_schema - description: "Response matches get-products-response.json" - - check: field_present - path: "products[0].product_id" - description: "At least one product covering the requested NL/geonames/city place is returned" - - - id: get_products_brief_uncovered_place - title: "Brief-mode filter for a place outside seller coverage returns no products and no error" - task: get_products - schema_ref: "media-buy/get-products-request.json" - response_schema_ref: "media-buy/get-products-response.json" - doc_ref: "/media-buy/task-reference/get_products" - stateful: false - expected: | - Return an empty products list — not an error. The seller has no products - declared for Berlin, Germany (GeoNames 2950158), so intersection yields - zero matches. Verifies filter-not-fail: unmatched places in filters.places - produce empty results, not INVALID_REQUEST. - sample_request: - buying_mode: "brief" - brief: "Display campaign reaching Berlin, Germany" - filters: - channels: ["display"] - places: - - country: "DE" - system: "geonames" - place_type: "city" - values: ["2950158"] - account: - brand: - domain: "acmeoutdoor.example" - operator: "pinnacle-agency.example" - sandbox: true - context: - correlation_id: "geo_place_targeting--get_products_brief_uncovered_place" - validations: - - check: response_schema - description: "Response matches get-products-response.json" - - check: field_absent - path: "products[0].product_id" - description: "No products match a place outside the seller's declared coverage" diff --git a/static/schemas/source/core/product-filters.json b/static/schemas/source/core/product-filters.json index c82ed55506..0bd1f70707 100644 --- a/static/schemas/source/core/product-filters.json +++ b/static/schemas/source/core/product-filters.json @@ -122,23 +122,11 @@ "description": "Metro code within the system (e.g., '501' for NYC DMA)" } }, - "required": [ - "system", - "code" - ], + "required": ["system", "code"], "additionalProperties": false }, "minItems": 1 }, - "places": { - "type": "array", - "description": "DEPRECATED. Use get_products.targeting_overlay.geo_places for known values or required_overlay_support.geo_places when values will be supplied on packages later.", - "deprecated": true, - "items": { - "$ref": "/schemas/core/geo-place-area.json" - }, - "minItems": 1 - }, "channels": { "type": "array", "description": "Filter by advertising channels (e.g., ['display', 'ctv', 'dooh'])", @@ -221,9 +209,7 @@ "description": "When true, require this provider to support identity match." } }, - "required": [ - "agent_url" - ], + "required": ["agent_url"], "additionalProperties": true }, "minItems": 1 @@ -245,155 +231,73 @@ }, "required_geo_targeting": { "type": "array", - "description": "DEPRECATED. Use get_products.required_overlay_support, which is product-scoped and applies consistently to geographic and non-geographic targeting dimensions. Legacy place entries require country, system, and place_type and may require an exact system_version.", + "description": "DEPRECATED. Use get_products.required_overlay_support, which is product-scoped and applies consistently to geographic and non-geographic targeting dimensions.", "deprecated": true, "items": { "type": "object", "properties": { "level": { - "$ref": "/schemas/enums/geo-targeting-level.json" + "$ref": "/schemas/enums/geo-level.json", + "description": "Geographic targeting level (country, region, metro, postal_area)" }, "country": { "type": "string", "pattern": "^[A-Z]{2}$", - "description": "ISO 3166-1 alpha-2 country code. Required for native postal_area system filters and place filters; not applicable to country, region, or metro filters." + "description": "ISO 3166-1 alpha-2 country code. Required for native postal_area system filters; not applicable to country, region, or metro filters." }, "system": { "type": "string", - "description": "Optional classification system within the level. Use for a specific metro system (e.g., 'nielsen_dma'), native postal_area system (e.g., 'zip' with country 'US'), deprecated legacy postal alias (e.g., 'us_zip'), or place identifier namespace (e.g., 'geonames'). Not applicable for country/region which use ISO standards." - }, - "place_type": { - "$ref": "/schemas/core/geo-place-type.json" - }, - "system_version": { - "type": "string", - "minLength": 1, - "description": "Exact place catalog version required for level=place. Not applicable to other levels." + "description": "Optional classification system within the level. Use for a specific metro system (e.g., 'nielsen_dma'), native postal_area system (e.g., 'zip' with country 'US'), or deprecated legacy postal alias (e.g., 'us_zip'). Not applicable for country/region which use ISO standards." } }, - "required": [ - "level" - ], + "required": ["level"], "allOf": [ { "if": { - "properties": { - "level": { - "enum": [ - "country", - "region" - ] - } - }, - "required": [ - "level" - ] + "properties": { "level": { "enum": ["country", "region"] } }, + "required": ["level"] }, "then": { "not": { "anyOf": [ - { - "required": [ - "country" - ] - }, - { - "required": [ - "system" - ] - }, - { - "required": [ - "place_type" - ] - }, - { - "required": [ - "system_version" - ] - } + { "required": ["country"] }, + { "required": ["system"] } ] } } }, { "if": { - "properties": { - "level": { - "const": "metro" - } - }, - "required": [ - "level" - ] + "properties": { "level": { "const": "metro" } }, + "required": ["level"] }, "then": { "anyOf": [ { - "not": { - "required": [ - "system" - ] - } + "not": { "required": ["system"] } }, { "properties": { - "system": { - "$ref": "/schemas/enums/metro-system.json" - } + "system": { "$ref": "/schemas/enums/metro-system.json" } }, - "required": [ - "system" - ] + "required": ["system"] } ], - "not": { - "anyOf": [ - { - "required": [ - "country" - ] - }, - { - "required": [ - "place_type" - ] - }, - { - "required": [ - "system_version" - ] - } - ] - } + "not": { "required": ["country"] } } }, { "if": { - "properties": { - "level": { - "const": "postal_area" - } - }, - "required": [ - "level" - ] + "properties": { "level": { "const": "postal_area" } }, + "required": ["level"] }, "then": { "anyOf": [ { "not": { "anyOf": [ - { - "required": [ - "country" - ] - }, - { - "required": [ - "system" - ] - } + { "required": ["country"] }, + { "required": ["system"] } ] } }, @@ -406,69 +310,11 @@ "$ref": "/schemas/enums/legacy-postal-system.json" } }, - "required": [ - "system" - ], - "not": { - "required": [ - "country" - ] - } + "required": ["system"], + "not": { "required": ["country"] } } ] } - }, - { - "if": { - "properties": { - "level": { - "const": "postal_area" - } - }, - "required": [ - "level" - ] - }, - "then": { - "not": { - "anyOf": [ - { - "required": [ - "place_type" - ] - }, - { - "required": [ - "system_version" - ] - } - ] - } - } - }, - { - "if": { - "properties": { - "level": { - "const": "place" - } - }, - "required": [ - "level" - ] - }, - "then": { - "properties": { - "system": { - "$ref": "/schemas/core/geo-place-system.json" - } - }, - "required": [ - "country", - "system", - "place_type" - ] - } } ], "additionalProperties": false @@ -542,10 +388,7 @@ "$ref": "/schemas/enums/travel-time-unit.json" } }, - "required": [ - "value", - "unit" - ], + "required": ["value", "unit"], "additionalProperties": false }, "transport_mode": { @@ -566,10 +409,7 @@ "description": "Distance unit" } }, - "required": [ - "value", - "unit" - ], + "required": ["value", "unit"], "additionalProperties": false }, "geometry": { @@ -578,10 +418,7 @@ "properties": { "type": { "type": "string", - "enum": [ - "Polygon", - "MultiPolygon" - ], + "enum": ["Polygon", "MultiPolygon"], "description": "GeoJSON geometry type" }, "coordinates": { @@ -589,73 +426,35 @@ "description": "GeoJSON coordinates array" } }, - "required": [ - "type", - "coordinates" - ], + "required": ["type", "coordinates"], "additionalProperties": false } }, "oneOf": [ { - "required": [ - "lat", - "lng", - "travel_time", - "transport_mode" - ], + "required": ["lat", "lng", "travel_time", "transport_mode"], "not": { "anyOf": [ - { - "required": [ - "radius" - ] - }, - { - "required": [ - "geometry" - ] - } + { "required": ["radius"] }, + { "required": ["geometry"] } ] } }, { - "required": [ - "lat", - "lng", - "radius" - ], + "required": ["lat", "lng", "radius"], "not": { "anyOf": [ - { - "required": [ - "travel_time" - ] - }, - { - "required": [ - "geometry" - ] - } + { "required": ["travel_time"] }, + { "required": ["geometry"] } ] } }, { - "required": [ - "geometry" - ], + "required": ["geometry"], "not": { "anyOf": [ - { - "required": [ - "travel_time" - ] - }, - { - "required": [ - "radius" - ] - } + { "required": ["travel_time"] }, + { "required": ["radius"] } ] } } @@ -681,18 +480,9 @@ "minItems": 1, "uniqueItems": true, "examples": [ - [ - "completed_views" - ], - [ - "completed_views", - "completion_rate" - ], - [ - "impressions", - "spend", - "engagements" - ] + ["completed_views"], + ["completed_views", "completion_rate"], + ["impressions", "spend", "engagements"] ] }, "required_vendor_metrics": { @@ -745,9 +535,7 @@ "default": "broad" } }, - "required": [ - "keyword" - ], + "required": ["keyword"], "additionalProperties": false }, "minItems": 1 diff --git a/static/schemas/source/core/targeting-overlay-requirements.json b/static/schemas/source/core/targeting-overlay-requirements.json index a10b81490e..d5da0fadcc 100644 --- a/static/schemas/source/core/targeting-overlay-requirements.json +++ b/static/schemas/source/core/targeting-overlay-requirements.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/targeting-overlay-requirements.json", "title": "Targeting Overlay Requirements", - "description": "Buyer minimum requirements for targeting dimensions that will be selected on packages later. This request shape deliberately excludes seller limits such as max_values_per_package and max_packages. A returned Product.overlay_support covers a required field when: (1) the product field is true, meaning unrestricted protocol-valid support for that dimension; or (2) both values are objects, every required boolean is true in the product, and every required array is a subset of the corresponding product array. Missing or unknown required fields do not match. Numeric seller limits are disclosed only in Product.overlay_support and do not participate in requirement matching.", + "description": "Buyer minimum requirements for targeting dimensions that will be selected on packages later. This request shape deliberately excludes seller limits such as max_values_per_package and max_packages. A returned Product.overlay_support covers a required field when: (1) where the field schema permits a boolean form, the product field is true, meaning unrestricted protocol-valid support for that dimension; or (2) both values are objects, every required boolean is true in the product, and every required array is a subset of the corresponding product array. geo_places and geo_places_exclude always use the collision-safe object form: every requested system and country key must exist and each requested place-type and system-version array must be a subset of the corresponding product array. Missing or unknown required fields do not match. Numeric seller limits are disclosed only in Product.overlay_support and do not participate in requirement matching.", "type": "object", "definitions": { "required": { "type": "boolean", "const": true }, @@ -24,6 +24,46 @@ } ] }, + "placeCatalogRequirement": { + "type": "object", + "description": "Required country/type pairs and optional exact catalog versions for one identifier system. Country keys prevent a false Cartesian product between countries and place types.", + "properties": { + "countries": { + "type": "object", + "propertyNames": { "pattern": "^[A-Z]{2}$" }, + "additionalProperties": { + "type": "array", + "items": { "$ref": "/schemas/core/geo-place-type.json" }, + "minItems": 1, + "uniqueItems": true + }, + "minProperties": 1 + }, + "system_versions": { + "type": "array", + "description": "Optional exact catalog versions that must remain selectable. Versions are opaque strings, not ordered ranges.", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "uniqueItems": true + } + }, + "required": ["countries"], + "additionalProperties": false + }, + "placeRequirement": { + "type": "object", + "description": "Identifier systems and collision-safe country/type pairs the buyer must be allowed to select later.", + "properties": { + "systems": { + "type": "object", + "propertyNames": { "$ref": "/schemas/core/geo-place-system.json" }, + "additionalProperties": { "$ref": "#/definitions/placeCatalogRequirement" }, + "minProperties": 1 + } + }, + "required": ["systems"], + "additionalProperties": false + }, "keywordRequirement": { "anyOf": [ { "$ref": "#/definitions/required" }, @@ -50,6 +90,8 @@ "geo_regions_exclude": { "$ref": "#/definitions/required" }, "geo_metros": { "$ref": "#/definitions/metroRequirement" }, "geo_metros_exclude": { "$ref": "#/definitions/metroRequirement" }, + "geo_places": { "$ref": "#/definitions/placeRequirement" }, + "geo_places_exclude": { "$ref": "#/definitions/placeRequirement" }, "geo_postal_areas": { "anyOf": [ { "$ref": "#/definitions/required" }, diff --git a/static/schemas/source/core/targeting-overlay-support.json b/static/schemas/source/core/targeting-overlay-support.json index 4c626d7222..976cafbd0e 100644 --- a/static/schemas/source/core/targeting-overlay-support.json +++ b/static/schemas/source/core/targeting-overlay-support.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/targeting-overlay-support.json", "title": "Targeting Overlay Support", - "description": "Product-scoped package targeting dimensions that may be supplied or changed after discovery. This is the seller response shape and may disclose seller limits such as max_values_per_package and max_packages. Product.overlay_support is the binding selectable-targeting contract. Presence means selectable support; inherent product coverage alone does not satisfy a future-support requirement. Buyer minimums use targeting-overlay-requirements.json, which excludes seller-shaped limit fields.", + "description": "Product-scoped package targeting dimensions that may be supplied or changed after discovery. This is the seller response shape and may disclose seller limits such as max_values_per_package and max_packages. Product.overlay_support is the binding selectable-targeting contract. Presence means selectable support; inherent product coverage alone does not satisfy a future-support requirement. This permission guarantees selectability within the declared systems, countries, types, versions, and limits, not value-specific inventory or an unchanged price/forecast before values are supplied. Buyer minimums use targeting-overlay-requirements.json, which excludes seller-shaped limit fields.", "type": "object", "definitions": { "supported": { @@ -43,6 +43,63 @@ } ] }, + "placeCatalogSupport": { + "type": "object", + "description": "Selectable country/type pairs and catalog versions for one identifier system.", + "properties": { + "countries": { + "type": "object", + "propertyNames": { "pattern": "^[A-Z]{2}$" }, + "additionalProperties": { + "type": "array", + "items": { "$ref": "/schemas/core/geo-place-type.json" }, + "minItems": 1, + "uniqueItems": true + }, + "minProperties": 1 + }, + "current_version": { + "type": "string", + "minLength": 1, + "description": "Version applied when a later package overlay omits system_version. Must be present in system_versions." + }, + "system_versions": { + "type": "array", + "description": "Exact catalog versions selectable on later package overlays.", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "uniqueItems": true + }, + "ext": { "$ref": "/schemas/core/ext.json" } + }, + "required": ["countries", "current_version", "system_versions"], + "additionalProperties": false, + "x-adcp-validation": { + "member_of": { "field": "current_version", "array_field": "system_versions" }, + "description": "JSON Schema draft-07 cannot express scalar membership in a sibling array; conformance tooling MUST verify current_version is present in system_versions." + } + }, + "placeSupport": { + "type": "object", + "description": "Binding permission to select named-place values later, keyed first by identifier system and then by country to avoid namespace and place-type collisions.", + "properties": { + "systems": { + "type": "object", + "propertyNames": { "$ref": "/schemas/core/geo-place-system.json" }, + "additionalProperties": { "$ref": "#/definitions/placeCatalogSupport" }, + "minProperties": 1 + }, + "max_values_per_package": { "type": "integer", "minimum": 1 }, + "max_packages": { + "type": "integer", + "minimum": 1, + "description": "Optional maximum number of independently place-targeted packages the seller will create from this configured product." + }, + "ext": { "$ref": "/schemas/core/ext.json" } + }, + "required": ["systems"], + "additionalProperties": false + }, "keywordSupport": { "anyOf": [ { @@ -76,6 +133,8 @@ "geo_regions_exclude": { "$ref": "#/definitions/supported" }, "geo_metros": { "$ref": "#/definitions/metroSupport" }, "geo_metros_exclude": { "$ref": "#/definitions/metroSupport" }, + "geo_places": { "$ref": "#/definitions/placeSupport" }, + "geo_places_exclude": { "$ref": "#/definitions/placeSupport" }, "geo_postal_areas": { "anyOf": [ { "$ref": "#/definitions/supported" }, diff --git a/static/schemas/source/enums/error-code.json b/static/schemas/source/enums/error-code.json index 1d47c36ba8..5b3c2df22c 100644 --- a/static/schemas/source/enums/error-code.json +++ b/static/schemas/source/enums/error-code.json @@ -164,7 +164,7 @@ "TERMS_REJECTED": "Buyer-proposed measurement_terms were rejected by the seller. The error details SHOULD identify which specific term was rejected and the seller's acceptable range or supported vendors. Recovery: correctable (adjust the proposed terms and retry, or omit measurement_terms to accept the product's defaults).", "BIDDING_PLACEMENT_CONFLICT": "The authored media-buy/package bidding scopes cannot be represented by the provider's native campaign, package, or shared-strategy placement rules. Sellers MUST detect this before any provider mutation and SHOULD identify the conflicting scopes and provider constraint in error.details. Recovery: correctable (remove or align package overrides, move the policy to the supported scope, or choose a compatible budget mode/product combination).", "AMBIGUOUS_BIDDING_POLICY": "The same effective package combines the canonical bidding block with legacy bid_price or monetary optimization-goal target fields, so two bidding interpretations are present. Sellers MUST reject rather than choosing a winner. Recovery: correctable (emit only the 3.2 bidding block or only one legacy representation).", - "REQUOTE_REQUIRED": "An update_media_buy request changes the parameter envelope (budget, flight dates, volume, targeting) the original quote was priced against. The pricing_option remains locked; the seller is declining the requested shape at that price. Distinct from TERMS_REJECTED (measurement) and POLICY_VIOLATION (content). Sellers SHOULD populate error.details.envelope_field with the field path(s) that breached the envelope (e.g., 'packages[0].budget', 'end_time') so the buyer's agent can decide whether to adjust the update, rediscover products, add packages where supported, or create a separate media buy. AdCP 3.1 does not define an amendment-quote artifact that can be attached to update_media_buy.", + "REQUOTE_REQUIRED": "A create_media_buy package overlay or update_media_buy request changes the parameter envelope (budget, flight dates, volume, targeting) the configured product or original quote was priced against. The pricing_option remains locked; the seller is declining the requested shape at that price. On create, this includes values supplied later through declared Product.overlay_support when they fall outside the priced or guaranteed envelope; the buyer re-runs get_products with those exact values in targeting_overlay. Distinct from TARGETING_TOO_NARROW (zero executable inventory), TERMS_REJECTED (measurement), and POLICY_VIOLATION (content). Sellers SHOULD populate error.details.envelope_field with the field path(s) that breached the envelope (e.g., 'packages[0].targeting_overlay.geo_places', 'packages[0].budget', 'end_time') so the buyer's agent can decide whether to adjust the request, rediscover products, add packages where supported, or create a separate media buy. AdCP 3.1 does not define an amendment-quote artifact that can be attached to update_media_buy.", "VERSION_UNSUPPORTED": "The declared adcp_version (release-precision) or adcp_major_version (deprecated) is not supported by this seller. The error details SHOULD follow `error-details/version-unsupported.json` — `supported_versions` (release-precision strings) is authoritative for retry; `supported_majors` is deprecated. Recovery: correctable (re-pin to a release in supported_versions and retry; or call get_adcp_capabilities without a version pin to discover supported_versions).", "CAMPAIGN_SUSPENDED": "Campaign governance has been suspended pending human review; the governance agent MUST reject `check_governance` and `report_plan_outcome` calls on the affected plan until the escalation is resolved. Distinct from `ACCOUNT_SUSPENDED` (account-wide) — this is scoped to a single plan/campaign. Recovery: transient (wait for the escalation to resolve; contact the plan operator if the suspension persists).", "GOVERNANCE_UNAVAILABLE": "A registered governance agent is unreachable. Sellers MUST place this code in `errors[]` + `adcp_error` (never a structured rejection arm) and flip transport-level failure markers (HTTP 5xx, MCP `isError: true`, A2A `failed`). Distinct from `GOVERNANCE_DENIED` (agent reachable and explicitly denied — see that code's wire-placement guidance). Recovery: transient (retry with backoff; if the agent remains unreachable, the buyer MUST contact the plan's governance operator — the seller MUST NOT proceed with the media buy without a valid decision).\n\nWire placement (full guidance). Governance unavailability is a system error — the governance call FAILED (timeout, network, config error) and the seller could not get a verdict at all. Always populate both layers per the two-layer model in `error-handling.mdx#envelope-vs-payload-errors-the-two-layer-model`. Do NOT use a structured rejection arm for unavailability even when the task offers one — the buyer's recovery semantics differ (retry-with-backoff for unavailability vs. restructure-or-escalate for denial), and conflating them masks the system-error signal.", @@ -203,7 +203,6 @@ "ITEM_VALIDATION_FAILED": "One or more catalog items failed schema validation during sync_catalogs. Recovery: correctable (check item_issues for per-item rejection reasons and fix the offending items).", "CATALOG_LIMIT_EXCEEDED": "The account has reached its maximum catalog count. Recovery: correctable (remove unused catalogs, or contact the seller to raise the limit).", "INVALID_PRICING_OPTION": "A `pricing_option_id` referenced in the request does not exist on the target account or product. Returned per-record in `report_usage` responses and at the request level for `create_media_buy` when the submitted pricing option cannot be resolved. `error.field` SHOULD point at the offending record path (e.g., `usage[1].pricing_option_id` or `packages[0].pricing_option_id`). Distinct from `PRODUCT_NOT_FOUND` (the product itself is unknown) by being narrowly about a pricing option within a known product or account. Recovery: correctable (verify `pricing_option_id` against the product's `pricing_options` from `get_products` or the vendor's discovery response, then resubmit with a valid ID).", - "INVALID_PRICING_OPTION": "A `pricing_option_id` referenced in the request does not exist on the target account or product. Returned per-record in `report_usage` responses and at the request level for `create_media_buy` when the submitted pricing option cannot be resolved. `error.field` SHOULD point at the offending record path (e.g., `usage[1].pricing_option_id` or `packages[0].pricing_option_id`). Distinct from `PRODUCT_NOT_FOUND` (the product itself is unknown) by being narrowly about a pricing option within a known product or account. Recovery: correctable (verify `pricing_option_id` against the product's `pricing_options` from `get_products` or the vendor's discovery response, then resubmit with a valid ID).", "INVALID_USAGE_DATA": "A usage record in `report_usage` has missing or invalid fields — required fields absent, values out of range, or type mismatches. Returned per-record in the `report_usage` response `errors[]` array. `error.field` SHOULD point at the offending field path (e.g., `usage[0].vendor_cost`, `usage[0].currency`). Distinct from `INVALID_REQUEST` (top-level request malformed) by being scoped to individual usage records within an otherwise well-formed request. Recovery: correctable (check required fields for the vendor type — at minimum `vendor_cost`, `currency`, and `account` — fix the offending values, and resubmit).", "TARGETING_TOO_NARROW": "The targeting_overlay constrains the requested product to zero executable inventory — the overlay combination eliminates all eligible supply. Recovery: correctable (relax one or more overlay constraints or select a product whose coverage encompasses the intended targets)." }, @@ -443,7 +442,7 @@ }, "REQUOTE_REQUIRED": { "recovery": "correctable", - "suggestion": "adjust the update to stay within the current quote envelope, rediscover products/terms, add packages when available, or create a separate media buy; 3.1 does not define an amendment-quote artifact for update_media_buy" + "suggestion": "for a create-time package overlay, rediscover with the exact targeting; for an update, stay within the current quote envelope, rediscover products/terms, add packages when available, or create a separate media buy" }, "VERSION_UNSUPPORTED": { "recovery": "correctable", diff --git a/static/schemas/source/enums/geo-targeting-level.json b/static/schemas/source/enums/geo-targeting-level.json deleted file mode 100644 index 9f58ae1e1f..0000000000 --- a/static/schemas/source/enums/geo-targeting-level.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/enums/geo-targeting-level.json", - "title": "Geographic Targeting Level", - "description": "Geographic levels available for product discovery and execution targeting. Reporting uses the narrower geo-level vocabulary until place reporting is standardized.", - "type": "string", - "enum": ["country", "region", "metro", "postal_area", "place"] -} diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index b7bb616a0a..14ae9761e4 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -1055,10 +1055,6 @@ "$ref": "/schemas/enums/geo-level.json", "description": "Geographic targeting granularity levels (country, region, metro, postal_area)" }, - "geo-targeting-level": { - "$ref": "/schemas/enums/geo-targeting-level.json", - "description": "Geographic levels available for discovery and targeting, including place" - }, "metro-system": { "$ref": "/schemas/enums/metro-system.json", "description": "Metro area classification systems for geographic targeting (nielsen_dma, uk_itl1, uk_itl2, eurostat_nuts2)" diff --git a/tests/lint-storyboard-context-output-paths.test.cjs b/tests/lint-storyboard-context-output-paths.test.cjs index c61a19f51f..f554034aa5 100644 --- a/tests/lint-storyboard-context-output-paths.test.cjs +++ b/tests/lint-storyboard-context-output-paths.test.cjs @@ -187,6 +187,36 @@ test('schema-valued additionalProperties resolves typed dynamic map keys', () => ); }); +test('pathResolves follows local definitions through typed dynamic maps', () => { + const schema = { + definitions: { + systemSupport: { + type: 'object', + properties: { + countries: { + type: 'object', + additionalProperties: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + }, + }, + properties: { + systems: { + type: 'object', + additionalProperties: { $ref: '#/definitions/systemSupport' }, + }, + }, + }; + + assert.equal( + pathResolves(schema, parsePath('systems.geonames.countries.NL.0')), + true, + ); +}); + test('parsePath accepts both bracket and dotted forms', () => { assert.deepEqual(parsePath('rights[0].rights_id'), ['rights', '0', 'rights_id']); assert.deepEqual(parsePath('rights.0.rights_id'), ['rights', '0', 'rights_id']); diff --git a/tests/lint-storyboard-validations-paths.test.cjs b/tests/lint-storyboard-validations-paths.test.cjs index ae5c20ec82..7f1b455de2 100644 --- a/tests/lint-storyboard-validations-paths.test.cjs +++ b/tests/lint-storyboard-validations-paths.test.cjs @@ -130,6 +130,36 @@ test('field_contains accepts wildcard paths that resolve through array items', ( assert.deepEqual(violations, []); }); +test('pathResolves follows local definitions through typed dynamic maps', () => { + const schema = { + definitions: { + systemSupport: { + type: 'object', + properties: { + countries: { + type: 'object', + additionalProperties: { + type: 'array', + items: { type: 'string' }, + }, + }, + }, + }, + }, + properties: { + systems: { + type: 'object', + additionalProperties: { $ref: '#/definitions/systemSupport' }, + }, + }, + }; + + assert.equal( + pathResolves(schema, parsePath('systems.geonames.countries.NL[*]')), + true, + ); +}); + test('dependency_impairment verify_impaired matches impairment entries without index coupling', () => { const filePath = path.join( REPO_ROOT, diff --git a/tests/schema-validation.test.cjs b/tests/schema-validation.test.cjs index de4537c0bb..a75d662226 100644 --- a/tests/schema-validation.test.cjs +++ b/tests/schema-validation.test.cjs @@ -1517,7 +1517,6 @@ async function runTests() { addFormats(testAjv); const validateTargeting = await testAjv.compileAsync(loadSchema(path.join(SCHEMA_BASE_DIR, 'core/targeting.json'))); - const validateProductFilters = await testAjv.compileAsync(loadSchema(path.join(SCHEMA_BASE_DIR, 'core/product-filters.json'))); const validateCapabilities = await testAjv.compileAsync(loadSchema(path.join(SCHEMA_BASE_DIR, 'protocol/get-adcp-capabilities-response.json'))); const validateForecastGeo = await testAjv.compileAsync(loadSchema(path.join(SCHEMA_BASE_DIR, 'core/forecast-dimension-geo.json'))); const validateResolutionRequest = await testAjv.compileAsync(loadSchema(path.join(SCHEMA_BASE_DIR, 'core/get-geo-place-resolution-request.json'))); @@ -1579,13 +1578,6 @@ async function runTests() { ); if (result !== true) return result; - result = assertInvalid( - validateProductFilters, - { required_geo_targeting: [{ level: 'metro', system: 'nielsen_dma', system_version: '2026-05' }] }, - 'system_version on a non-place capability filter' - ); - if (result !== true) return result; - result = assertInvalid( validateTargeting, { geo_places: [{ country: 'NL', system: 'geonames', values: ['2759794'] }] }, @@ -1593,30 +1585,6 @@ async function runTests() { ); if (result !== true) return result; - result = assertValid( - validateProductFilters, - { - places: [{ country: 'NL', system: 'geonames', system_version: '2026-05', place_type: 'city', values: ['2759794'] }], - required_geo_targeting: [{ level: 'place', country: 'NL', system: 'geonames', system_version: '2026-05', place_type: 'city' }] - }, - 'product discovery with place coverage and capability filters' - ); - if (result !== true) return result; - - result = assertInvalid( - validateProductFilters, - { required_geo_targeting: [{ level: 'place', country: 'NL', system: 'geonames' }] }, - 'place capability filter without place_type' - ); - if (result !== true) return result; - - result = assertInvalid( - validateProductFilters, - { required_geo_targeting: [{ level: 'metro', system: 'nielsen_dma', place_type: 'city' }] }, - 'place_type on a non-place capability filter' - ); - if (result !== true) return result; - const capabilityBase = { status: 'completed', adcp: { major_versions: [3], idempotency: { supported: false } }, diff --git a/tests/targeting-aware-discovery.test.cjs b/tests/targeting-aware-discovery.test.cjs index 8e274223dd..4b28bccde8 100644 --- a/tests/targeting-aware-discovery.test.cjs +++ b/tests/targeting-aware-discovery.test.cjs @@ -301,6 +301,109 @@ test("required overlay requirements exclude seller limit fields", async () => { ); }); +test("named-place targeting supports known-now and declared-later discovery", async () => { + const [validateRequest, validateRequirements, validateSupport] = + await Promise.all([ + compile("/schemas/media-buy/get-products-request.json"), + compile("/schemas/core/targeting-overlay-requirements.json"), + compile("/schemas/core/targeting-overlay-support.json"), + ]); + + const placeRequirement = { + systems: { + geonames: { + countries: { NL: ["city", "municipality"] }, + system_versions: ["2026-05"], + }, + "https://places.meridiangeo.example/catalog": { + countries: { GB: ["city_region"] }, + }, + }, + }; + + assert.equal( + validateRequest({ + buying_mode: "brief", + brief: "Local municipal campaign", + targeting_overlay: { + geo_places: [ + { + country: "NL", + system: "geonames", + system_version: "2026-05", + place_type: "city", + values: ["2759794"], + }, + ], + }, + required_overlay_support: { + geo_places_exclude: placeRequirement, + }, + }), + true, + errors(validateRequest) + ); + + assert.equal( + validateRequirements({ geo_places: placeRequirement }), + true, + errors(validateRequirements) + ); + assert.equal( + validateRequirements({ geo_places: true }), + false, + "future named-place support must bind an identifier system and country/type pairs" + ); + assert.equal( + validateRequirements({ + geo_places: { + systems: { geonames: { countries: { Netherlands: ["city"] } } }, + }, + }), + false, + "country keys use collision-safe ISO alpha-2 identifiers" + ); + + const placeSupport = { + systems: { + geonames: { + countries: { NL: ["city", "municipality"] }, + current_version: "2026-05", + system_versions: ["2026-05", "2025-11"], + }, + "https://places.meridiangeo.example/catalog": { + countries: { GB: ["city_region"] }, + current_version: "2026-Q2", + system_versions: ["2026-Q2"], + }, + }, + max_values_per_package: 50, + max_packages: 20, + }; + assert.equal( + validateSupport({ + geo_places: placeSupport, + geo_places_exclude: placeSupport, + }), + true, + errors(validateSupport) + ); + assert.equal( + validateSupport({ + geo_places: { + systems: { + geonames: { + countries: { NL: ["city"] }, + system_versions: ["2026-05"], + }, + }, + }, + }), + false, + "product support discloses the current catalog version used for omitted package versions" + ); +}); + test("product and package targeting resolutions reject cross-lifecycle fields", async () => { const validateProduct = await compile( "/schemas/core/product-targeting-resolution.json"