Skip to content

feat: Support AdCP v3 structured geo targeting (#1006)#1024

Open
KonstantinMirin wants to merge 18 commits intoprebid:mainfrom
KonstantinMirin:feature/structured-geo-support-1006
Open

feat: Support AdCP v3 structured geo targeting (#1006)#1024
KonstantinMirin wants to merge 18 commits intoprebid:mainfrom
KonstantinMirin:feature/structured-geo-support-1006

Conversation

@KonstantinMirin
Copy link

@KonstantinMirin KonstantinMirin commented Feb 11, 2026

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 Targeting class was a standalone Pydantic model with flat string lists (geo_country_any_of: list[str]). The AdCP v3 spec requires inheriting from the library's TargetingOverlay with typed structured objects (geo_metros: list[GeoMetro] where GeoMetro has a system enum and values list). Making this switch exposed problems at every layer:

  1. Schema layerTargeting couldn't just add new fields; it had to inherit from TargetingOverlay to maintain isinstance checks and protocol compliance. This changed the class hierarchy and required overriding frequency_cap to use our extended type with scope.

  2. Serialization layerGeoMetro.system is a MetroAreaSystem enum. model_dump() (default mode='python') keeps it as an enum object. When psycopg2 calls json.dumps() for JSONB storage, it raises TypeError: 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 at session.commit(). Fixed by defaulting Targeting.model_dump() to mode='json'.

  3. Validation layer — The overlay validator used a suffix-stripping heuristic (geo_country_any_of → strip _any_ofgeo_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 explicit FIELD_TO_DIMENSION mapping table, and added model_extra inspection to catch truly unknown fields (typos, bogus names).

  4. 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 system and values from GeoMetro/GeoPostalArea objects and validate the system matches its capabilities.

  5. 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_city dimension.

  6. 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

Bug Severity Impact
model_dump() produces non-JSON-serializable enum objects P1 Metro/postal targeting crashes DB writes at session.commit()
Suffix-stripping heuristic fails for v3 field names P2 Unknown/unmapped targeting fields silently accepted
model_extra fields never inspected P2 Buyer typos (geo_contries) silently ignored
City targeting silently dropped P2 geo_city_any_of accepted but never reached adapter
Bare region codes ("CA") not normalized P2 Region targeting fails unless buyer knows to send "US-CA"
Storage key mismatch after schema change P2 Targeting data lost on DB round-trip

Changes

Core schema (src/core/schemas.py)

  • Targeting now extends TargetingOverlay from adcp library
  • FrequencyCap extends LibraryFrequencyCap with scope field
  • Added geo exclusion fields (geo_countries_exclude, geo_metros_exclude, etc.)
  • Legacy normalizer converts flat v2 field names to v3 structured format
  • model_dump() / model_dump_internal() default to mode='json' for DB safety

Adapters (src/adapters/)

  • GAM: Full v3 structured geo handling — extracts system/values from GeoMetro/GeoPostalArea, validates system against adapter capabilities, handles exclusion variants
  • Kevel/Triton/Xandr/Mock: Updated to read from v3 field names
  • Base adapter: Added validate_geo_systems() — adapters declare which metro/postal systems they support, requests using unsupported systems get clear errors

Validation (src/services/targeting_capabilities.py)

  • Replaced suffix-stripping heuristic with explicit FIELD_TO_DIMENSION mapping
  • Added validate_unknown_targeting_fields() using Pydantic model_extra
  • Both inclusion and exclusion field variants mapped to the same capability dimension
  • Added validate_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 inheritance
  • test_v3_targeting_roundtrip.py — construct→dump→reconstruct identity, DB storage simulation
  • test_gam_targeting_v3.py — GAM adapter structured geo handling
  • test_validate_geo_systems.py — Adapter system validation
  • test_targeting_normalizer.py — Legacy v2→v3 conversion
  • test_overlay_validation_v3.py — Explicit field mapping validation
  • test_unknown_targeting_fields.py — model_extra unknown field detection
  • test_city_targeting_rejected.py — Removed dimension rejection
  • test_targeting_storage_key.py — DB round-trip key consistency
  • test_adapter_v3_geo_fields.py — Non-GAM adapter v3 consumption
  • test_geo_overlap_validation.py — Geo inclusion/exclusion same-value overlap rejection
  • test_adcp_contract.py — Updated for new type hierarchy

Test plan

  • All 1918 unit tests pass (uv run pytest tests/unit/ -x)
  • AdCP contract compliance: isinstance(Targeting(...), TargetingOverlay) verified
  • DB serialization safety: json.dumps(Targeting(geo_metros=[...]).model_dump()) succeeds
  • Legacy backward compatibility: v2 field names normalize to v3 at parse time
  • Adapter system validation: GAM rejects eurostat_nuts2, accepts nielsen_dma
  • Geo overlap validation: same value in inclusion + exclusion at same level → rejected

Closes #1006


Update: Post-review changes (3 new commits)

1. had_city_targeting Pydantic Field cleanup

Replaced the _had_city_targeting transient attribute (manually managed via __dict__ and hardcoded exclusions in model_dump()) with a proper Pydantic Field(default=False, exclude=True). Pydantic forbids underscore-prefixed field names, so renamed to had_city_targeting. The exclude=True annotation handles serialization exclusion automatically — no more hardcoded string lists in model_dump() and model_dump_internal().

2. CreateMediaBuyRequest.packages type override

When a buyer sends targeting_overlay inside a package, the request JSON is first parsed by CreateMediaBuyRequest. The parent class (LibraryCreateMediaBuyRequest) defines packages: list[LibraryPackageRequest], and the library's PackageRequest types targeting_overlay as TargetingOverlay. This means Pydantic constructs a TargetingOverlay instance — which bypasses our legacy normalizer and doesn't carry our extension fields.

Later, when the code constructs MediaPackage(targeting_overlay=...), it fails because MediaPackage expects Targeting (our extended type), not the library's TargetingOverlay.

Fix: override packages on CreateMediaBuyRequest to reference our PackageRequest (which already overrides targeting_overlay: Targeting). This follows the same inheritance pattern used throughout the codebase — CreateMediaBuyRequest had a gap where it extended the library but forgot to override packages to use our PackageRequest.

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 both TARGETING_CAPABILITIES and FIELD_TO_DIMENSION to distinguish AdCP-defined fields from seller extensions, with a note that the seller extensions are candidates for upstream AdCP inclusion.

@github-actions
Copy link
Contributor

github-actions bot commented Feb 11, 2026

All contributors have agreed to the IPR Policy. Thank you!
Posted by the CLA Assistant Lite bot.

@KonstantinMirin
Copy link
Author

I have read the IPR policy

github-actions bot added a commit that referenced this pull request Feb 11, 2026
…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).
@KonstantinMirin KonstantinMirin force-pushed the feature/structured-geo-support-1006 branch from 3466d00 to a0692a3 Compare February 11, 2026 11:15
@KonstantinMirin KonstantinMirin marked this pull request as draft February 11, 2026 11:34
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
@KonstantinMirin KonstantinMirin marked this pull request as ready for review February 11, 2026 12:51
@KonstantinMirin KonstantinMirin marked this pull request as draft February 11, 2026 13:59
@ChrisHuie ChrisHuie self-requested a review February 11, 2026 14:37
# 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] = {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not all of these are in the adcp list - we could conceptually add them

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Checked against adcp.types.TargetingOverlay — it defines 8 fields:

  • geo_countries, geo_regions, geo_metros, geo_postal_areas
  • frequency_cap, property_list
  • axe_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.
@KonstantinMirin KonstantinMirin marked this pull request as ready for review February 12, 2026 01:37
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Support v3 structured geo targeting with inclusion and exclusion

2 participants