feat: Support AdCP v3 structured geo targeting (#1006)#1024
feat: Support AdCP v3 structured geo targeting (#1006)#1024KonstantinMirin wants to merge 18 commits intoprebid:mainfrom
Conversation
|
All contributors have agreed to the IPR Policy. Thank you! |
|
I have read the IPR policy |
…ebid#1006) - FrequencyCap now extends library FrequencyCap, inheriting suppress_minutes: float and adding scope as extension field - Targeting now extends TargetingOverlay with v3 structured geo fields (geo_countries, geo_regions, geo_metros, geo_postal_areas) - Added 4 exclusion extension fields (geo_*_exclude) - Added legacy normalizer to convert flat DB fields to v3 structured - Added backward-compat properties for adapters (to be removed in salesagent-oee/fwm) - Removed geo_city_any_of/none_of (never supported by any adapter) - Updated AdCP contract and schema validation tests for v3 fields
- Remove Union[Targeting, Any] hack on MediaPackage.targeting_overlay, now that Targeting extends TargetingOverlay (salesagent-81n) - Override targeting_overlay on PackageRequest to use Targeting type ensuring request JSON gets full validation and legacy normalizer - Remove unused Union import from schemas.py - Fix storage key mismatch: media_buy_update now writes "targeting_overlay" instead of "targeting" (salesagent-dzr) - Add fallback reader in media_buy_create for pre-fix data stored under the old "targeting" key
- mock_ad_server: use geo_countries/geo_regions/geo_metros in dry-run logging - kevel: read v3 structured fields in _build_targeting, remove geo_city_any_of, add int() cast for FreqCapDuration (suppress_minutes is now float) - triton_digital: read v3 structured fields in _build_targeting - xandr: read geo_countries/geo_regions from top-level dict keys in _create_targeting_profile (was nested under "geo") - Add 16 regression tests for v3 geo field consumption Resolves: salesagent-fwm
… mapping Replace _any_of/_none_of suffix-stripping heuristic with FIELD_TO_DIMENSION dict for v3 structured field names. Both inclusion and exclusion geo fields are now mapped explicitly.
Add validate_geo_systems() method that checks geo_metros and geo_postal_areas (both include and exclude) against the adapter's declared system support. Returns descriptive errors naming the unsupported system and listing supported alternatives. Unknown/custom systems are rejected by default — no adapter currently handles them, so silent pass-through would mask failures.
…dropping Buyer sending geo_city_any_of in targeting_overlay was silently accepted by validate_overlay_targeting (not in FIELD_TO_DIMENSION) and later silently dropped by Targeting._normalize_legacy_fields. Changes: - Add access='removed' level to TargetingCapability - Change geo_city from access='overlay' to access='removed' - Add geo_city_any_of/geo_city_none_of to FIELD_TO_DIMENSION - Update validate_overlay_targeting to reject removed dimensions - Add get_removed_dimensions() helper Closes salesagent-hfz
…y flag
Three fixes in normalize_legacy_geo():
- Convert bare US state codes to ISO 3166-2 ("CA" → "US-CA")
- Drop v2 keys when v3 already present (prevents model_extra leak)
- Set _had_city_targeting flag instead of silently dropping city fields
Closes salesagent-uca
- build_targeting() uses geo_countries, geo_regions, geo_metros (v3)
- _lookup_region_id() accepts ISO 3166-2 format ("US-CA")
- GeoMetro system validation: nielsen_dma required, others rejected
- GeoPostalArea handled: raises until static mapping implemented
- _had_city_targeting flag triggers explicit ValueError
- Exclusion fields (geo_*_exclude) processed into GAM excluded locations
- validate_targeting() updated to use v3 field accessors
- int() cast on FrequencyCap suppress_minutes arithmetic for GAM API
Remove backward-compat properties (geo_country_any_of, etc.) from Targeting class since all adapters now use v3 fields directly. Update 28 files: - Test fixtures, builders, conftest to use geo_countries/geo_regions/geo_metros - TypeScript types updated with structured metro/postal types - Database seeds, HTML templates, docs, examples updated - Legacy normalizer preserved for DB backward compatibility Closes prebid#1006 (salesagent-1hn)
Pydantic's extra='allow' on Targeting silently accepts bogus fields — they land in model_extra but validate_overlay_targeting() skips unmapped keys. Add validate_unknown_targeting_fields() that checks model_extra and reports unrecognized fields at the media_buy_create validation boundary. Known model fields (including managed-only like axe_include_segment) and v2 field names consumed by the normalizer are not affected — they are real model attributes, not model_extra entries.
…suite
Targeting.model_dump() and model_dump_internal() produced MetroAreaSystem
and PostalCodeSystem enum objects instead of strings, causing TypeError
when psycopg2 called json.dumps() for JSONB storage. Any buyer sending
geo_metros or geo_postal_areas targeting would crash the create/update
media buy flow at session.commit().
Fix: kwargs.setdefault("mode", "json") in both methods — one line each.
New test files (41 tests):
- test_v3_geo_targeting.py: type construction, model_dump JSON safety,
FrequencyCap inheritance, exclusion field serialization
- test_v3_targeting_roundtrip.py: construct/dump/reconstruct identity,
DB storage simulation (json.dumps/loads), legacy normalizer roundtrip,
FrequencyCap scope roundtrip
Reverts formatting-only changes (else→elif flattening, f-string merging), CLAUDE.md refactor, Makefile, ruff config additions, and .claude tooling files that were unrelated to prebid#1006. Keeps only the int() cast fix in orders.py (required because FrequencyCap.suppress_minutes is now float from library inheritance).
3466d00 to
a0692a3
Compare
Implement AdCP SHOULD requirement from adcp PR prebid#1010: reject requests where the same value appears in both inclusion and exclusion targeting at the same geo level (countries, regions, metros, postal_areas). - Add validate_geo_overlap() to targeting_capabilities.py - Wire into _create_media_buy_impl validation alongside overlay checks - Handle both simple (RootModel[str]) and structured (system+values) types - 19 new unit tests covering all geo levels and edge cases
| # v3 structured fields (geo_countries, geo_regions, etc.) map directly without | ||
| # suffix-stripping. Both inclusion and exclusion variants map to the same | ||
| # capability so exclusion fields are validated alongside inclusion fields. | ||
| FIELD_TO_DIMENSION: dict[str, str] = { |
There was a problem hiding this comment.
not all of these are in the adcp list - we could conceptually add them
There was a problem hiding this comment.
Good catch. Checked against adcp.types.TargetingOverlay — it defines 8 fields:
geo_countries,geo_regions,geo_metros,geo_postal_areasfrequency_cap,property_listaxe_include_segment,axe_exclude_segment
Of the 26 entries in FIELD_TO_DIMENSION, only 5 map to actual AdCP fields (the 4 geo fields + frequency_cap). The geo exclusion fields (geo_countries_exclude, etc.) are our PR #1006 extensions. The rest — device_type_any_of, os_any_of, browser_any_of, media_type_any_of, audiences_any_of, content_cat_any_of, custom — are seller extensions carried forward from the original implementation, which had Targeting as a standalone BaseModel that never inherited from the AdCP library.
These seller extensions are actively consumed by adapters (GAM, Kevel, Triton, Xandr all read device type, media type, etc.) — they're standard ad-server dimensions that AdCP hasn't adopted yet. We've annotated both TARGETING_CAPABILITIES and FIELD_TO_DIMENSION to clearly distinguish AdCP-defined fields from seller extensions, with a note that the extensions are candidates for upstream AdCP inclusion.
…Field - Use Field(default=False, exclude=True) instead of manually managed underscore-prefixed attribute for the city targeting signal - Fix CreateMediaBuyRequest.packages to use our PackageRequest (with Targeting) instead of library PackageRequest (with TargetingOverlay) - Add code annotations for Brian's review on targeting_capabilities.py - Add validate_geo_systems field-tuple sync test
Tests that the full targeting validation chain correctly rejects geo_countries with both inclusion and exclusion values for the same country code.
The library's PackageRequest parses targeting_overlay as TargetingOverlay, but our MediaPackage expects Targeting (our extended type with the legacy normalizer). Override packages to use our PackageRequest which properly references Targeting instead of TargetingOverlay.
Add resolve_enum_value() to validation_helpers.py — extracts .value from enum members or returns strings as-is. Referenced by base.py and targeting_capabilities.py but never defined, causing 30 unit test failures and 2 mypy errors. Also add type: ignore[assignment] for intentional PackageRequest subclass overrides in schemas.py and media_buy_create.py.
Upgrade cryptography 46.0.3 -> 46.0.5 (GHSA-r6ph-v2qm-q3c2) and pillow 12.0.0 -> 12.1.1 (GHSA-cfh3-3jmp-rvhc). Add diskcache GHSA-w8v5-vhqr-4h9v to ignore list (no fix available, transitive from fastmcp, tracked upstream).
Summary
Implements AdCP v3 structured geo targeting syntax. What started as a schema migration revealed high coupling between targeting types, validation layers, adapters, and serialization paths — each layer had assumptions about field names and data shapes that required coordinated changes.
Why this is larger than "just add structured fields"
The original
Targetingclass was a standalone Pydantic model with flat string lists (geo_country_any_of: list[str]). The AdCP v3 spec requires inheriting from the library'sTargetingOverlaywith typed structured objects (geo_metros: list[GeoMetro]whereGeoMetrohas asystemenum andvalueslist). Making this switch exposed problems at every layer:Schema layer —
Targetingcouldn't just add new fields; it had to inherit fromTargetingOverlayto maintainisinstancechecks and protocol compliance. This changed the class hierarchy and required overridingfrequency_capto use our extended type withscope.Serialization layer —
GeoMetro.systemis aMetroAreaSystemenum.model_dump()(defaultmode='python') keeps it as an enum object. When psycopg2 callsjson.dumps()for JSONB storage, it raisesTypeError: Object of type MetroAreaSystem is not JSON serializable. This means any buyer sending metro or postal targeting would crash the media buy create/update flow atsession.commit(). Fixed by defaultingTargeting.model_dump()tomode='json'.Validation layer — The overlay validator used a suffix-stripping heuristic (
geo_country_any_of→ strip_any_of→geo_country) to map field names to targeting dimensions. V3 field names (geo_countries,geo_metros) don't follow this convention. The heuristic silently passed unknown fields through without error. Replaced with an explicitFIELD_TO_DIMENSIONmapping table, and addedmodel_extrainspection to catch truly unknown fields (typos, bogus names).Adapter layer — All 5 adapters (GAM, Kevel, Triton, Xandr, Mock) read targeting fields by the old flat names. Each needed updating to read from the new structured types. GAM additionally needed to extract
systemandvaluesfromGeoMetro/GeoPostalAreaobjects and validate the system matches its capabilities.Normalizer — Existing database records store flat field names. A legacy normalizer converts v2 → v3 at parse time so old data reconstructs correctly. This required hardening for edge cases: bare region codes without country prefix, both v2+v3 fields present simultaneously, and the removed
geo_citydimension.Codebase-wide rename — ~60 test files and fixtures referenced the old field names (
geo_country_any_of,geo_metro_any_of, etc.). These needed updating to v3 names to match the new schema.Bugs found and fixed during implementation
model_dump()produces non-JSON-serializable enum objectssession.commit()model_extrafields never inspectedgeo_contries) silently ignoredgeo_city_any_ofaccepted but never reached adapter"CA") not normalized"US-CA"Changes
Core schema (
src/core/schemas.py)Targetingnow extendsTargetingOverlayfromadcplibraryFrequencyCapextendsLibraryFrequencyCapwithscopefieldgeo_countries_exclude,geo_metros_exclude, etc.)model_dump()/model_dump_internal()default tomode='json'for DB safetyAdapters (
src/adapters/)system/valuesfromGeoMetro/GeoPostalArea, validates system against adapter capabilities, handles exclusion variantsvalidate_geo_systems()— adapters declare which metro/postal systems they support, requests using unsupported systems get clear errorsValidation (
src/services/targeting_capabilities.py)FIELD_TO_DIMENSIONmappingvalidate_unknown_targeting_fields()using Pydanticmodel_extravalidate_geo_overlap()— rejects same-value overlap between inclusion and exclusion fields at the same geo level (AdCP SHOULD requirement from adcp PR fix: preserve format dimensions during media buy approval #1010)Test coverage (12 new test files, ~220 new tests)
test_v3_geo_targeting.py— Type construction, model_dump JSON safety, FrequencyCap inheritancetest_v3_targeting_roundtrip.py— construct→dump→reconstruct identity, DB storage simulationtest_gam_targeting_v3.py— GAM adapter structured geo handlingtest_validate_geo_systems.py— Adapter system validationtest_targeting_normalizer.py— Legacy v2→v3 conversiontest_overlay_validation_v3.py— Explicit field mapping validationtest_unknown_targeting_fields.py— model_extra unknown field detectiontest_city_targeting_rejected.py— Removed dimension rejectiontest_targeting_storage_key.py— DB round-trip key consistencytest_adapter_v3_geo_fields.py— Non-GAM adapter v3 consumptiontest_geo_overlap_validation.py— Geo inclusion/exclusion same-value overlap rejectiontest_adcp_contract.py— Updated for new type hierarchyTest plan
uv run pytest tests/unit/ -x)isinstance(Targeting(...), TargetingOverlay)verifiedjson.dumps(Targeting(geo_metros=[...]).model_dump())succeedseurostat_nuts2, acceptsnielsen_dmaCloses #1006
Update: Post-review changes (3 new commits)
1.
had_city_targetingPydantic Field cleanupReplaced the
_had_city_targetingtransient attribute (manually managed via__dict__and hardcoded exclusions inmodel_dump()) with a proper PydanticField(default=False, exclude=True). Pydantic forbids underscore-prefixed field names, so renamed tohad_city_targeting. Theexclude=Trueannotation handles serialization exclusion automatically — no more hardcoded string lists inmodel_dump()andmodel_dump_internal().2.
CreateMediaBuyRequest.packagestype overrideWhen a buyer sends
targeting_overlayinside a package, the request JSON is first parsed byCreateMediaBuyRequest. The parent class (LibraryCreateMediaBuyRequest) definespackages: list[LibraryPackageRequest], and the library'sPackageRequesttypestargeting_overlayasTargetingOverlay. This means Pydantic constructs aTargetingOverlayinstance — which bypasses our legacy normalizer and doesn't carry our extension fields.Later, when the code constructs
MediaPackage(targeting_overlay=...), it fails becauseMediaPackageexpectsTargeting(our extended type), not the library'sTargetingOverlay.Fix: override
packagesonCreateMediaBuyRequestto reference ourPackageRequest(which already overridestargeting_overlay: Targeting). This follows the same inheritance pattern used throughout the codebase —CreateMediaBuyRequesthad a gap where it extended the library but forgot to overridepackagesto use ourPackageRequest.3. Geo overlap integration test
Added
tests/integration/test_targeting_validation_chain.py— tests the full validation chain with a real database, confirming that geo overlap rejection works end-to-end (not just in unit isolation).Review response
Addressed @bokelley's comment on
targeting_capabilities.py— annotated bothTARGETING_CAPABILITIESandFIELD_TO_DIMENSIONto distinguish AdCP-defined fields from seller extensions, with a note that the seller extensions are candidates for upstream AdCP inclusion.