From 0d1c0cebd1e5ea23012848efe80806fdba881db1 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 24 May 2026 07:05:09 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(canonical-formats):=20v1=E2=86=94v2=20?= =?UTF-8?q?reverse=20projection=20+=20pixel=5Ftracker=20+=20narrowing=20(#?= =?UTF-8?q?741,=20half=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #741. Lands the v1 → v2 inbound projection, the bidirectional ``pixel_tracker`` contract, the ``FORMAT_DECLARATION_DIVERGENT`` narrowing check, and the upstream reference fixtures + round-trip tests. ## v1 → v2 projection (``v1_to_v2.py``) ``project_v1_format_to_declaration`` walks the 4-step resolution order from ``registries/v1-canonical-mapping.json``: 1. Seller-asserted ``canonical:`` annotation on the v1 file → use declared kind; thread ``asset_source`` and ``slots_override[]`` into the emitted declaration's ``params``. 2. Registry ``format_id_glob`` lookup. 3. Registry ``structural`` match → emit ``FORMAT_DECLARATION_V1_AMBIGUOUS`` (family-level guess) but still produce a usable declaration. 4. Fail closed → emit ``FORMAT_PROJECTION_FAILED``, no declaration. Emitted declarations always carry ``v1_format_ref`` pointing back at the source v1 format_id so the half-1 v2 → v1 path round-trips. ``project_v1_catalog_to_v2`` is the bulk helper. ## Bidirectional ``pixel_tracker`` (``pixel_tracker.py``) Implements the normative downgrade/upgrade table from ``core/assets/pixel-tracker-asset.json``: * ``downgrade_pixel_tracker`` (v2 → v1) collapses 7 event × 2 method combos onto the v1 ``{asset_type: url, url_type: tracker_pixel}`` shape keyed on ``impression_tracker`` / ``viewability_tracker`` / ``click_tracker`` slot ids. Lossy on viewability variants, custom events, and any JS method — those emit ``PIXEL_TRACKER_LOSSY_DOWNGRADE``. * ``upgrade_v1_tracker`` (v1 → v2) inverts via asset_id convention. ALWAYS emits ``PIXEL_TRACKER_UPGRADE_INFERRED``. Batch helpers deduplicate advisories. ## ``FORMAT_DECLARATION_DIVERGENT`` narrowing (``narrowing.py``) ``check_narrows(v2_params, v1_requirements)`` returns divergence records covering four kinds: exceeds-max, below-min, not-subset (enum), not-equal (exact scalar). ``narrowing_advisory`` wraps in the wire-correct ``FORMAT_DECLARATION_DIVERGENT`` ``Error``. ## Vendored fixtures * 14 v2 ``Product`` fixtures from ``adcontextprotocol/adcp@main/static/examples/products/canonical/``. * 50-entry v1 reference catalog from ``adcontextprotocol/adcp@main/server/src/creative-agent/reference-formats.json``. ## Round-trip tests * Every v2 product fixture → ``project_product_to_v1`` emits format_ids that round-trip back to source declarations via ``find_declaration_by_v1_format_id``. * Every v2 product fixture → ``format_options[]`` parseable as the hand-rolled ``ProductFormatDeclaration``. * v1 reference catalog → 50 declarations, zero advisories. * v1 catalog covers 8 distinct ``CanonicalFormatKind`` values (pinned). ## Tests 5190 passed locally (5117 from half 1 + 73 new across 4 new files). Closes #741. --- MIGRATION_v5_to_v6.md | 109 +- src/adcp/canonical_formats/__init__.py | 31 + src/adcp/canonical_formats/narrowing.py | 268 + src/adcp/canonical_formats/pixel_tracker.py | 392 ++ src/adcp/canonical_formats/v1_to_v2.py | 394 ++ .../canonical/amazon_sponsored_products.json | 67 + .../canonical/chatgpt_brand_mention.json | 77 + .../canonical/gam_3p_display_tag.json | 85 + .../canonical/google_performance_max.json | 93 + tests/fixtures/canonical/meta_carousel.json | 88 + tests/fixtures/canonical/meta_reels_us.json | 85 + .../canonical/nytimes_homepage_html5.json | 70 + .../canonical/nytimes_homepage_mrec.json | 196 + .../nytimes_homepage_takeover_custom.json | 67 + .../taboola_content_recommendation.json | 82 + .../canonical/the_daily_30s_host_read.json | 78 + .../canonical/triton_daast_audio_30s.json | 68 + .../canonical/v1-reference-formats.json | 5245 +++++++++++++++++ .../canonical/veo_generative_video_15s.json | 95 + .../canonical/youtube_vast_preroll.json | 94 + tests/test_canonical_formats_narrowing.py | 160 + tests/test_canonical_formats_pixel_tracker.py | 149 + tests/test_canonical_formats_roundtrip.py | 215 + tests/test_canonical_formats_v1_to_v2.py | 186 + 24 files changed, 8390 insertions(+), 4 deletions(-) create mode 100644 src/adcp/canonical_formats/narrowing.py create mode 100644 src/adcp/canonical_formats/pixel_tracker.py create mode 100644 src/adcp/canonical_formats/v1_to_v2.py create mode 100644 tests/fixtures/canonical/amazon_sponsored_products.json create mode 100644 tests/fixtures/canonical/chatgpt_brand_mention.json create mode 100644 tests/fixtures/canonical/gam_3p_display_tag.json create mode 100644 tests/fixtures/canonical/google_performance_max.json create mode 100644 tests/fixtures/canonical/meta_carousel.json create mode 100644 tests/fixtures/canonical/meta_reels_us.json create mode 100644 tests/fixtures/canonical/nytimes_homepage_html5.json create mode 100644 tests/fixtures/canonical/nytimes_homepage_mrec.json create mode 100644 tests/fixtures/canonical/nytimes_homepage_takeover_custom.json create mode 100644 tests/fixtures/canonical/taboola_content_recommendation.json create mode 100644 tests/fixtures/canonical/the_daily_30s_host_read.json create mode 100644 tests/fixtures/canonical/triton_daast_audio_30s.json create mode 100644 tests/fixtures/canonical/v1-reference-formats.json create mode 100644 tests/fixtures/canonical/veo_generative_video_15s.json create mode 100644 tests/fixtures/canonical/youtube_vast_preroll.json create mode 100644 tests/test_canonical_formats_narrowing.py create mode 100644 tests/test_canonical_formats_pixel_tracker.py create mode 100644 tests/test_canonical_formats_roundtrip.py create mode 100644 tests/test_canonical_formats_v1_to_v2.py diff --git a/MIGRATION_v5_to_v6.md b/MIGRATION_v5_to_v6.md index fd1c1a63e..f7f00c290 100644 --- a/MIGRATION_v5_to_v6.md +++ b/MIGRATION_v5_to_v6.md @@ -197,7 +197,108 @@ keys on this and would corrupt under drift. Adopters relying on a particular `sdk_id` for multi-hop dedup should pin to a specific SDK release rather than parsing the string. -**Not yet shipped (later beta increments):** v1 → v2 reverse projection, -`pixel_tracker` bidirectional contract, the 14 reference fixtures and -round-trip tests, `FORMAT_DECLARATION_DIVERGENT` narrowing check between -v2 `params` and the referenced v1 format's `requirements`. +### Canonical-formats part 2 (#741 second half) + +Ships the v1 → v2 reverse projection, the bidirectional `pixel_tracker` +contract, the divergence narrowing check, and the upstream reference +fixtures + round-trip tests. + +**New public-API helpers on `adcp.canonical_formats`:** + +```python +from adcp.canonical_formats import ( + # v1 → v2 inbound projection + project_v1_format_to_declaration, + project_v1_catalog_to_v2, + V1ToV2Projection, + V1CatalogProjection, + # pixel_tracker bidirectional + downgrade_pixel_tracker, + downgrade_pixel_trackers, + upgrade_v1_tracker, + upgrade_v1_trackers, + PixelTrackerDowngrade, + PixelTrackerUpgrade, + PixelTrackerBatchResult, + V1Tracker, + # narrowing check + check_narrows, + narrowing_advisory, +) +``` + +**Recipe — read a v1 catalog and emit v2 declarations:** + +```python +import json +from adcp.canonical_formats import project_v1_catalog_to_v2 + +v1_formats = json.loads(catalog_path.read_text()) +result = project_v1_catalog_to_v2(v1_formats) +for decl in result.declarations: + ... # use the typed ProductFormatDeclaration +response.errors = (response.errors or []) + result.advisories +``` + +Resolution order per `registries/v1-canonical-mapping.json`: +1. v1 `canonical:` annotation set → use seller-declared kind. +2. registry `format_id_glob` match → use registry's canonical + params. +3. registry `structural` match → use registry's canonical, emit + `FORMAT_DECLARATION_V1_AMBIGUOUS` (family-level guess). +4. no match → emit `FORMAT_PROJECTION_FAILED`, no declaration. + +**Recipe — narrowing check before publishing:** + +```python +from adcp.canonical_formats import narrowing_advisory + +advisory = narrowing_advisory( + declaration, + v1_requirements=v1_format.requirements, + v1_format_id=v1_format.format_id.id, + field_path="format_options[0]", +) +if advisory is not None: + response.errors.append(advisory) # FORMAT_DECLARATION_DIVERGENT +``` + +`check_narrows(v2_params, v1_requirements)` returns the raw divergence +list when adopters want to drive their own error shape; the +`narrowing_advisory` helper wraps that in the wire-correct +`FORMAT_DECLARATION_DIVERGENT` `Error`. + +**Recipe — bidirectional `pixel_tracker`:** + +```python +from adcp.canonical_formats import ( + downgrade_pixel_tracker, upgrade_v1_tracker, +) + +# v2 → v1 (talking to a 3.0.x seller): +v1 = downgrade_pixel_tracker(pixel_tracker_asset).v1 +v1_asset = {"asset_type": "url", "url_type": "tracker_pixel", + "asset_id": v1.asset_id, "url": v1.url} + +# v1 → v2 (reading a v1 manifest as a 3.1 buyer): +result = upgrade_v1_tracker(asset_id="impression_tracker", url="...") +typed_pixel = result.pixel_tracker +# result.advisory is ALWAYS present — PIXEL_TRACKER_UPGRADE_INFERRED +``` + +Lossy combinations are listed in the +`adcp.canonical_formats.pixel_tracker` module docstring. + +**Vendored reference fixtures** under `tests/fixtures/canonical/`: + +* 14 v2 `Product` fixtures from `adcontextprotocol/adcp@main/static/ + examples/products/canonical/` — exercise the v2 → v1 path against + real seller catalogs. +* 1 v1 reference catalog (`v1-reference-formats.json`, 50 entries + with explicit `canonical:` annotations) from + `adcontextprotocol/adcp@main/server/src/creative-agent/ + reference-formats.json` — exercises the v1 → v2 path. + +Round-trip tests in `tests/test_canonical_formats_roundtrip.py` pin +the projection layer against these fixtures so an upstream-contract +drift (e.g., a dropped `canonical:` annotation, a renamed slot) +surfaces immediately in CI. diff --git a/src/adcp/canonical_formats/__init__.py b/src/adcp/canonical_formats/__init__.py index 3536f69bd..98af8ea18 100644 --- a/src/adcp/canonical_formats/__init__.py +++ b/src/adcp/canonical_formats/__init__.py @@ -54,6 +54,17 @@ find_declaration_by_v1_format_id, validate_format_kind_in_options, ) +from adcp.canonical_formats.narrowing import check_narrows, narrowing_advisory +from adcp.canonical_formats.pixel_tracker import ( + PixelTrackerBatchResult, + PixelTrackerDowngrade, + PixelTrackerUpgrade, + V1Tracker, + downgrade_pixel_tracker, + downgrade_pixel_trackers, + upgrade_v1_tracker, + upgrade_v1_trackers, +) from adcp.canonical_formats.projection import ( V1_TRANSLATABLE, V2ToV1Projection, @@ -66,21 +77,41 @@ load_default_registry, structural_match, ) +from adcp.canonical_formats.v1_to_v2 import ( + V1CatalogProjection, + V1ToV2Projection, + project_v1_catalog_to_v2, + project_v1_format_to_declaration, +) __all__ = [ "FormatKindNotInClosedSetError", + "PixelTrackerBatchResult", + "PixelTrackerDowngrade", + "PixelTrackerUpgrade", "RegistryLoadError", "SDK_ID", "SdkAdvisory", + "V1CatalogProjection", + "V1ToV2Projection", + "V1Tracker", "V1_TRANSLATABLE", "V2ToV1Projection", + "check_narrows", + "downgrade_pixel_tracker", + "downgrade_pixel_trackers", "find_declaration_by_kind", "find_declaration_by_v1_format_id", "glob_match", "load_default_registry", "make_sdk_advisory", + "narrowing_advisory", "project_declaration_to_v1", "project_product_to_v1", + "project_v1_catalog_to_v2", + "project_v1_format_to_declaration", "structural_match", + "upgrade_v1_tracker", + "upgrade_v1_trackers", "validate_format_kind_in_options", ] diff --git a/src/adcp/canonical_formats/narrowing.py b/src/adcp/canonical_formats/narrowing.py new file mode 100644 index 000000000..5b73a917e --- /dev/null +++ b/src/adcp/canonical_formats/narrowing.py @@ -0,0 +1,268 @@ +"""``FORMAT_DECLARATION_DIVERGENT`` narrowing check. + +Per ``schemas/cache//core/product-format-declaration.json#v1_format_ref`` +(normative): + + The v2 declaration's `params` MUST narrow (be compatible with) each + referenced v1 format's `requirements` — see the 'Narrows — formal + definition' section in canonical-formats.mdx. SDKs comparing + dual-emitted shapes (`Product.format_ids[]` ⊇ entries from + `v1_format_ref` AND `Product.format_options[]` carrying this + declaration) treat the link as the authoritative pairing and run + the narrowing check between this declaration and EACH referenced v1 + format file's `requirements`. + +"Narrows" is structural — v2 params MUST be a subset of the constraints +v1 declared: + +* **Numeric maxima** (``max_width``, ``max_height``, ``max_file_size_kb``, + ``max_duration_ms``, …): v2's declared value MUST be ≤ v1's maximum. +* **Numeric minima** (``min_width``, ``min_height``, ``min_dpi``, …): + v2's value MUST be ≥ v1's minimum. +* **Enum subsets** (``image_formats``, ``vast_versions``, …): v2's + declared set MUST be a subset of v1's allowed set. +* **Exact-equal** scalars (``aspect_ratio``, ``vast_version`` when both + declare a single value): MUST be equal. + +The check is conservative — when v1 declares a constraint and v2 omits +the matching field, that's NOT a divergence (v2 silently inherits the +v1 cap, which is what "narrows" means). When v2 declares a constraint +v1 doesn't mention, that's also not a divergence (v2 narrows into +unconstrained space). + +The check emits :class:`adcp.types.Error` advisories on +``FORMAT_DECLARATION_DIVERGENT`` when divergence is detected, with +``details`` enumerating each diverging field and the v1/v2 values so +the seller can reconcile. +""" + +from __future__ import annotations + +from typing import Any + +from adcp.canonical_formats.advisory import make_sdk_advisory +from adcp.types import Error, ProductFormatDeclaration + +# Field-name pairs declaring (v2 params field, v1 requirements field). +# When the names already match (the common case) the v2 lookup is the +# same key. The lists below are exhaustive for the canonical format +# parameter sets but tolerant: a v1 requirement without a v2 mirror +# isn't checked, and vice versa. +_MAX_FIELDS: tuple[str, ...] = ( + "max_width", + "max_height", + "max_file_size_kb", + "max_initial_load_kb", + "max_polite_load_kb", + "max_duration_ms", + "max_animation_duration_ms", + "max_dpi", + "max_redirect_depth", + "max_mention_length_chars", + "max_mention_duration_ms", +) +_MIN_FIELDS: tuple[str, ...] = ( + "min_width", + "min_height", + "min_dpi", + "min_duration_ms", +) +_ENUM_SUBSET_FIELDS: tuple[str, ...] = ( + "image_formats", + "formats", + "supported_tag_types", + "supported_catalog_types", + "allowed_card_media_asset_types", + "vast_versions", + "daast_versions", +) +_EXACT_FIELDS: tuple[str, ...] = ( + "aspect_ratio", + "orientation", + "ssl_required", +) + + +def _as_dict(value: Any) -> dict[str, Any]: + """Convert a Pydantic model or dict to a plain dict for field access. + + Tolerates ``None`` (returns empty), Pydantic models (via + ``model_dump``), and anything dict-shaped. Anything else returns + ``{}`` so the check fails open rather than raising on opaque inputs. + """ + if value is None: + return {} + if isinstance(value, dict): + return value + if hasattr(value, "model_dump"): + dumped: dict[str, Any] = value.model_dump(exclude_none=True) + return dumped + return {} + + +def _is_subset(v2: Any, v1: Any) -> bool: + """Return ``True`` iff ``v2`` is a subset of ``v1`` under set semantics.""" + if not isinstance(v2, (list, tuple, set)): + v2 = [v2] + if not isinstance(v1, (list, tuple, set)): + v1 = [v1] + return set(v2).issubset(set(v1)) + + +def check_narrows( + v2_params: dict[str, Any] | Any, + v1_requirements: dict[str, Any] | Any, +) -> list[dict[str, Any]]: + """Compare ``v2_params`` against ``v1_requirements`` and return divergences. + + A divergence is one of: + + * v1 declares ``max_X = N`` and v2 declares an ``X`` (or ``max_X``, + or ``X_max``) strictly greater than ``N``. + * v1 declares ``min_X = N`` and v2 declares an ``X`` (or ``min_X``) + strictly less than ``N``. + * v1 declares an enum-typed allowed set and v2 declares a value + outside that set. + * Both sides declare a scalar with exact-equal semantics and they + disagree. + + Returns an empty list when ``v2_params`` narrows ``v1_requirements`` + (the spec-conformant case). Returns a list of ``{field, v1_value, + v2_value, kind}`` records when divergent — one record per diverging + field, suitable for ``error.details["divergences"]``. + """ + v2 = _as_dict(v2_params) + v1 = _as_dict(v1_requirements) + if not v2 or not v1: + return [] + + divergences: list[dict[str, Any]] = [] + + for field_name in _MAX_FIELDS: + v1_max = v1.get(field_name) + if not isinstance(v1_max, (int, float)): + continue + # v2 may carry the cap directly OR the value being capped (e.g., + # v1 declares ``max_width`` and v2 declares ``width``). + v2_value = v2.get(field_name) + if v2_value is None: + v2_value = v2.get(field_name.removeprefix("max_")) + if v2_value is None: + continue + # The value-being-capped form: v2 ``width`` against v1 ``max_width`` + # is a "v2 value MUST be ≤ v1 cap" check. + if isinstance(v2_value, (int, float)) and v2_value > v1_max: + divergences.append( + { + "field": field_name, + "kind": "exceeds_max", + "v1_max": v1_max, + "v2_value": v2_value, + } + ) + + for field_name in _MIN_FIELDS: + v1_min = v1.get(field_name) + if not isinstance(v1_min, (int, float)): + continue + v2_value = v2.get(field_name) + if v2_value is None: + v2_value = v2.get(field_name.removeprefix("min_")) + if v2_value is None: + continue + if isinstance(v2_value, (int, float)) and v2_value < v1_min: + divergences.append( + { + "field": field_name, + "kind": "below_min", + "v1_min": v1_min, + "v2_value": v2_value, + } + ) + + for field_name in _ENUM_SUBSET_FIELDS: + v1_set = v1.get(field_name) + v2_set = v2.get(field_name) + if v1_set is None or v2_set is None: + continue + if not _is_subset(v2_set, v1_set): + divergences.append( + { + "field": field_name, + "kind": "not_subset", + "v1_allowed": list(v1_set) if not isinstance(v1_set, list) else v1_set, + "v2_declared": list(v2_set) if not isinstance(v2_set, list) else v2_set, + } + ) + + for field_name in _EXACT_FIELDS: + v1_value = v1.get(field_name) + v2_value = v2.get(field_name) + if v1_value is None or v2_value is None: + continue + if v1_value != v2_value: + divergences.append( + { + "field": field_name, + "kind": "not_equal", + "v1_value": v1_value, + "v2_value": v2_value, + } + ) + + return divergences + + +def narrowing_advisory( + declaration: ProductFormatDeclaration, + *, + v1_requirements: dict[str, Any], + v1_format_id: str, + field_path: str = "format_options[]", +) -> Error | None: + """Build the ``FORMAT_DECLARATION_DIVERGENT`` advisory for a single pairing. + + Returns ``None`` when ``declaration.params`` narrows ``v1_requirements`` + (no divergence to report). Returns an :class:`Error` with + ``details.divergences`` listing the failing fields when divergent. + + Args: + declaration: The v2 ``ProductFormatDeclaration`` carrying + ``v1_format_ref[]``. + v1_requirements: The referenced v1 format's ``requirements`` + object (dict or Pydantic model). + v1_format_id: The v1 format identifier (``id`` portion of the + ``FormatId``) — surfaced in advisory details so adopters can + locate the divergent pair when a declaration carries many + refs. + field_path: JSONPath-lite pointer for the advisory's ``field``. + """ + divs = check_narrows(declaration.params, v1_requirements) + if not divs: + return None + return make_sdk_advisory( + code="FORMAT_DECLARATION_DIVERGENT", + message=( + f"v2 declaration (format_kind={declaration.format_kind.value!r}) " + f"params do not narrow v1 format {v1_format_id!r} requirements: " + f"{len(divs)} divergence(s)." + ), + field=field_path, + details={ + "format_kind": declaration.format_kind.value, + "v1_format_id": v1_format_id, + "divergences": divs, + }, + suggestion=( + "Reconcile the v2 params against the referenced v1 format's " + "requirements: lower the v2 cap, expand the v1 allowed set, " + "or drop the v1_format_ref entry if the formats genuinely " + "differ in shape." + ), + ) + + +__all__ = [ + "check_narrows", + "narrowing_advisory", +] diff --git a/src/adcp/canonical_formats/pixel_tracker.py b/src/adcp/canonical_formats/pixel_tracker.py new file mode 100644 index 000000000..5c1736dca --- /dev/null +++ b/src/adcp/canonical_formats/pixel_tracker.py @@ -0,0 +1,392 @@ +"""Bidirectional ``pixel_tracker`` ↔ v1 ``url`` asset projection. + +Implements the normative downgrade/upgrade contract from +``schemas/cache//core/assets/pixel-tracker-asset.json``. The +downgrade table collapses the 7 v2 ``event`` values + 2 ``method`` +values into the v1 ``{asset_type: url, url_type: tracker_pixel}`` shape +keyed on a small set of conventional ``asset_id`` slots. The upgrade +table infers event/method from the v1 ``asset_id`` convention. + +Both directions are lossy-with-advisory: + +* **Downgrade (v2 → v1)** emits ``PIXEL_TRACKER_LOSSY_DOWNGRADE`` on + the response ``errors[]`` when the source pixel carries a viewability + variant, the ``custom`` event, or ``method: js`` — those don't fit + the single v1 slot they collapse onto. ``impression`` + ``click`` on + ``method: img`` are the only no-loss combinations. + +* **Upgrade (v1 → v2)** ALWAYS emits ``PIXEL_TRACKER_UPGRADE_INFERRED`` + because the v1 wire shape carries no explicit event/method — the + inference is on the SDK, and consumers MUST see the advisory so they + know the upgraded shape is a convention call rather than a wire fact. + +Downgrade table (v2 → v1): + +* ``event=impression, method=img`` → ``impression_tracker`` slot, no loss +* ``event=viewable_*`` or ``event=audible_video_complete`` + (any method=img) → ``viewability_tracker`` slot, event-variant LOST +* ``event=click, method=img`` → ``click_tracker`` slot, no loss +* ``event=custom, custom_event_name=X`` → ``impression_tracker`` slot, + custom-event timing LOST +* ``method=js`` (any event) → same slot as method=img, JS execution LOST +* All lossy combinations emit ``PIXEL_TRACKER_LOSSY_DOWNGRADE`` + +Upgrade table (v1 → v2, all emit ``PIXEL_TRACKER_UPGRADE_INFERRED``): + +* ``impression_tracker`` → ``event=impression, method=img`` +* ``viewability_tracker`` → ``event=viewable_mrc_50, method=img`` + (50% is the dominant default in v1 catalogs) +* ``click_tracker`` → ``event=click, method=img`` +* anything else → ``event=custom, custom_event_name=, + method=img`` (fallback preserves the original slot id) +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from adcp.canonical_formats.advisory import make_sdk_advisory +from adcp.types import Error, PixelTrackerAsset, PixelTrackerEvent, PixelTrackerMethod + +# v1 conventional asset_id slots — these are the names a v1 catalog uses +# for renderer-fired tracker URLs. The downgrade table maps v2 events to +# one of these three (or a custom-event fallback that lands on the +# impression slot per the spec table). +_V1_ASSET_ID_IMPRESSION = "impression_tracker" +_V1_ASSET_ID_VIEWABILITY = "viewability_tracker" +_V1_ASSET_ID_CLICK = "click_tracker" + + +# Events that collapse onto the viewability slot on the v1 side. Together +# with the JS-method check and the custom-event check, this set fully +# determines which downgrades emit the LOSSY advisory. +_VIEWABILITY_EVENTS: frozenset[PixelTrackerEvent] = frozenset( + { + PixelTrackerEvent.viewable_mrc_50, + PixelTrackerEvent.viewable_mrc_100, + PixelTrackerEvent.viewable_video_50, + PixelTrackerEvent.audible_video_complete, + } +) + + +@dataclass +class V1Tracker: + """v1 wire-shape projection of a single ``pixel_tracker``. + + Carries the projected ``asset_id`` + ``url`` plus a flag for whether + the source pixel was a JS include (``method=js``). Adopters + assembling a v1 ``assets[]`` array consume this directly: + + .. code-block:: python + + v1 = downgrade_pixel_tracker(pt).v1 + v1_asset = { + "asset_type": "url", + "url_type": "tracker_pixel", + "asset_id": v1.asset_id, + "url": v1.url, + } + + The ``js_method`` flag is exposed so adopters with v1 catalogs that + track a separate JS-tracker slot can still distinguish — the + spec collapses both onto the same ``url_type`` on the wire, but + nothing prevents an adopter from tracking the source method. + """ + + asset_id: str + url: str + js_method: bool = False + + +@dataclass +class PixelTrackerDowngrade: + """Result of downgrading one ``PixelTrackerAsset`` to v1 wire shape.""" + + v1: V1Tracker + advisory: Error | None = None + + +@dataclass +class PixelTrackerUpgrade: + """Result of upgrading one v1 url-tracker asset to v2 ``PixelTrackerAsset``. + + The upgrade ALWAYS carries an advisory per the spec — event/method + are inferred, not declared. + """ + + pixel_tracker: PixelTrackerAsset + advisory: Error + + +# --------------------------------------------------------------------------- +# Downgrade — v2 PixelTrackerAsset → v1 url tracker +# --------------------------------------------------------------------------- + + +def _coerce_event(value: Any) -> PixelTrackerEvent | None: + """Tolerate string or enum input for ``event``; ``None`` defaults to impression.""" + if value is None: + return None + if isinstance(value, PixelTrackerEvent): + return value + try: + return PixelTrackerEvent(value) + except (ValueError, TypeError): + return None + + +def _coerce_method(value: Any) -> PixelTrackerMethod: + if isinstance(value, PixelTrackerMethod): + return value + try: + return PixelTrackerMethod(value) + except (ValueError, TypeError): + return PixelTrackerMethod.img + + +def _downgrade_slot(event: PixelTrackerEvent | None) -> str: + """Map a v2 event to its v1 ``asset_id`` slot.""" + if event is PixelTrackerEvent.click: + return _V1_ASSET_ID_CLICK + if event in _VIEWABILITY_EVENTS: + return _V1_ASSET_ID_VIEWABILITY + # impression (None defaults here), custom, and anything else fall to the + # impression slot per the spec table's `custom` row. + return _V1_ASSET_ID_IMPRESSION + + +def downgrade_pixel_tracker( + pixel: PixelTrackerAsset, + *, + field_path: str | None = None, +) -> PixelTrackerDowngrade: + """Project a single :class:`PixelTrackerAsset` onto v1 wire shape. + + Lossy when the source pixel carries a viewability variant, the + ``custom`` event, or ``method=js`` — those don't fit the single v1 + slot they collapse onto. The advisory carries the source + ``event``, ``method``, and (when present) ``custom_event_name`` + under ``details`` so downstream consumers can reason about what + was lost. + + Args: + pixel: The v2 ``PixelTrackerAsset`` to downgrade. + field_path: Optional JSONPath-lite pointer for the emitted + advisory's ``field`` (e.g., + ``"creative_manifest.assets[2]"``). + """ + event = _coerce_event(pixel.event) + method = _coerce_method(pixel.method) + url = str(pixel.url) + js = method is PixelTrackerMethod.js + custom_name = pixel.custom_event_name if hasattr(pixel, "custom_event_name") else None + + v1 = V1Tracker(asset_id=_downgrade_slot(event), url=url, js_method=js) + + # Determine whether this downgrade is lossy per the spec table. + is_lossy_event = event in _VIEWABILITY_EVENTS or event is PixelTrackerEvent.custom + is_lossy = is_lossy_event or js + + if not is_lossy: + return PixelTrackerDowngrade(v1=v1, advisory=None) + + details: dict[str, Any] = { + "source_event": event.value if event is not None else None, + "source_method": method.value, + "v1_asset_id": v1.asset_id, + } + if custom_name is not None: + details["source_custom_event_name"] = custom_name + + lost_axes: list[str] = [] + if is_lossy_event: + lost_axes.append("event") + if js: + lost_axes.append("method_js_execution") + details["lost"] = lost_axes + + advisory = make_sdk_advisory( + code="PIXEL_TRACKER_LOSSY_DOWNGRADE", + message=( + f"Pixel tracker (event={event.value if event else 'impression'!r}, " + f"method={method.value!r}) downgrades to v1 url-tracker slot " + f"{v1.asset_id!r} with loss on {', '.join(lost_axes)!r}." + ), + field=field_path, + details=details, + suggestion=( + "v1-only buyers will see the URL fire but cannot distinguish " + "the original event variant or execute the JS body. Keep the " + "v2 manifest in flight for 3.1+ buyers." + ), + ) + return PixelTrackerDowngrade(v1=v1, advisory=advisory) + + +# --------------------------------------------------------------------------- +# Upgrade — v1 url tracker → v2 PixelTrackerAsset +# --------------------------------------------------------------------------- + + +# Event/method mapping by v1 ``asset_id`` convention, per the spec +# table. Anything not in this map upgrades to a custom event whose +# name preserves the original v1 ``asset_id``. +_UPGRADE_TABLE: dict[str, tuple[PixelTrackerEvent, PixelTrackerMethod]] = { + _V1_ASSET_ID_IMPRESSION: (PixelTrackerEvent.impression, PixelTrackerMethod.img), + _V1_ASSET_ID_VIEWABILITY: (PixelTrackerEvent.viewable_mrc_50, PixelTrackerMethod.img), + _V1_ASSET_ID_CLICK: (PixelTrackerEvent.click, PixelTrackerMethod.img), +} + + +def upgrade_v1_tracker( + *, + asset_id: str, + url: str, + field_path: str | None = None, +) -> PixelTrackerUpgrade: + """Project a v1 ``{asset_type: url, url_type: tracker_pixel}`` to v2. + + ALWAYS emits ``PIXEL_TRACKER_UPGRADE_INFERRED`` — the v1 wire shape + carries no explicit event/method, so the inferred values are an + SDK convention, not a wire fact. Consumers reading the advisory + can decide whether to trust the convention or treat the pixel as + opaque. + + Args: + asset_id: v1 ``asset_id`` of the tracker slot (e.g., + ``"impression_tracker"``). Drives the inference. + url: The tracker URL. + field_path: Optional JSONPath-lite pointer for the emitted + advisory's ``field``. + """ + inferred = _UPGRADE_TABLE.get(asset_id) + if inferred is None: + # Fallback: preserve the original asset_id as the custom event + # name so a downstream consumer who knows the seller's + # convention can still bucket events correctly. + event, method = PixelTrackerEvent.custom, PixelTrackerMethod.img + custom_name = asset_id + basis = "fallback_custom_event" + else: + event, method = inferred + custom_name = None + basis = "asset_id_convention" + + pt_kwargs: dict[str, Any] = { + "asset_type": "pixel_tracker", + "event": event, + "method": method, + "url": url, + } + if custom_name is not None: + pt_kwargs["custom_event_name"] = custom_name + pixel = PixelTrackerAsset(**pt_kwargs) + + details: dict[str, Any] = { + "source_asset_id": asset_id, + "inferred_event": event.value, + "inferred_method": method.value, + "inference_basis": basis, + } + if custom_name is not None: + details["inferred_custom_event_name"] = custom_name + + advisory = make_sdk_advisory( + code="PIXEL_TRACKER_UPGRADE_INFERRED", + message=( + f"v1 url-tracker asset_id={asset_id!r} upgraded to v2 " + f"pixel_tracker(event={event.value!r}, method={method.value!r}) " + f"by {basis}." + ), + field=field_path, + details=details, + suggestion=( + "Sellers SHOULD migrate v1 catalogs to v2 pixel_tracker so event " + "/ method are declared on the wire rather than inferred from " + "asset_id naming convention." + ), + ) + return PixelTrackerUpgrade(pixel_tracker=pixel, advisory=advisory) + + +@dataclass +class PixelTrackerBatchResult: + """Aggregate downgrade or upgrade across a list of trackers.""" + + items: list[Any] = field(default_factory=list) + advisories: list[Error] = field(default_factory=list) + + +def downgrade_pixel_trackers( + pixels: list[PixelTrackerAsset], + *, + field_path_prefix: str | None = None, +) -> PixelTrackerBatchResult: + """Apply :func:`downgrade_pixel_tracker` across a list. + + Returns the projected v1 trackers + a deduplicated list of + advisories. Advisories are deduplicated on + ``(code, source_event, source_method)`` so a manifest with many + viewability pixels surfaces ONE advisory per kind, not one per + pixel. + """ + out = PixelTrackerBatchResult() + seen: set[tuple[str, str | None, str]] = set() + for i, pt in enumerate(pixels): + prefix = f"{field_path_prefix}[{i}]" if field_path_prefix else None + result = downgrade_pixel_tracker(pt, field_path=prefix) + out.items.append(result.v1) + if result.advisory is not None: + details = result.advisory.details or {} + key = ( + result.advisory.code, + details.get("source_event"), + details.get("source_method", "img"), + ) + if key not in seen: + seen.add(key) + out.advisories.append(result.advisory) + return out + + +def upgrade_v1_trackers( + v1_trackers: list[dict[str, Any]], + *, + field_path_prefix: str | None = None, +) -> PixelTrackerBatchResult: + """Apply :func:`upgrade_v1_tracker` across a list of v1 url-tracker dicts. + + Each input MUST be a dict with ``asset_id`` + ``url`` keys (the + v1 wire shape). Advisories are deduplicated on + ``(code, asset_id)`` so many trackers under the same slot + surface ONE advisory. + """ + out = PixelTrackerBatchResult() + seen: set[tuple[str, str]] = set() + for i, v1 in enumerate(v1_trackers): + prefix = f"{field_path_prefix}[{i}]" if field_path_prefix else None + asset_id = v1.get("asset_id") + url = v1.get("url") + if not isinstance(asset_id, str) or not isinstance(url, str): + continue + result = upgrade_v1_tracker(asset_id=asset_id, url=url, field_path=prefix) + out.items.append(result.pixel_tracker) + key = (result.advisory.code, asset_id) + if key not in seen: + seen.add(key) + out.advisories.append(result.advisory) + return out + + +__all__ = [ + "PixelTrackerBatchResult", + "PixelTrackerDowngrade", + "PixelTrackerUpgrade", + "V1Tracker", + "downgrade_pixel_tracker", + "downgrade_pixel_trackers", + "upgrade_v1_tracker", + "upgrade_v1_trackers", +] diff --git a/src/adcp/canonical_formats/v1_to_v2.py b/src/adcp/canonical_formats/v1_to_v2.py new file mode 100644 index 000000000..e85521c78 --- /dev/null +++ b/src/adcp/canonical_formats/v1_to_v2.py @@ -0,0 +1,394 @@ +"""v1 → v2 canonical-format projection. + +Projects a v1 named-format declaration (``core/format.json`` shape) +into a v2 :class:`ProductFormatDeclaration`. Mirror image of +:mod:`adcp.canonical_formats.projection` (v2 → v1). + +Resolution order per ``registries/v1-canonical-mapping.json`` +"Resolution order (normative)" — items applied in order until a v2 +canonical is identified: + +1. **Seller-asserted on the v1 file.** ``v1_format.canonical`` is a + :class:`CanonicalProjectionReference` carrying ``kind``, + ``asset_source``, and ``slots_override[]``. Highest priority. +2. **Registry glob match.** Look up ``v1_format.format_id.id`` in the + bundled registry's ``format_id_glob`` entries. +3. **Registry structural match.** Match ``v1_format.assets[*].asset_type`` + + VAST/DAAST versions + dimensions against the registry's + ``structural`` entries. Yields a *family-level* identification only. +4. **Family-level structural match** (sub-case of 3) — emit + ``FORMAT_DECLARATION_V1_AMBIGUOUS`` because the registry's + structural patterns are all pure-structural family matches that + can't be inverted back to a specific v1 format_id without seller + assertion. The v2 declaration still gets a ``format_kind`` and + ``params`` skeleton; the advisory notifies the consumer that the + pairing is a family guess. +5. **Fail closed.** No match in steps 1-4 — emit + ``FORMAT_PROJECTION_FAILED`` and emit no v2 declaration. The v1 + format remains valid on the v1 wire; the v2 projection is just + absent for this entry. + +The emitted v2 declaration always carries ``v1_format_ref`` pointing +back at the source v1 format_id, satisfying the v2→v1 reverse path +that half 1 implements. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from adcp.canonical_formats.advisory import _echo_identifier, make_sdk_advisory +from adcp.canonical_formats.registry import ( + glob_match, + load_default_registry, + structural_match, +) +from adcp.types import ( + CanonicalFormatKind, + CanonicalProjectionReference, + Error, + FormatId, + ProductFormatDeclaration, +) + + +@dataclass +class V1ToV2Projection: + """Result of projecting one v1 named format to a v2 declaration. + + Attributes: + declaration: The projected ``ProductFormatDeclaration``, or + ``None`` when projection failed closed (see step 5 above). + When non-``None`` the declaration carries ``v1_format_ref`` + pointing back at the source v1 format. + advisories: SDK-source ``errors[]`` entries the resolution + order emitted. May include + ``FORMAT_DECLARATION_V1_AMBIGUOUS`` (family-only structural + match) or ``FORMAT_PROJECTION_FAILED`` (no match). + """ + + declaration: ProductFormatDeclaration | None = None + advisories: list[Error] = field(default_factory=list) + + +def _v1_format_id(v1_format: Any) -> FormatId | None: + """Extract the v1 ``format_id`` from a v1 format declaration. + + Tolerates a raw dict, a Pydantic ``Format``, or a duck-typed object + carrying ``format_id``. Returns ``None`` when the shape doesn't + expose a parseable id (the caller will then fail-closed). + """ + fid = ( + v1_format.get("format_id") + if isinstance(v1_format, dict) + else getattr(v1_format, "format_id", None) + ) + if fid is None: + return None + if isinstance(fid, FormatId): + return fid + if isinstance(fid, dict): + try: + return FormatId.model_validate(fid) + except Exception: + return None + return None + + +def _v1_canonical_annotation(v1_format: Any) -> CanonicalProjectionReference | None: + """Extract the v1 format's ``canonical`` annotation when present. + + Tolerates the same input shapes as :func:`_v1_format_id`. Returns + ``None`` when the v1 format doesn't carry an explicit annotation + (the caller falls through to the registry). + """ + raw = ( + v1_format.get("canonical") + if isinstance(v1_format, dict) + else getattr(v1_format, "canonical", None) + ) + if raw is None: + return None + if isinstance(raw, CanonicalProjectionReference): + return raw + if isinstance(raw, dict): + try: + return CanonicalProjectionReference.model_validate(raw) + except Exception: + return None + return None + + +def _v1_asset_types(v1_format: Any) -> list[str]: + """Collect the unique ``asset_type`` values from ``v1_format.assets[]``. + + Used by the structural-match step. Tolerates both individual assets + (``asset_type`` on the slot) and repeatable groups (per-slot + ``asset_type`` on each inner slot). + """ + assets = ( + v1_format.get("assets") + if isinstance(v1_format, dict) + else getattr(v1_format, "assets", None) + ) + if not isinstance(assets, list): + return [] + out: list[str] = [] + for asset in assets: + if isinstance(asset, dict): + atype = asset.get("asset_type") + else: + atype = getattr(asset, "asset_type", None) + if isinstance(atype, str) and atype not in out: + out.append(atype) + return out + + +def _v1_version_constraints( + v1_format: Any, + *, + keys: tuple[str, ...], +) -> list[str]: + """Collect VAST/DAAST version values from a v1 format. + + The version may live at the top level (a flat catalog entry) or + inside per-asset requirements. Walks both. Returns an empty list + when none are declared. + """ + out: list[str] = [] + src = v1_format if isinstance(v1_format, dict) else None + if src is None: + return out + for key in keys: + v = src.get(key) + if isinstance(v, str): + out.append(v) + elif isinstance(v, list): + out.extend(s for s in v if isinstance(s, str)) + # Walk per-asset requirements. + assets = src.get("assets") + if isinstance(assets, list): + for asset in assets: + if not isinstance(asset, dict): + continue + requirements = asset.get("requirements") + if not isinstance(requirements, dict): + continue + for key in keys: + v = requirements.get(key) + if isinstance(v, str): + out.append(v) + elif isinstance(v, list): + out.extend(s for s in v if isinstance(s, str)) + return out + + +def _build_declaration( + *, + kind: CanonicalFormatKind, + v1_format_id: FormatId, + params: dict[str, Any] | None = None, + canonical_ref: CanonicalProjectionReference | None = None, +) -> ProductFormatDeclaration: + """Assemble the v2 declaration from resolved kind + source v1 ref. + + Threads ``asset_source`` and ``slots_override`` from the v1 + ``canonical`` annotation into ``params`` when present, so the + seller's projection hints propagate cleanly. + """ + body: dict[str, Any] = dict(params or {}) + if canonical_ref is not None: + if canonical_ref.asset_source is not None and "asset_source" not in body: + body["asset_source"] = canonical_ref.asset_source.value + if canonical_ref.slots_override is not None and "slots" not in body: + body["slots"] = [ + slot.model_dump(exclude_none=True) for slot in canonical_ref.slots_override + ] + return ProductFormatDeclaration( + format_kind=kind, + params=body, + v1_format_ref=[v1_format_id], + ) + + +def project_v1_format_to_declaration( + v1_format: Any, + *, + field_path: str = "formats[]", +) -> V1ToV2Projection: + """Project a single v1 named format to a v2 ``ProductFormatDeclaration``. + + Walks the resolution order documented at module level. Tolerates + both raw dicts (the common case when reading a v1 catalog from + JSON) and Pydantic-validated v1 ``Format`` instances. + + Args: + v1_format: The v1 format declaration to project. Dict or + duck-typed object with ``format_id``, ``canonical``, + ``assets`` accessors. + field_path: JSONPath-lite pointer for emitted advisories + (e.g., ``"formats[2]"``). + + Returns: + :class:`V1ToV2Projection` carrying the declaration (when + projection succeeded) and any advisories the resolution order + emitted. + """ + fid = _v1_format_id(v1_format) + if fid is None: + return V1ToV2Projection( + advisories=[ + make_sdk_advisory( + code="FORMAT_PROJECTION_FAILED", + message="v1 format declaration carries no parseable format_id.", + field=field_path, + details={"resolution_failure": "missing_format_id"}, + ) + ] + ) + + # --- Step 1: seller-asserted ``canonical`` annotation --- + annotation = _v1_canonical_annotation(v1_format) + if annotation is not None: + return V1ToV2Projection( + declaration=_build_declaration( + kind=annotation.kind, + v1_format_id=fid, + canonical_ref=annotation, + ) + ) + + # --- Step 2 + 3: registry lookup (glob, then structural) --- + registry = load_default_registry() + for mapping in registry.mappings: + pattern = mapping.v1_pattern + if hasattr(pattern, "format_id_glob"): + if glob_match(fid.id, pattern.format_id_glob): + return V1ToV2Projection( + declaration=_build_declaration( + kind=mapping.v2.canonical, + v1_format_id=fid, + params=dict(mapping.v2.parameters or {}), + ) + ) + + # No literal-glob hit — try structural fallback. + asset_types = _v1_asset_types(v1_format) + vast_versions = _v1_version_constraints(v1_format, keys=("vast_version", "vast_versions")) + daast_versions = _v1_version_constraints(v1_format, keys=("daast_version", "daast_versions")) + + structural_hits: list[Any] = [] + for mapping in registry.mappings: + pattern = mapping.v1_pattern + # The discriminated union distinguishes structural (``V1Pattern1``) from + # glob (``V1Pattern``); only the structural branch is consultable here. + structural = getattr(pattern, "structural", None) + if structural is None: + continue + if structural_match( + asset_types=asset_types, + vast_versions=vast_versions or None, + daast_versions=daast_versions or None, + pattern=structural, + ): + structural_hits.append(mapping) + + if structural_hits: + # Step 4: family-level match — emit AMBIGUOUS advisory but still + # produce a usable declaration with the matched canonical so + # consumers have a typed shape to work against. + first = structural_hits[0] + declaration = _build_declaration( + kind=first.v2.canonical, + v1_format_id=fid, + params=dict(first.v2.parameters or {}), + ) + advisory = make_sdk_advisory( + code="FORMAT_DECLARATION_V1_AMBIGUOUS", + message=( + f"v1 format {fid.id!r} structurally matched the " + f"{first.v2.canonical.value!r} family but the registry " + f"entry is pure-structural — the projection is a " + f"family-level guess. Seller SHOULD add an explicit " + f"``canonical`` annotation on the v1 format." + ), + field=field_path, + details={ + "v1_format_id": _echo_identifier(fid.id), + "matched_canonical": first.v2.canonical.value, + "match_kind": "structural_family", + "candidate_count": len(structural_hits), + }, + suggestion=( + "Add a ``canonical: { kind: ..., asset_source?: ..., " + "slots_override?: [...] }`` annotation on the v1 format " + "file so the projection is seller-declared rather than " + "family-inferred." + ), + ) + return V1ToV2Projection(declaration=declaration, advisories=[advisory]) + + # --- Step 5: fail closed --- + return V1ToV2Projection( + advisories=[ + make_sdk_advisory( + code="FORMAT_PROJECTION_FAILED", + message=( + f"v1 format {fid.id!r} has no ``canonical`` annotation and " + f"no registry match — SDK cannot project it onto a v2 " + f"canonical." + ), + field=field_path, + details={ + "v1_format_id": _echo_identifier(fid.id), + "resolution_failure": "no_registry_match", + "asset_types": asset_types, + }, + suggestion=( + "Add a ``canonical`` annotation to the v1 format file, " + "or file a registry PR adding a structural pattern " + "covering this format's shape." + ), + ) + ] + ) + + +@dataclass +class V1CatalogProjection: + """Aggregate result of projecting a list of v1 formats to v2 declarations.""" + + declarations: list[ProductFormatDeclaration] = field(default_factory=list) + advisories: list[Error] = field(default_factory=list) + + +def project_v1_catalog_to_v2( + v1_formats: list[Any], + *, + field_path_prefix: str = "formats", +) -> V1CatalogProjection: + """Project a list of v1 named formats (a catalog) to v2 declarations. + + Aggregates per-format projection results — failed-closed entries + contribute their advisory but no declaration. Useful for migrating + an entire v1 ``reference-formats.json``-style catalog in one call. + """ + out = V1CatalogProjection() + for i, v1_format in enumerate(v1_formats): + result = project_v1_format_to_declaration( + v1_format, + field_path=f"{field_path_prefix}[{i}]", + ) + if result.declaration is not None: + out.declarations.append(result.declaration) + out.advisories.extend(result.advisories) + return out + + +__all__ = [ + "V1CatalogProjection", + "V1ToV2Projection", + "project_v1_catalog_to_v2", + "project_v1_format_to_declaration", +] diff --git a/tests/fixtures/canonical/amazon_sponsored_products.json b/tests/fixtures/canonical/amazon_sponsored_products.json new file mode 100644 index 000000000..0aade51ac --- /dev/null +++ b/tests/fixtures/canonical/amazon_sponsored_products.json @@ -0,0 +1,67 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "amazon_sp_search", + "name": "Amazon Sponsored Products — Search", + "description": "Catalog-driven sponsored product placement in Amazon search results. Buyer supplies a product catalog with ASINs; Amazon's surface composes per-item rendering (product image + title + price + rating + Prime badge) using its native placement template. Composition is deterministic — buyer can predict per-slot rendering from the catalog item structure. No buyer creative slots; the catalog reference is the entire input.", + "publisher_properties": [ + { + "publisher_domain": "amazon.com", + "selection_type": "all" + } + ], + "channels": [ + "retail_media" + ], + "delivery_type": "non_guaranteed", + "pricing_options": [ + { + "pricing_option_id": "cpc_auction", + "pricing_model": "cpc", + "currency": "USD", + "floor_price": 0.5 + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": [ + "hourly", + "daily" + ], + "expected_delay_minutes": 60, + "timezone": "America/Los_Angeles", + "supports_webhooks": true, + "available_metrics": [ + "impressions", + "clicks", + "spend", + "ctr", + "conversions", + "conversion_value", + "roas", + "new_to_brand_rate" + ], + "date_range_support": "date_range" + }, + "format_options": [ + { + "format_kind": "sponsored_placement", + "params": { + "supported_catalog_types": [ + "product" + ], + "min_items": 1, + "max_items": 50, + "fanout_mode": "per_item", + "required_catalog_fields": [ + "title", + "image_url", + "price" + ], + "supported_id_types": [ + "asin" + ], + "hero_asset_supported": false, + "composition_model": "deterministic" + } + } + ] +} diff --git a/tests/fixtures/canonical/chatgpt_brand_mention.json b/tests/fixtures/canonical/chatgpt_brand_mention.json new file mode 100644 index 000000000..8506bcb26 --- /dev/null +++ b/tests/fixtures/canonical/chatgpt_brand_mention.json @@ -0,0 +1,77 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "openai_chatgpt_sponsored_mention_us", + "name": "ChatGPT Sponsored Brand Mention — United States", + "description": "AI-surface sponsored placement on ChatGPT. Buyer supplies a BrandRef (resolving brand.json for context) plus optional offering reference; ChatGPT composes a natural-language sponsored mention within its response to a relevant user query. Composition is algorithmic — the agent chooses phrasing and presentation, with disclosure required and no buyer-fixed creative. Distinct from si_chat (which is the user-converses-with-brand's-agent pattern, brand-owned conversational surface). Parallels sponsored_placement structurally (both surface-composed) but for AI/agentic surfaces rather than retail-media catalog.", + "publisher_properties": [ + { + "publisher_domain": "openai.com", + "selection_type": "all" + } + ], + "channels": [ + "sponsored_intelligence" + ], + "delivery_type": "non_guaranteed", + "pricing_options": [ + { + "pricing_option_id": "cpm_mention", + "pricing_model": "cpm", + "currency": "USD", + "floor_price": 18 + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": [ + "daily" + ], + "expected_delay_minutes": 1440, + "timezone": "UTC", + "supports_webhooks": false, + "available_metrics": [ + "impressions", + "clicks", + "spend", + "ctr", + "engagement_rate" + ], + "date_range_support": "date_range" + }, + "format_options": [ + { + "format_kind": "agent_placement", + "params": { + "output_modality": "text", + "max_mention_length_chars": 280, + "supports_offering_reference": true, + "supports_landing_page_url": true, + "tone_constraints": [ + "factual", + "no_superlatives" + ], + "disclosure_required": true, + "composition_model": "algorithmic", + "slots": [ + { + "asset_group_id": "offering_ref", + "required": false, + "asset_type": "text", + "description": "Optional offering identifier to focus the mention on a specific product, service, or campaign within the brand." + }, + { + "asset_group_id": "landing_page_url", + "required": false, + "asset_type": "url", + "description": "Optional URL the surface MAY attach to mentions as a citation or learn-more link." + } + ], + "platform_extensions": [ + { + "uri": "https://creative.adcontextprotocol.org/translated/openai/extensions/chatgpt_response_card", + "digest": "sha256:f3a6c9b2e5d8f1a4c7b0e3d6f9a2c5b8e1d4f7a0c3b6e9d2f5a8c1b4e7d0f3a6" + } + ] + } + } + ] +} diff --git a/tests/fixtures/canonical/gam_3p_display_tag.json b/tests/fixtures/canonical/gam_3p_display_tag.json new file mode 100644 index 000000000..93283b2cd --- /dev/null +++ b/tests/fixtures/canonical/gam_3p_display_tag.json @@ -0,0 +1,85 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "gam_publisher_3p_display_tag_300x250", + "name": "GAM Publisher \u2014 3P Display Tag (300\u00d7250)", + "description": "Third-party-served display tag (JS or iframe) on a GAM-managed publisher placement. Buyer's adserver hosts the creative; the publisher calls the tag URL at impression time. 200KB max-snippet-size and a runtime allowlist (no eval, no document.write, no setTimeout, no javascript: / data: URLs in click trackers) apply at the GAM level \u2014 these are publisher-policy constraints, not protocol-level.", + "publisher_properties": [ + { + "publisher_domain": "examplepublisher.example", + "selection_type": "all" + } + ], + "channels": [ + "display" + ], + "delivery_type": "non_guaranteed", + "pricing_options": [ + { + "pricing_option_id": "cpm_floor", + "pricing_model": "cpm", + "currency": "USD", + "floor_price": 1.5 + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": [ + "daily" + ], + "expected_delay_minutes": 240, + "timezone": "UTC", + "supports_webhooks": false, + "available_metrics": [ + "impressions", + "clicks", + "spend", + "ctr", + "viewability" + ], + "date_range_support": "date_range" + }, + "format_options": [ + { + "format_kind": "display_tag", + "v1_format_ref": [ + { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_js" + } + ], + "params": { + "width": 300, + "height": 250, + "supported_tag_types": [ + "iframe", + "javascript", + "1x1_redirect" + ], + "ssl_required": true, + "max_redirect_depth": 4, + "max_response_time_ms": 1500, + "backup_image_required": true, + "backup_image_max_size_kb": 50, + "om_sdk_required": false, + "composition_model": "deterministic", + "slots": [ + { + "asset_group_id": "tag_url", + "asset_type": "url", + "required": true + }, + { + "asset_group_id": "backup_image", + "asset_type": "image", + "required": true, + "max_size_kb": 50 + }, + { + "asset_group_id": "landing_page_url", + "asset_type": "url", + "required": false + } + ] + } + } + ] +} diff --git a/tests/fixtures/canonical/google_performance_max.json b/tests/fixtures/canonical/google_performance_max.json new file mode 100644 index 000000000..940ba4521 --- /dev/null +++ b/tests/fixtures/canonical/google_performance_max.json @@ -0,0 +1,93 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "google_pmax_us", + "name": "Google Performance Max — United States", + "description": "Google Performance Max campaign — buyer supplies a pool of typed assets (multiple headlines, descriptions, landscape/square images, videos, logos) and Google's optimizer composes combinations across Search, Display, YouTube, Discover, Gmail, and Maps. Composition is algorithmic — surface picks combinations and reports per-asset performance breakdowns. Industry term: \"Responsive\" (Google) / \"Advantage+ creative\" (Meta) / \"Dynamic Creative\" (older Meta term). Distinct from sponsored_placement (catalog-driven, deterministic) and agent_placement (AI-surface composition).", + "publisher_properties": [ + { + "publisher_domain": "google.com", + "selection_type": "all" + } + ], + "channels": [ + "search", + "display", + "ctv", + "olv" + ], + "delivery_type": "non_guaranteed", + "pricing_options": [ + { + "pricing_option_id": "cpa_purchase", + "pricing_model": "cpa", + "event_type": "purchase", + "currency": "USD", + "fixed_price": 25 + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": [ + "daily" + ], + "expected_delay_minutes": 240, + "timezone": "America/Los_Angeles", + "supports_webhooks": false, + "available_metrics": [ + "impressions", + "clicks", + "spend", + "ctr", + "conversions", + "conversion_value", + "cost_per_acquisition", + "roas", + "viewability" + ], + "date_range_support": "date_range" + }, + "format_options": [ + { + "format_kind": "responsive_creative", + "params": { + "headlines_min": 3, + "headlines_max": 15, + "headline_max_chars": 30, + "long_headlines_min": 1, + "long_headlines_max": 5, + "long_headline_max_chars": 90, + "descriptions_min": 2, + "descriptions_max": 5, + "description_max_chars": 90, + "images_landscape_min": 1, + "images_landscape_max": 20, + "images_landscape_aspect_ratio": "1.91:1", + "images_square_min": 1, + "images_square_max": 20, + "videos_min": 0, + "videos_max": 5, + "video_min_duration_ms": 10000, + "video_max_duration_ms": 600000, + "logo_min": 1, + "logo_max": 5, + "logo_aspect_ratios": [ + "1:1", + "4:1" + ], + "business_name_max_chars": 25, + "asset_image_max_file_size_kb": 5120, + "supports_catalog_input": true, + "composition_model": "algorithmic", + "platform_extensions": [ + { + "uri": "https://creative.adcontextprotocol.org/translated/google/extensions/google_conversion_actions", + "digest": "sha256:d8f1a4c7b0e3d6f9a2c5b8e1d4f7a0c3b6e9d2f5a8c1b4e7d0f3a6c9b2e5d8f1" + }, + { + "uri": "https://creative.adcontextprotocol.org/translated/google/extensions/google_audience_signals", + "digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + ] + } + } + ] +} diff --git a/tests/fixtures/canonical/meta_carousel.json b/tests/fixtures/canonical/meta_carousel.json new file mode 100644 index 000000000..de6ca9be9 --- /dev/null +++ b/tests/fixtures/canonical/meta_carousel.json @@ -0,0 +1,88 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "meta_carousel_us", + "name": "Meta Carousel — United States", + "description": "Multi-card swipeable carousel on Meta feed (Facebook + Instagram). 2-10 cards, square aspect, polymorphic per-card asset (image OR video). Each card carries its own headline + click URL. Surface composes the swipeable presentation; buyer ships the cards array.", + "publisher_properties": [ + { + "publisher_domain": "meta.com", + "selection_type": "all" + } + ], + "channels": [ + "social" + ], + "delivery_type": "non_guaranteed", + "pricing_options": [ + { + "pricing_option_id": "cpm_floor", + "pricing_model": "cpm", + "currency": "USD", + "floor_price": 4.5 + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": [ + "hourly", + "daily" + ], + "expected_delay_minutes": 60, + "timezone": "America/Los_Angeles", + "supports_webhooks": true, + "available_metrics": [ + "impressions", + "clicks", + "spend", + "ctr", + "engagement_rate", + "viewability" + ], + "date_range_support": "date_range" + }, + "format_options": [ + { + "format_kind": "image_carousel", + "params": { + "card_aspect_ratio": "1:1", + "min_cards": 2, + "max_cards": 10, + "allowed_card_asset_types": [ + "image", + "video" + ], + "card_image_max_file_size_kb": 30000, + "card_video_max_duration_ms": 240000, + "primary_text_max_chars": 125, + "card_headline_max_chars": 40, + "ssl_required": true, + "composition_model": "deterministic", + "slots": [ + { + "asset_group_id": "cards", + "asset_type": "object", + "required": true, + "min": 2, + "max": 10 + }, + { + "asset_group_id": "primary_text", + "asset_type": "text", + "required": false, + "max_chars": 125 + }, + { + "asset_group_id": "landing_page_url", + "asset_type": "url", + "required": true + } + ], + "platform_extensions": [ + { + "uri": "https://creative.adcontextprotocol.org/translated/meta/extensions/meta_pixel", + "digest": "sha256:a3f5b7c9d8e2f1a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2" + } + ] + } + } + ] +} diff --git a/tests/fixtures/canonical/meta_reels_us.json b/tests/fixtures/canonical/meta_reels_us.json new file mode 100644 index 000000000..feb3033d1 --- /dev/null +++ b/tests/fixtures/canonical/meta_reels_us.json @@ -0,0 +1,85 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "meta_reels_us", + "name": "Meta Reels \u2014 United States", + "description": "9:16 vertical short-form video on Meta Reels (Facebook + Instagram). Buyers upload H.264 mp4 (3-90s) plus headline and primary text; Meta serves to Reels feeds with placement-native UI overlays. Conversion tracking is campaign-scoped and configured via `sync_event_sources` (event_log surface) — NOT via the creative format declaration.", + "publisher_properties": [ + { + "publisher_domain": "meta.com", + "selection_type": "all" + } + ], + "channels": [ + "social" + ], + "delivery_type": "non_guaranteed", + "pricing_options": [ + { + "pricing_option_id": "cpm_floor", + "pricing_model": "cpm", + "currency": "USD", + "floor_price": 5.5 + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": [ + "hourly", + "daily" + ], + "expected_delay_minutes": 60, + "timezone": "America/Los_Angeles", + "supports_webhooks": true, + "available_metrics": [ + "impressions", + "clicks", + "spend", + "completion_rate", + "viewability" + ], + "date_range_support": "date_range" + }, + "format_options": [ + { + "format_kind": "video_hosted", + "capability_id": "meta_reels", + "v1_format_ref": [ + { + "agent_url": "https://creative.adcontextprotocol.org/translated/meta", + "id": "meta_reels" + } + ], + "params": { + "orientation": "vertical", + "aspect_ratio": "9:16", + "duration_ms_range": [ + 3000, + 90000 + ], + "min_width": 1080, + "min_height": 1920, + "max_file_size_mb": 200, + "video_codecs": [ + "h264" + ], + "audio_codecs": [ + "aac" + ], + "containers": [ + "mp4" + ], + "headline_max_chars": 40, + "primary_text_max_chars": 125, + "captions": "recommended", + "cta_values": [ + "LEARN_MORE", + "SHOP_NOW", + "DOWNLOAD", + "SIGN_UP", + "CONTACT_US", + "BOOK_NOW" + ], + "composition_model": "deterministic" + } + } + ] +} diff --git a/tests/fixtures/canonical/nytimes_homepage_html5.json b/tests/fixtures/canonical/nytimes_homepage_html5.json new file mode 100644 index 000000000..6fcb407c9 --- /dev/null +++ b/tests/fixtures/canonical/nytimes_homepage_html5.json @@ -0,0 +1,70 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "nytimes_homepage_html5", + "name": "NYTimes.com Homepage HTML5 Banner (300\u00d7250)", + "description": "IAB Medium Rectangle (300\u00d7250) interactive HTML5 banner placement on the NYTimes.com homepage. Buyers upload an HTML5 zip bundle (\u2264200KB initial load, \u2264500KB polite-load with host_initiated_subload, max 30s animation, OM-SDK + clickTag macro). Different canonical from the static image MREC because the tracking model is fundamentally different (MRAID + OM-SDK vs impression pixel + click URL).", + "publisher_properties": [ + { + "publisher_domain": "nytimes.com", + "selection_type": "by_id", + "property_ids": [ + "homepage_above_fold" + ] + } + ], + "channels": [ + "display" + ], + "delivery_type": "guaranteed", + "pricing_options": [ + { + "pricing_option_id": "cpm_homepage_html5", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 28 + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": [ + "daily" + ], + "expected_delay_minutes": 240, + "timezone": "America/New_York", + "supports_webhooks": false, + "available_metrics": [ + "impressions", + "clicks", + "spend", + "ctr", + "viewability", + "engagement_rate" + ], + "date_range_support": "date_range" + }, + "format_options": [ + { + "format_kind": "html5", + "v1_format_ref": [ + { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_300x250_html" + } + ], + "params": { + "width": 300, + "height": 250, + "max_initial_load_kb": 200, + "max_polite_load_kb": 500, + "host_initiated_subload": true, + "max_animation_duration_ms": 30000, + "max_cpu_load_percent": 30, + "om_sdk_required": true, + "clicktag_macro": "clickTag", + "backup_image_required": true, + "backup_image_max_size_kb": 50, + "ssl_required": true, + "composition_model": "deterministic" + } + } + ] +} diff --git a/tests/fixtures/canonical/nytimes_homepage_mrec.json b/tests/fixtures/canonical/nytimes_homepage_mrec.json new file mode 100644 index 000000000..4537eb16b --- /dev/null +++ b/tests/fixtures/canonical/nytimes_homepage_mrec.json @@ -0,0 +1,196 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "nytimes_homepage_flex_display", + "name": "NYTimes.com Homepage Above-the-Fold Display", + "description": "Flexible above-the-fold display slot on the NYTimes.com homepage. Slot accepts IAB display sizes (300\u00d7250 MREC, 728\u00d790 leaderboard, 970\u00d7250 billboard) as image, HTML5 zip, or third-party tag \u2014 the three creative shapes the same physical placement consumes. Single product, three format_options (one per creative type), each declaring `sizes[]` for the multi-size acceptance. Buyer picks the creative type they ship; size matches one of the listed pairs. Standard impression pixel + click URL tracking via universal_macros plus IAB Open Measurement viewability via the nytimes_om_strict extension.", + "publisher_properties": [ + { + "publisher_domain": "nytimes.com", + "selection_type": "by_id", + "property_ids": [ + "homepage_above_fold" + ] + } + ], + "channels": [ + "display" + ], + "delivery_type": "guaranteed", + "pricing_options": [ + { + "pricing_option_id": "cpm_homepage_display", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 22 + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": [ + "daily" + ], + "expected_delay_minutes": 240, + "timezone": "America/New_York", + "supports_webhooks": false, + "available_metrics": [ + "impressions", + "clicks", + "spend", + "ctr", + "viewability" + ], + "date_range_support": "date_range" + }, + "format_options": [ + { + "format_kind": "image", + "capability_id": "nytimes_homepage_image", + "display_name": "NYTimes Homepage \u2014 Image (multi-size)", + "v1_format_ref": [ + { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_300x250_image" + }, + { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_728x90_image" + }, + { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_970x250_image" + } + ], + "params": { + "sizes": [ + { + "width": 300, + "height": 250 + }, + { + "width": 728, + "height": 90 + }, + { + "width": 970, + "height": 250 + } + ], + "max_file_size_kb": 200, + "image_formats": [ + "jpg", + "png", + "gif" + ], + "ssl_required": true, + "cta_values": [ + "LEARN_MORE", + "SHOP_NOW", + "GET_OFFER" + ], + "composition_model": "deterministic", + "platform_extensions": [ + { + "uri": "https://nytimes.example/extensions/nytimes_om_strict", + "digest": "sha256:c9d2f5b8e1a4c7b0e3d6f9a2c5b8e1d4f7a0c3b6e9d2f5a8c1b4e7d0f3a6c9b2" + } + ] + } + }, + { + "format_kind": "html5", + "capability_id": "nytimes_homepage_html5", + "display_name": "NYTimes Homepage \u2014 HTML5 (multi-size)", + "v1_format_ref": [ + { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_300x250_html" + }, + { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_728x90_html" + }, + { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_970x250_html" + } + ], + "params": { + "sizes": [ + { + "width": 300, + "height": 250 + }, + { + "width": 728, + "height": 90 + }, + { + "width": 970, + "height": 250 + } + ], + "max_initial_load_kb": 200, + "max_polite_load_kb": 500, + "host_initiated_subload": true, + "max_animation_duration_ms": 30000, + "backup_image_required": true, + "om_sdk_required": true, + "composition_model": "deterministic", + "platform_extensions": [ + { + "uri": "https://nytimes.example/extensions/nytimes_om_strict", + "digest": "sha256:c9d2f5b8e1a4c7b0e3d6f9a2c5b8e1d4f7a0c3b6e9d2f5a8c1b4e7d0f3a6c9b2" + } + ] + } + }, + { + "format_kind": "display_tag", + "capability_id": "nytimes_homepage_3p_tag", + "display_name": "NYTimes Homepage \u2014 Third-party tag (multi-size)", + "v1_format_ref": [ + { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_300x250_js" + }, + { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_728x90_js" + }, + { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_970x250_js" + } + ], + "params": { + "sizes": [ + { + "width": 300, + "height": 250 + }, + { + "width": 728, + "height": 90 + }, + { + "width": 970, + "height": 250 + } + ], + "supported_tag_types": [ + "javascript", + "iframe" + ], + "ssl_required": true, + "max_redirect_depth": 2, + "om_sdk_required": true, + "composition_model": "deterministic", + "platform_extensions": [ + { + "uri": "https://nytimes.example/extensions/nytimes_om_strict", + "digest": "sha256:c9d2f5b8e1a4c7b0e3d6f9a2c5b8e1d4f7a0c3b6e9d2f5a8c1b4e7d0f3a6c9b2" + } + ] + } + } + ] +} diff --git a/tests/fixtures/canonical/nytimes_homepage_takeover_custom.json b/tests/fixtures/canonical/nytimes_homepage_takeover_custom.json new file mode 100644 index 000000000..6412e1841 --- /dev/null +++ b/tests/fixtures/canonical/nytimes_homepage_takeover_custom.json @@ -0,0 +1,67 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "nytimes_homepage_takeover_premium", + "name": "NYTimes.com Homepage Takeover (Multi-Placement Sponsorship)", + "description": "24-hour multi-placement homepage takeover on NYTimes.com — bundles a homepage skin, a preroll video on homepage video assets, and a sponsorship-lockup banner adjacent to top-of-page content. Sold as a unit (exclusivity_window_hours: 24). Demonstrates `format_kind: \"custom\"` with `format_shape: multi_placement_takeover` and a `format_schema` URI+digest reference pointing at NYTimes's hosted schema describing the takeover's components. Required `canonical_formats_only: true` — no v1 named format can express the multi-component shape, so SDKs MUST NOT synthesize a v1 `format_id` for this declaration.", + "publisher_properties": [ + { + "publisher_domain": "nytimes.com", + "selection_type": "by_id", + "property_ids": [ + "homepage_above_fold" + ] + } + ], + "channels": [ + "display", + "olv" + ], + "delivery_type": "guaranteed", + "pricing_options": [ + { + "pricing_option_id": "flat_takeover_24h", + "pricing_model": "flat_rate", + "currency": "USD", + "fixed_price": 250000 + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": [ + "daily" + ], + "expected_delay_minutes": 240, + "timezone": "America/New_York", + "supports_webhooks": false, + "available_metrics": [ + "impressions", + "clicks", + "spend", + "ctr", + "viewability" + ], + "date_range_support": "date_range" + }, + "format_options": [ + { + "format_kind": "custom", + "canonical_formats_only": true, + "format_shape": "multi_placement_takeover", + "format_schema": { + "uri": "https://nytimes.example/schemas/formats/homepage_takeover_v3", + "digest": "sha256:e1d4f6a9c2b5e8d1f4a7c0b3e6d9f2a5c8b1e4d7f0a3c6b9e2d5f8a1c4b7e0a3" + }, + "capability_id": "nytimes_homepage_takeover_premium", + "display_name": "Homepage Takeover — Premium Sponsorship", + "applies_to_channels": ["display", "olv"], + "params": { + "components": [ + { "placement_type": "homepage_skin", "required": true }, + { "placement_type": "preroll_video", "required": true }, + { "placement_type": "sponsorship_lockup", "required": true } + ], + "exclusivity_window_hours": 24, + "ssl_required": true + } + } + ] +} diff --git a/tests/fixtures/canonical/taboola_content_recommendation.json b/tests/fixtures/canonical/taboola_content_recommendation.json new file mode 100644 index 000000000..c8efe52d5 --- /dev/null +++ b/tests/fixtures/canonical/taboola_content_recommendation.json @@ -0,0 +1,82 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "taboola_content_recommendation_us", + "name": "Taboola — US Content Recommendation", + "description": "In-feed native content-recommendation placement on Taboola's publisher network (CNN, USA Today, Bloomberg, MSN, and ~2 000 other premium properties). Buyer ships an IAB-shaped native asset bundle (title, thumbnail, body, display_url, optional rating); Taboola's renderer composes the unit to match each publisher's feed look-and-feel. Distinct from `sponsored_placement` (catalog-keyed retail-media — Taboola has no per-item catalog feed) and from `responsive_creative` (algorithmic combinator — Taboola uses a single asset bundle, not an asset pool). Renderer-fired pixel trackers (impression, viewability, click) ship as `pixel_tracker` assets.", + "publisher_properties": [ + { + "publisher_domain": "taboola.com", + "selection_type": "all" + } + ], + "channels": [ + "display" + ], + "delivery_type": "non_guaranteed", + "pricing_options": [ + { + "pricing_option_id": "cpc_open_auction", + "pricing_model": "cpc", + "currency": "USD", + "floor_price": 0.1 + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": [ + "hourly", + "daily" + ], + "expected_delay_minutes": 30, + "timezone": "America/New_York", + "supports_webhooks": true, + "available_metrics": [ + "impressions", + "clicks", + "spend", + "ctr", + "viewability", + "conversions" + ], + "date_range_support": "date_range" + }, + "format_options": [ + { + "format_kind": "native_in_feed", + "display_name": "Taboola Sponsored Content — Premium", + "applies_to_channels": ["display"], + "params": { + "title_max_chars": 80, + "body_text_max_chars": 140, + "cta_max_chars": 25, + "cta_values": ["LEARN_MORE", "READ_MORE", "SHOP_NOW", "SIGN_UP", "DOWNLOAD"], + "main_image_sizes": [ + { "width": 1200, "height": 627 }, + { "width": 1080, "height": 1080 } + ], + "max_image_file_size_kb": 500, + "image_formats": ["jpg", "jpeg", "png", "webp"], + "ssl_required": true, + "asset_source": "buyer_uploaded", + "buyer_asset_acceptance": "accepted", + "composition_model": "deterministic", + "slots": [ + { "asset_group_id": "title", "asset_type": "text", "required": true, "max_chars": 80 }, + { "asset_group_id": "body_text", "asset_type": "text", "required": false, "max_chars": 140 }, + { "asset_group_id": "main_image", "asset_type": "image", "required": true }, + { "asset_group_id": "cta", "asset_type": "text", "required": false, "max_chars": 25 }, + { "asset_group_id": "advertiser_name", "asset_type": "text", "required": true }, + { "asset_group_id": "sponsored_label", "asset_type": "text", "required": false }, + { "asset_group_id": "landing_page_url", "asset_type": "url", "required": true }, + { "asset_group_id": "display_url", "asset_type": "text", "required": false }, + { "asset_group_id": "rating", "asset_type": "text", "required": false }, + { "asset_group_id": "impression_tracker", "asset_type": "pixel_tracker", "required": false }, + { "asset_group_id": "viewability_tracker", "asset_type": "pixel_tracker", "required": false }, + { "asset_group_id": "click_tracker", "asset_type": "pixel_tracker", "required": false } + ] + }, + "v1_format_ref": [ + { "agent_url": "https://creative.adcontextprotocol.org", "id": "native_content" } + ] + } + ] +} diff --git a/tests/fixtures/canonical/the_daily_30s_host_read.json b/tests/fixtures/canonical/the_daily_30s_host_read.json new file mode 100644 index 000000000..24b9f3ed8 --- /dev/null +++ b/tests/fixtures/canonical/the_daily_30s_host_read.json @@ -0,0 +1,78 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "the_daily_30s_host_read_us", + "name": "The Daily — 30s Host-Read Pre-roll (US)", + "description": "30-second podcast host-read pre-roll on The Daily. Buyer-uploaded audio is rejected (asset_source: publisher_host_recorded); buyer submits a verbatim script (≤800 chars) as a text asset under the `script` slot, plus brand context via the manifest's BrandRef. The publisher's host records the audio, which is dynamically inserted at podcast playback time. 7-business-day production turnaround. A brief-driven host-read product would have the same shape with `creative_brief` (brief asset_type) in the slots instead of `script` (text asset_type).", + "publisher_properties": [ + { + "publisher_domain": "thedailypod.example", + "selection_type": "all" + } + ], + "channels": [ + "podcast" + ], + "delivery_type": "guaranteed", + "pricing_options": [ + { + "pricing_option_id": "cpm_host_read", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 35 + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": [ + "daily" + ], + "expected_delay_minutes": 1440, + "timezone": "America/New_York", + "supports_webhooks": false, + "available_metrics": [ + "impressions", + "spend", + "completion_rate", + "completed_views" + ], + "date_range_support": "date_range" + }, + "format_options": [ + { + "format_kind": "audio_hosted", + "params": { + "duration_ms_exact": 30000, + "audio_codecs": [ + "mp3", + "aac" + ], + "audio_sample_rates": [ + 44100, + 48000 + ], + "audio_channels": [ + "stereo" + ], + "loudness_lufs": -16, + "asset_source": "publisher_host_recorded", + "buyer_asset_acceptance": "rejected", + "composition_model": "deterministic", + "slots": [ + { + "asset_group_id": "script", + "required": true, + "asset_type": "text", + "max_chars": 800, + "description": "Verbatim script the host reads — exact wording; no improvisation; legal pre-cleared." + }, + { + "asset_group_id": "offering_ref", + "required": false, + "asset_type": "text", + "description": "Optional offering identifier from the buyer's catalog to focus the host-read." + } + ], + "production_window_business_days": 7 + } + } + ] +} diff --git a/tests/fixtures/canonical/triton_daast_audio_30s.json b/tests/fixtures/canonical/triton_daast_audio_30s.json new file mode 100644 index 000000000..4413b4e1a --- /dev/null +++ b/tests/fixtures/canonical/triton_daast_audio_30s.json @@ -0,0 +1,68 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "triton_daast_audio_30s", + "name": "Triton Audio DAAST (30s)", + "description": "DAAST 1.1 audio tag on Triton-managed streaming radio inventory. Buyer ships a DAAST tag (URL or inline XML); the streaming server fires DAAST events (impression / quartiles / click / complete / error) inherent to the spec. Audio analog of VAST.", + "publisher_properties": [ + { + "publisher_domain": "triton.example", + "selection_type": "all" + } + ], + "channels": [ + "streaming_audio", + "radio" + ], + "delivery_type": "non_guaranteed", + "pricing_options": [ + { + "pricing_option_id": "cpm_floor", + "pricing_model": "cpm", + "currency": "USD", + "floor_price": 12 + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": [ + "daily" + ], + "expected_delay_minutes": 1440, + "timezone": "UTC", + "supports_webhooks": false, + "available_metrics": [ + "impressions", + "spend", + "completion_rate", + "completed_views", + "quartile_data" + ], + "date_range_support": "date_range" + }, + "format_options": [ + { + "format_kind": "audio_daast", + "canonical_formats_only": true, + "params": { + "daast_version": "1.1", + "duration_ms_exact": 30000, + "linear_required": true, + "max_wrapper_depth": 3, + "ssl_required": true, + "companion_image_required": false, + "composition_model": "deterministic", + "slots": [ + { + "asset_group_id": "daast_tag", + "asset_type": "daast", + "required": true + }, + { + "asset_group_id": "landing_page_url", + "asset_type": "url", + "required": false + } + ] + } + } + ] +} diff --git a/tests/fixtures/canonical/v1-reference-formats.json b/tests/fixtures/canonical/v1-reference-formats.json new file mode 100644 index 000000000..31672eb12 --- /dev/null +++ b/tests/fixtures/canonical/v1-reference-formats.json @@ -0,0 +1,5245 @@ +[ + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_generative" + }, + "name": "Display Banner - AI Generated", + "description": "AI-generated display banner from brand context and prompt (supports any dimensions)", + "type": "display", + "accepts_parameters": [ + "dimensions" + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "generation_prompt", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Text prompt describing the desired creative" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "catalog_requirements": [ + { + "catalog_type": "offering", + "required": true, + "required_fields": [ + "name" + ] + } + ], + "assets_required": [ + { + "asset_id": "generation_prompt", + "item_type": "individual", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Text prompt describing the desired creative" + } + } + ], + "canonical": { + "kind": "image", + "asset_source": "agent_synthesized", + "slots_override": [ + { + "asset_group_id": "generation_prompt", + "asset_type": "text", + "required": true + } + ] + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_300x250_generative" + }, + "name": "Medium Rectangle - AI Generated", + "description": "AI-generated 300x250 banner from brand context and prompt", + "type": "display", + "renders": [ + { + "dimensions": { + "height": 250, + "responsive": { + "height": false, + "width": false + }, + "width": 300 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "generation_prompt", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Text prompt describing the desired creative" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "output_format_ids": [ + { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_300x250_image" + } + ], + "catalog_requirements": [ + { + "catalog_type": "offering", + "required": true, + "required_fields": [ + "name" + ] + } + ], + "assets_required": [ + { + "asset_id": "generation_prompt", + "item_type": "individual", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Text prompt describing the desired creative" + } + } + ], + "canonical": { + "kind": "image", + "asset_source": "agent_synthesized", + "slots_override": [ + { + "asset_group_id": "generation_prompt", + "asset_type": "text", + "required": true + } + ] + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_728x90_generative" + }, + "name": "Leaderboard - AI Generated", + "description": "AI-generated 728x90 banner from brand context and prompt", + "type": "display", + "renders": [ + { + "dimensions": { + "height": 90, + "responsive": { + "height": false, + "width": false + }, + "width": 728 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "generation_prompt", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Text prompt describing the desired creative" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "output_format_ids": [ + { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_728x90_image" + } + ], + "catalog_requirements": [ + { + "catalog_type": "offering", + "required": true, + "required_fields": [ + "name" + ] + } + ], + "assets_required": [ + { + "asset_id": "generation_prompt", + "item_type": "individual", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Text prompt describing the desired creative" + } + } + ], + "canonical": { + "kind": "image", + "asset_source": "agent_synthesized", + "slots_override": [ + { + "asset_group_id": "generation_prompt", + "asset_type": "text", + "required": true + } + ] + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_320x50_generative" + }, + "name": "Mobile Banner - AI Generated", + "description": "AI-generated 320x50 mobile banner from brand context and prompt", + "type": "display", + "renders": [ + { + "dimensions": { + "height": 50, + "responsive": { + "height": false, + "width": false + }, + "width": 320 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "generation_prompt", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Text prompt describing the desired creative" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "output_format_ids": [ + { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_320x50_image" + } + ], + "catalog_requirements": [ + { + "catalog_type": "offering", + "required": true, + "required_fields": [ + "name" + ] + } + ], + "assets_required": [ + { + "asset_id": "generation_prompt", + "item_type": "individual", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Text prompt describing the desired creative" + } + } + ], + "canonical": { + "kind": "image", + "asset_source": "agent_synthesized", + "slots_override": [ + { + "asset_group_id": "generation_prompt", + "asset_type": "text", + "required": true + } + ] + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_160x600_generative" + }, + "name": "Wide Skyscraper - AI Generated", + "description": "AI-generated 160x600 wide skyscraper from brand context and prompt", + "type": "display", + "renders": [ + { + "dimensions": { + "height": 600, + "responsive": { + "height": false, + "width": false + }, + "width": 160 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "generation_prompt", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Text prompt describing the desired creative" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "output_format_ids": [ + { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_160x600_image" + } + ], + "catalog_requirements": [ + { + "catalog_type": "offering", + "required": true, + "required_fields": [ + "name" + ] + } + ], + "assets_required": [ + { + "asset_id": "generation_prompt", + "item_type": "individual", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Text prompt describing the desired creative" + } + } + ], + "canonical": { + "kind": "image", + "asset_source": "agent_synthesized", + "slots_override": [ + { + "asset_group_id": "generation_prompt", + "asset_type": "text", + "required": true + } + ] + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_336x280_generative" + }, + "name": "Large Rectangle - AI Generated", + "description": "AI-generated 336x280 large rectangle from brand context and prompt", + "type": "display", + "renders": [ + { + "dimensions": { + "height": 280, + "responsive": { + "height": false, + "width": false + }, + "width": 336 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "generation_prompt", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Text prompt describing the desired creative" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "output_format_ids": [ + { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_336x280_image" + } + ], + "catalog_requirements": [ + { + "catalog_type": "offering", + "required": true, + "required_fields": [ + "name" + ] + } + ], + "assets_required": [ + { + "asset_id": "generation_prompt", + "item_type": "individual", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Text prompt describing the desired creative" + } + } + ], + "canonical": { + "kind": "image", + "asset_source": "agent_synthesized", + "slots_override": [ + { + "asset_group_id": "generation_prompt", + "asset_type": "text", + "required": true + } + ] + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_300x600_generative" + }, + "name": "Half Page - AI Generated", + "description": "AI-generated 300x600 half page from brand context and prompt", + "type": "display", + "renders": [ + { + "dimensions": { + "height": 600, + "responsive": { + "height": false, + "width": false + }, + "width": 300 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "generation_prompt", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Text prompt describing the desired creative" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "output_format_ids": [ + { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_300x600_image" + } + ], + "catalog_requirements": [ + { + "catalog_type": "offering", + "required": true, + "required_fields": [ + "name" + ] + } + ], + "assets_required": [ + { + "asset_id": "generation_prompt", + "item_type": "individual", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Text prompt describing the desired creative" + } + } + ], + "canonical": { + "kind": "image", + "asset_source": "agent_synthesized", + "slots_override": [ + { + "asset_group_id": "generation_prompt", + "asset_type": "text", + "required": true + } + ] + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_970x250_generative" + }, + "name": "Billboard - AI Generated", + "description": "AI-generated 970x250 billboard from brand context and prompt", + "type": "display", + "renders": [ + { + "dimensions": { + "height": 250, + "responsive": { + "height": false, + "width": false + }, + "width": 970 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "generation_prompt", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Text prompt describing the desired creative" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "output_format_ids": [ + { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_970x250_image" + } + ], + "catalog_requirements": [ + { + "catalog_type": "offering", + "required": true, + "required_fields": [ + "name" + ] + } + ], + "assets_required": [ + { + "asset_id": "generation_prompt", + "item_type": "individual", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Text prompt describing the desired creative" + } + } + ], + "canonical": { + "kind": "image", + "asset_source": "agent_synthesized", + "slots_override": [ + { + "asset_group_id": "generation_prompt", + "asset_type": "text", + "required": true + } + ] + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "video_standard" + }, + "name": "Standard Video", + "description": "Video ad in standard aspect ratios (supports any duration)", + "type": "video", + "accepts_parameters": [ + "duration" + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "video_file", + "required": true, + "asset_type": "video", + "requirements": { + "containers": [ + "mp4", + "mov", + "webm" + ] + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING", + "VIDEO_ID", + "POD_POSITION", + "CONTENT_GENRE" + ], + "assets_required": [ + { + "asset_id": "video_file", + "item_type": "individual", + "required": true, + "asset_type": "video", + "requirements": { + "containers": [ + "mp4", + "mov", + "webm" + ] + } + } + ], + "canonical": { + "kind": "video_hosted" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "video_dimensions" + }, + "name": "Video with Dimensions", + "description": "Video ad with specific dimensions (supports any size)", + "type": "video", + "accepts_parameters": [ + "dimensions" + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "video_file", + "required": true, + "asset_type": "video", + "requirements": { + "containers": [ + "mp4", + "mov", + "webm" + ] + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING", + "VIDEO_ID", + "POD_POSITION", + "CONTENT_GENRE" + ], + "assets_required": [ + { + "asset_id": "video_file", + "item_type": "individual", + "required": true, + "asset_type": "video", + "requirements": { + "containers": [ + "mp4", + "mov", + "webm" + ] + } + } + ], + "canonical": { + "kind": "video_hosted" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "video_vast" + }, + "name": "VAST Video", + "description": "Video ad via VAST tag (supports any duration)", + "type": "video", + "accepts_parameters": [ + "duration" + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "vast_tag", + "required": true, + "asset_type": "vast" + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING", + "VIDEO_ID", + "POD_POSITION", + "CONTENT_GENRE" + ], + "assets_required": [ + { + "asset_id": "vast_tag", + "item_type": "individual", + "required": true, + "asset_type": "vast" + } + ], + "canonical": { + "kind": "video_vast" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "video_standard_30s" + }, + "name": "Standard Video - 30 seconds", + "description": "30-second video ad in standard aspect ratios", + "type": "video", + "assets": [ + { + "item_type": "individual", + "asset_id": "video_file", + "required": true, + "asset_type": "video", + "requirements": { + "min_duration_ms": 30000, + "max_duration_ms": 30000, + "containers": [ + "mp4", + "mov", + "webm" + ], + "description": "30-second video file" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING", + "VIDEO_ID", + "POD_POSITION", + "CONTENT_GENRE" + ], + "assets_required": [ + { + "asset_id": "video_file", + "item_type": "individual", + "required": true, + "asset_type": "video", + "requirements": { + "min_duration_ms": 30000, + "max_duration_ms": 30000, + "containers": [ + "mp4", + "mov", + "webm" + ], + "description": "30-second video file" + } + } + ], + "canonical": { + "kind": "video_hosted" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "video_standard_15s" + }, + "name": "Standard Video - 15 seconds", + "description": "15-second video ad in standard aspect ratios", + "type": "video", + "assets": [ + { + "item_type": "individual", + "asset_id": "video_file", + "required": true, + "asset_type": "video", + "requirements": { + "min_duration_ms": 15000, + "max_duration_ms": 15000, + "containers": [ + "mp4", + "mov", + "webm" + ], + "description": "15-second video file" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING", + "VIDEO_ID", + "POD_POSITION", + "CONTENT_GENRE" + ], + "assets_required": [ + { + "asset_id": "video_file", + "item_type": "individual", + "required": true, + "asset_type": "video", + "requirements": { + "min_duration_ms": 15000, + "max_duration_ms": 15000, + "containers": [ + "mp4", + "mov", + "webm" + ], + "description": "15-second video file" + } + } + ], + "canonical": { + "kind": "video_hosted" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "video_vast_30s" + }, + "name": "VAST Video - 30 seconds", + "description": "30-second video ad via VAST tag", + "type": "video", + "assets": [ + { + "item_type": "individual", + "asset_id": "vast_tag", + "required": true, + "asset_type": "vast", + "requirements": { + "description": "VAST 4.x compatible tag" + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING", + "VIDEO_ID", + "POD_POSITION", + "CONTENT_GENRE" + ], + "assets_required": [ + { + "asset_id": "vast_tag", + "item_type": "individual", + "required": true, + "asset_type": "vast", + "requirements": { + "description": "VAST 4.x compatible tag" + } + } + ], + "canonical": { + "kind": "video_vast" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "video_1920x1080" + }, + "name": "Full HD Video - 1920x1080", + "description": "1920x1080 Full HD video (16:9)", + "type": "video", + "renders": [ + { + "dimensions": { + "height": 1080, + "responsive": { + "height": false, + "width": false + }, + "width": 1920 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "video_file", + "required": true, + "asset_type": "video", + "requirements": { + "width": 1920, + "height": 1080, + "containers": [ + "mp4", + "mov", + "webm" + ], + "description": "1920x1080 video file" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING", + "VIDEO_ID", + "POD_POSITION", + "CONTENT_GENRE" + ], + "assets_required": [ + { + "asset_id": "video_file", + "item_type": "individual", + "required": true, + "asset_type": "video", + "requirements": { + "width": 1920, + "height": 1080, + "containers": [ + "mp4", + "mov", + "webm" + ], + "description": "1920x1080 video file" + } + } + ], + "canonical": { + "kind": "video_hosted" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "video_1280x720" + }, + "name": "HD Video - 1280x720", + "description": "1280x720 HD video (16:9)", + "type": "video", + "renders": [ + { + "dimensions": { + "height": 720, + "responsive": { + "height": false, + "width": false + }, + "width": 1280 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "video_file", + "required": true, + "asset_type": "video", + "requirements": { + "width": 1280, + "height": 720, + "containers": [ + "mp4", + "mov", + "webm" + ], + "description": "1280x720 video file" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING", + "VIDEO_ID", + "POD_POSITION", + "CONTENT_GENRE" + ], + "assets_required": [ + { + "asset_id": "video_file", + "item_type": "individual", + "required": true, + "asset_type": "video", + "requirements": { + "width": 1280, + "height": 720, + "containers": [ + "mp4", + "mov", + "webm" + ], + "description": "1280x720 video file" + } + } + ], + "canonical": { + "kind": "video_hosted" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "video_1080x1920" + }, + "name": "Vertical Video - 1080x1920", + "description": "1080x1920 vertical video (9:16) for mobile stories", + "type": "video", + "renders": [ + { + "dimensions": { + "height": 1920, + "responsive": { + "height": false, + "width": false + }, + "width": 1080 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "video_file", + "required": true, + "asset_type": "video", + "requirements": { + "width": 1080, + "height": 1920, + "containers": [ + "mp4", + "mov", + "webm" + ], + "description": "1080x1920 vertical video file" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING", + "VIDEO_ID", + "POD_POSITION", + "CONTENT_GENRE" + ], + "assets_required": [ + { + "asset_id": "video_file", + "item_type": "individual", + "required": true, + "asset_type": "video", + "requirements": { + "width": 1080, + "height": 1920, + "containers": [ + "mp4", + "mov", + "webm" + ], + "description": "1080x1920 vertical video file" + } + } + ], + "canonical": { + "kind": "video_hosted" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "video_1080x1080" + }, + "name": "Square Video - 1080x1080", + "description": "1080x1080 square video (1:1) for social feeds", + "type": "video", + "renders": [ + { + "dimensions": { + "height": 1080, + "responsive": { + "height": false, + "width": false + }, + "width": 1080 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "video_file", + "required": true, + "asset_type": "video", + "requirements": { + "width": 1080, + "height": 1080, + "containers": [ + "mp4", + "mov", + "webm" + ], + "description": "1080x1080 square video file" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING", + "VIDEO_ID", + "POD_POSITION", + "CONTENT_GENRE" + ], + "assets_required": [ + { + "asset_id": "video_file", + "item_type": "individual", + "required": true, + "asset_type": "video", + "requirements": { + "width": 1080, + "height": 1080, + "containers": [ + "mp4", + "mov", + "webm" + ], + "description": "1080x1080 square video file" + } + } + ], + "canonical": { + "kind": "video_hosted" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "video_ctv_preroll_30s" + }, + "name": "CTV Pre-Roll - 30 seconds", + "description": "30-second pre-roll ad for Connected TV and streaming platforms", + "type": "video", + "assets": [ + { + "item_type": "individual", + "asset_id": "video_file", + "required": true, + "asset_type": "video", + "requirements": { + "min_duration_ms": 30000, + "max_duration_ms": 30000, + "containers": [ + "mp4", + "mov" + ], + "description": "30-second CTV-optimized video file (1920x1080 recommended)" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING", + "VIDEO_ID", + "POD_POSITION", + "CONTENT_GENRE", + "PLAYER_SIZE" + ], + "assets_required": [ + { + "asset_id": "video_file", + "item_type": "individual", + "required": true, + "asset_type": "video", + "requirements": { + "min_duration_ms": 30000, + "max_duration_ms": 30000, + "containers": [ + "mp4", + "mov" + ], + "description": "30-second CTV-optimized video file (1920x1080 recommended)" + } + } + ], + "canonical": { + "kind": "video_hosted" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "video_ctv_midroll_30s" + }, + "name": "CTV Mid-Roll - 30 seconds", + "description": "30-second mid-roll ad for Connected TV and streaming platforms", + "type": "video", + "assets": [ + { + "item_type": "individual", + "asset_id": "video_file", + "required": true, + "asset_type": "video", + "requirements": { + "min_duration_ms": 30000, + "max_duration_ms": 30000, + "containers": [ + "mp4", + "mov" + ], + "description": "30-second CTV-optimized video file (1920x1080 recommended)" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING", + "VIDEO_ID", + "POD_POSITION", + "CONTENT_GENRE", + "PLAYER_SIZE" + ], + "assets_required": [ + { + "asset_id": "video_file", + "item_type": "individual", + "required": true, + "asset_type": "video", + "requirements": { + "min_duration_ms": 30000, + "max_duration_ms": 30000, + "containers": [ + "mp4", + "mov" + ], + "description": "30-second CTV-optimized video file (1920x1080 recommended)" + } + } + ], + "canonical": { + "kind": "video_hosted" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "broadcast_spot_15s" + }, + "name": "Broadcast TV Spot - 15 seconds", + "description": "15-second broadcast television spot. H.264 HD video file delivered directly to station \u2014 no VAST, no impression tracking, no clickthrough.", + "type": "video", + "assets": [ + { + "item_type": "individual", + "asset_id": "video_file", + "required": true, + "asset_type": "video", + "requirements": { + "min_duration_ms": 15000, + "max_duration_ms": 15000, + "containers": [ + "mp4", + "mov" + ], + "codecs": [ + "h264" + ], + "min_width": 1920, + "max_width": 1920, + "min_height": 1080, + "max_height": 1080, + "min_bitrate_kbps": 15000, + "frame_rates": [ + 29.97, + 30 + ], + "frame_rate_type": "constant", + "scan_type": "progressive", + "gop_type": "closed", + "min_gop_interval_seconds": 1, + "max_gop_interval_seconds": 2, + "audio_required": true, + "audio_codecs": [ + "aac", + "pcm" + ], + "audio_sample_rates": [ + 48000 + ], + "audio_channels": [ + "stereo" + ], + "loudness_lufs": -24, + "loudness_tolerance_db": 2, + "true_peak_dbfs": -2, + "description": "15-second broadcast spot file (1920x1080 HD)" + } + }, + { + "item_type": "individual", + "asset_id": "captions_file", + "required": false, + "asset_type": "url", + "requirements": { + "description": "Closed captions file (SRT or WebVTT)" + } + } + ], + "assets_required": [ + { + "asset_id": "video_file", + "item_type": "individual", + "required": true, + "asset_type": "video", + "requirements": { + "min_duration_ms": 15000, + "max_duration_ms": 15000, + "containers": [ + "mp4", + "mov" + ], + "codecs": [ + "h264" + ], + "description": "15-second broadcast spot file (1920x1080 HD)" + } + } + ], + "canonical": { + "kind": "video_hosted" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "broadcast_spot_30s" + }, + "name": "Broadcast TV Spot - 30 seconds", + "description": "30-second broadcast television spot. H.264 HD video file delivered directly to station \u2014 no VAST, no impression tracking, no clickthrough.", + "type": "video", + "assets": [ + { + "item_type": "individual", + "asset_id": "video_file", + "required": true, + "asset_type": "video", + "requirements": { + "min_duration_ms": 30000, + "max_duration_ms": 30000, + "containers": [ + "mp4", + "mov" + ], + "codecs": [ + "h264" + ], + "min_width": 1920, + "max_width": 1920, + "min_height": 1080, + "max_height": 1080, + "min_bitrate_kbps": 15000, + "frame_rates": [ + 29.97, + 30 + ], + "frame_rate_type": "constant", + "scan_type": "progressive", + "gop_type": "closed", + "min_gop_interval_seconds": 1, + "max_gop_interval_seconds": 2, + "audio_required": true, + "audio_codecs": [ + "aac", + "pcm" + ], + "audio_sample_rates": [ + 48000 + ], + "audio_channels": [ + "stereo" + ], + "loudness_lufs": -24, + "loudness_tolerance_db": 2, + "true_peak_dbfs": -2, + "description": "30-second broadcast spot file (1920x1080 HD)" + } + }, + { + "item_type": "individual", + "asset_id": "captions_file", + "required": false, + "asset_type": "url", + "requirements": { + "description": "Closed captions file (SRT or WebVTT)" + } + } + ], + "assets_required": [ + { + "asset_id": "video_file", + "item_type": "individual", + "required": true, + "asset_type": "video", + "requirements": { + "min_duration_ms": 30000, + "max_duration_ms": 30000, + "containers": [ + "mp4", + "mov" + ], + "codecs": [ + "h264" + ], + "description": "30-second broadcast spot file (1920x1080 HD)" + } + } + ], + "canonical": { + "kind": "video_hosted" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "broadcast_spot_60s" + }, + "name": "Broadcast TV Spot - 60 seconds", + "description": "60-second broadcast television spot. H.264 HD video file delivered directly to station \u2014 no VAST, no impression tracking, no clickthrough.", + "type": "video", + "assets": [ + { + "item_type": "individual", + "asset_id": "video_file", + "required": true, + "asset_type": "video", + "requirements": { + "min_duration_ms": 60000, + "max_duration_ms": 60000, + "containers": [ + "mp4", + "mov" + ], + "codecs": [ + "h264" + ], + "min_width": 1920, + "max_width": 1920, + "min_height": 1080, + "max_height": 1080, + "min_bitrate_kbps": 15000, + "frame_rates": [ + 29.97, + 30 + ], + "frame_rate_type": "constant", + "scan_type": "progressive", + "gop_type": "closed", + "min_gop_interval_seconds": 1, + "max_gop_interval_seconds": 2, + "audio_required": true, + "audio_codecs": [ + "aac", + "pcm" + ], + "audio_sample_rates": [ + 48000 + ], + "audio_channels": [ + "stereo" + ], + "loudness_lufs": -24, + "loudness_tolerance_db": 2, + "true_peak_dbfs": -2, + "description": "60-second broadcast spot file (1920x1080 HD)" + } + }, + { + "item_type": "individual", + "asset_id": "captions_file", + "required": false, + "asset_type": "url", + "requirements": { + "description": "Closed captions file (SRT or WebVTT)" + } + } + ], + "assets_required": [ + { + "asset_id": "video_file", + "item_type": "individual", + "required": true, + "asset_type": "video", + "requirements": { + "min_duration_ms": 60000, + "max_duration_ms": 60000, + "containers": [ + "mp4", + "mov" + ], + "codecs": [ + "h264" + ], + "description": "60-second broadcast spot file (1920x1080 HD)" + } + } + ], + "canonical": { + "kind": "video_hosted" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_image" + }, + "name": "Display Banner - Image", + "description": "Static image banner (supports any dimensions)", + "type": "display", + "accepts_parameters": [ + "dimensions" + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "banner_image", + "required": true, + "asset_type": "image", + "requirements": { + "containers": [ + "jpg", + "png", + "gif", + "webp" + ] + } + }, + { + "item_type": "individual", + "asset_id": "click_url", + "required": true, + "asset_type": "url", + "requirements": { + "url_type": "clickthrough", + "description": "Clickthrough destination URL" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "assets_required": [ + { + "asset_id": "banner_image", + "item_type": "individual", + "required": true, + "asset_type": "image", + "requirements": { + "containers": [ + "jpg", + "png", + "gif", + "webp" + ] + } + }, + { + "asset_id": "click_url", + "item_type": "individual", + "required": true, + "asset_type": "url", + "requirements": { + "url_type": "clickthrough", + "description": "Clickthrough destination URL" + } + } + ], + "canonical": { + "kind": "image" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_300x250_image" + }, + "name": "Medium Rectangle - Image", + "description": "300x250 static image banner", + "type": "display", + "renders": [ + { + "dimensions": { + "height": 250, + "responsive": { + "height": false, + "width": false + }, + "width": 300 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "banner_image", + "required": true, + "asset_type": "image", + "requirements": { + "width": 300, + "height": 250, + "max_file_size_mb": 0.2, + "containers": [ + "jpg", + "png", + "gif", + "webp" + ] + } + }, + { + "item_type": "individual", + "asset_id": "click_url", + "required": true, + "asset_type": "url", + "requirements": { + "url_type": "clickthrough", + "description": "Clickthrough destination URL" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "assets_required": [ + { + "asset_id": "banner_image", + "item_type": "individual", + "required": true, + "asset_type": "image", + "requirements": { + "width": 300, + "height": 250, + "max_file_size_mb": 0.2, + "containers": [ + "jpg", + "png", + "gif", + "webp" + ] + } + }, + { + "asset_id": "click_url", + "item_type": "individual", + "required": true, + "asset_type": "url", + "requirements": { + "url_type": "clickthrough", + "description": "Clickthrough destination URL" + } + } + ], + "canonical": { + "kind": "image" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_728x90_image" + }, + "name": "Leaderboard - Image", + "description": "728x90 static image banner", + "type": "display", + "renders": [ + { + "dimensions": { + "height": 90, + "responsive": { + "height": false, + "width": false + }, + "width": 728 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "banner_image", + "required": true, + "asset_type": "image", + "requirements": { + "width": 728, + "height": 90, + "max_file_size_mb": 0.15, + "containers": [ + "jpg", + "png", + "gif", + "webp" + ] + } + }, + { + "item_type": "individual", + "asset_id": "click_url", + "required": true, + "asset_type": "url", + "requirements": { + "url_type": "clickthrough", + "description": "Clickthrough destination URL" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "assets_required": [ + { + "asset_id": "banner_image", + "item_type": "individual", + "required": true, + "asset_type": "image", + "requirements": { + "width": 728, + "height": 90, + "max_file_size_mb": 0.15, + "containers": [ + "jpg", + "png", + "gif", + "webp" + ] + } + }, + { + "asset_id": "click_url", + "item_type": "individual", + "required": true, + "asset_type": "url", + "requirements": { + "url_type": "clickthrough", + "description": "Clickthrough destination URL" + } + } + ], + "canonical": { + "kind": "image" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_320x50_image" + }, + "name": "Mobile Banner - Image", + "description": "320x50 mobile banner", + "type": "display", + "renders": [ + { + "dimensions": { + "height": 50, + "responsive": { + "height": false, + "width": false + }, + "width": 320 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "banner_image", + "required": true, + "asset_type": "image", + "requirements": { + "width": 320, + "height": 50, + "max_file_size_mb": 0.05, + "containers": [ + "jpg", + "png", + "gif", + "webp" + ] + } + }, + { + "item_type": "individual", + "asset_id": "click_url", + "required": true, + "asset_type": "url", + "requirements": { + "url_type": "clickthrough", + "description": "Clickthrough destination URL" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "assets_required": [ + { + "asset_id": "banner_image", + "item_type": "individual", + "required": true, + "asset_type": "image", + "requirements": { + "width": 320, + "height": 50, + "max_file_size_mb": 0.05, + "containers": [ + "jpg", + "png", + "gif", + "webp" + ] + } + }, + { + "asset_id": "click_url", + "item_type": "individual", + "required": true, + "asset_type": "url", + "requirements": { + "url_type": "clickthrough", + "description": "Clickthrough destination URL" + } + } + ], + "canonical": { + "kind": "image" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_160x600_image" + }, + "name": "Wide Skyscraper - Image", + "description": "160x600 wide skyscraper banner", + "type": "display", + "renders": [ + { + "dimensions": { + "height": 600, + "responsive": { + "height": false, + "width": false + }, + "width": 160 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "banner_image", + "required": true, + "asset_type": "image", + "requirements": { + "width": 160, + "height": 600, + "max_file_size_mb": 0.15, + "containers": [ + "jpg", + "png", + "gif", + "webp" + ] + } + }, + { + "item_type": "individual", + "asset_id": "click_url", + "required": true, + "asset_type": "url", + "requirements": { + "url_type": "clickthrough", + "description": "Clickthrough destination URL" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "assets_required": [ + { + "asset_id": "banner_image", + "item_type": "individual", + "required": true, + "asset_type": "image", + "requirements": { + "width": 160, + "height": 600, + "max_file_size_mb": 0.15, + "containers": [ + "jpg", + "png", + "gif", + "webp" + ] + } + }, + { + "asset_id": "click_url", + "item_type": "individual", + "required": true, + "asset_type": "url", + "requirements": { + "url_type": "clickthrough", + "description": "Clickthrough destination URL" + } + } + ], + "canonical": { + "kind": "image" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_336x280_image" + }, + "name": "Large Rectangle - Image", + "description": "336x280 large rectangle banner", + "type": "display", + "renders": [ + { + "dimensions": { + "height": 280, + "responsive": { + "height": false, + "width": false + }, + "width": 336 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "banner_image", + "required": true, + "asset_type": "image", + "requirements": { + "width": 336, + "height": 280, + "max_file_size_mb": 0.25, + "containers": [ + "jpg", + "png", + "gif", + "webp" + ] + } + }, + { + "item_type": "individual", + "asset_id": "click_url", + "required": true, + "asset_type": "url", + "requirements": { + "url_type": "clickthrough", + "description": "Clickthrough destination URL" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "assets_required": [ + { + "asset_id": "banner_image", + "item_type": "individual", + "required": true, + "asset_type": "image", + "requirements": { + "width": 336, + "height": 280, + "max_file_size_mb": 0.25, + "containers": [ + "jpg", + "png", + "gif", + "webp" + ] + } + }, + { + "asset_id": "click_url", + "item_type": "individual", + "required": true, + "asset_type": "url", + "requirements": { + "url_type": "clickthrough", + "description": "Clickthrough destination URL" + } + } + ], + "canonical": { + "kind": "image" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_300x600_image" + }, + "name": "Half Page - Image", + "description": "300x600 half page banner", + "type": "display", + "renders": [ + { + "dimensions": { + "height": 600, + "responsive": { + "height": false, + "width": false + }, + "width": 300 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "banner_image", + "required": true, + "asset_type": "image", + "requirements": { + "width": 300, + "height": 600, + "max_file_size_mb": 0.3, + "containers": [ + "jpg", + "png", + "gif", + "webp" + ] + } + }, + { + "item_type": "individual", + "asset_id": "click_url", + "required": true, + "asset_type": "url", + "requirements": { + "url_type": "clickthrough", + "description": "Clickthrough destination URL" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "assets_required": [ + { + "asset_id": "banner_image", + "item_type": "individual", + "required": true, + "asset_type": "image", + "requirements": { + "width": 300, + "height": 600, + "max_file_size_mb": 0.3, + "containers": [ + "jpg", + "png", + "gif", + "webp" + ] + } + }, + { + "asset_id": "click_url", + "item_type": "individual", + "required": true, + "asset_type": "url", + "requirements": { + "url_type": "clickthrough", + "description": "Clickthrough destination URL" + } + } + ], + "canonical": { + "kind": "image" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_970x250_image" + }, + "name": "Billboard - Image", + "description": "970x250 billboard banner", + "type": "display", + "renders": [ + { + "dimensions": { + "height": 250, + "responsive": { + "height": false, + "width": false + }, + "width": 970 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "banner_image", + "required": true, + "asset_type": "image", + "requirements": { + "width": 970, + "height": 250, + "max_file_size_mb": 0.4, + "containers": [ + "jpg", + "png", + "gif", + "webp" + ] + } + }, + { + "item_type": "individual", + "asset_id": "click_url", + "required": true, + "asset_type": "url", + "requirements": { + "url_type": "clickthrough", + "description": "Clickthrough destination URL" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "assets_required": [ + { + "asset_id": "banner_image", + "item_type": "individual", + "required": true, + "asset_type": "image", + "requirements": { + "width": 970, + "height": 250, + "max_file_size_mb": 0.4, + "containers": [ + "jpg", + "png", + "gif", + "webp" + ] + } + }, + { + "asset_id": "click_url", + "item_type": "individual", + "required": true, + "asset_type": "url", + "requirements": { + "url_type": "clickthrough", + "description": "Clickthrough destination URL" + } + } + ], + "canonical": { + "kind": "image" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_html" + }, + "name": "Display Banner - HTML5", + "description": "HTML5 creative (supports any dimensions)", + "type": "display", + "accepts_parameters": [ + "dimensions" + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "html_creative", + "required": true, + "asset_type": "html", + "requirements": { + "sandbox": "none", + "max_file_size_mb": 0.5 + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "assets_required": [ + { + "asset_id": "html_creative", + "item_type": "individual", + "required": true, + "asset_type": "html", + "requirements": { + "sandbox": "none", + "max_file_size_mb": 0.5 + } + } + ], + "canonical": { + "kind": "html5" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_300x250_html" + }, + "name": "Medium Rectangle - HTML5", + "description": "300x250 HTML5 creative", + "type": "display", + "renders": [ + { + "dimensions": { + "height": 250, + "responsive": { + "height": false, + "width": false + }, + "width": 300 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "html_creative", + "required": true, + "asset_type": "html", + "requirements": { + "sandbox": "none", + "width": 300, + "height": 250, + "max_file_size_mb": 0.5, + "description": "HTML5 creative code" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "assets_required": [ + { + "asset_id": "html_creative", + "item_type": "individual", + "required": true, + "asset_type": "html", + "requirements": { + "sandbox": "none", + "width": 300, + "height": 250, + "max_file_size_mb": 0.5, + "description": "HTML5 creative code" + } + } + ], + "canonical": { + "kind": "html5" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_728x90_html" + }, + "name": "Leaderboard - HTML5", + "description": "728x90 HTML5 creative", + "type": "display", + "renders": [ + { + "dimensions": { + "height": 90, + "responsive": { + "height": false, + "width": false + }, + "width": 728 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "html_creative", + "required": true, + "asset_type": "html", + "requirements": { + "sandbox": "none", + "width": 728, + "height": 90, + "max_file_size_mb": 0.5 + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "assets_required": [ + { + "asset_id": "html_creative", + "item_type": "individual", + "required": true, + "asset_type": "html", + "requirements": { + "sandbox": "none", + "width": 728, + "height": 90, + "max_file_size_mb": 0.5 + } + } + ], + "canonical": { + "kind": "html5" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_160x600_html" + }, + "name": "Wide Skyscraper - HTML5", + "description": "160x600 HTML5 creative", + "type": "display", + "renders": [ + { + "dimensions": { + "height": 600, + "responsive": { + "height": false, + "width": false + }, + "width": 160 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "html_creative", + "required": true, + "asset_type": "html", + "requirements": { + "sandbox": "none", + "width": 160, + "height": 600, + "max_file_size_mb": 0.5 + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "assets_required": [ + { + "asset_id": "html_creative", + "item_type": "individual", + "required": true, + "asset_type": "html", + "requirements": { + "sandbox": "none", + "width": 160, + "height": 600, + "max_file_size_mb": 0.5 + } + } + ], + "canonical": { + "kind": "html5" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_336x280_html" + }, + "name": "Large Rectangle - HTML5", + "description": "336x280 HTML5 creative", + "type": "display", + "renders": [ + { + "dimensions": { + "height": 280, + "responsive": { + "height": false, + "width": false + }, + "width": 336 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "html_creative", + "required": true, + "asset_type": "html", + "requirements": { + "sandbox": "none", + "width": 336, + "height": 280, + "max_file_size_mb": 0.5 + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "assets_required": [ + { + "asset_id": "html_creative", + "item_type": "individual", + "required": true, + "asset_type": "html", + "requirements": { + "sandbox": "none", + "width": 336, + "height": 280, + "max_file_size_mb": 0.5 + } + } + ], + "canonical": { + "kind": "html5" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_300x600_html" + }, + "name": "Half Page - HTML5", + "description": "300x600 HTML5 creative", + "type": "display", + "renders": [ + { + "dimensions": { + "height": 600, + "responsive": { + "height": false, + "width": false + }, + "width": 300 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "html_creative", + "required": true, + "asset_type": "html", + "requirements": { + "sandbox": "none", + "width": 300, + "height": 600, + "max_file_size_mb": 0.5 + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "assets_required": [ + { + "asset_id": "html_creative", + "item_type": "individual", + "required": true, + "asset_type": "html", + "requirements": { + "sandbox": "none", + "width": 300, + "height": 600, + "max_file_size_mb": 0.5 + } + } + ], + "canonical": { + "kind": "html5" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_970x250_html" + }, + "name": "Billboard - HTML5", + "description": "970x250 HTML5 creative", + "type": "display", + "renders": [ + { + "dimensions": { + "height": 250, + "responsive": { + "height": false, + "width": false + }, + "width": 970 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "html_creative", + "required": true, + "asset_type": "html", + "requirements": { + "sandbox": "none", + "width": 970, + "height": 250, + "max_file_size_mb": 0.5 + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "assets_required": [ + { + "asset_id": "html_creative", + "item_type": "individual", + "required": true, + "asset_type": "html", + "requirements": { + "sandbox": "none", + "width": 970, + "height": 250, + "max_file_size_mb": 0.5 + } + } + ], + "canonical": { + "kind": "html5" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "display_js" + }, + "name": "Display Banner - JavaScript", + "description": "JavaScript-based display ad (supports any dimensions)", + "type": "display", + "accepts_parameters": [ + "dimensions" + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "js_creative", + "required": true, + "asset_type": "javascript" + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "assets_required": [ + { + "asset_id": "js_creative", + "item_type": "individual", + "required": true, + "asset_type": "javascript" + } + ], + "canonical": { + "kind": "display_tag" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "native_standard" + }, + "name": "IAB Native Standard", + "description": "Standard native ad with title, description, image, and CTA", + "type": "native", + "assets": [ + { + "item_type": "individual", + "asset_id": "title", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Headline text (25 chars recommended)" + } + }, + { + "item_type": "individual", + "asset_id": "description", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Body copy (90 chars recommended)" + } + }, + { + "item_type": "individual", + "asset_id": "main_image", + "required": true, + "asset_type": "image", + "requirements": { + "description": "Primary image (1200x627 recommended)" + } + }, + { + "item_type": "individual", + "asset_id": "icon", + "required": false, + "asset_type": "image", + "requirements": { + "description": "Brand icon (square, 200x200 recommended)" + } + }, + { + "item_type": "individual", + "asset_id": "cta_text", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Call-to-action text" + } + }, + { + "item_type": "individual", + "asset_id": "sponsored_by", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Advertiser name for disclosure" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "assets_required": [ + { + "asset_id": "title", + "item_type": "individual", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Headline text (25 chars recommended)" + } + }, + { + "asset_id": "description", + "item_type": "individual", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Body copy (90 chars recommended)" + } + }, + { + "asset_id": "main_image", + "item_type": "individual", + "required": true, + "asset_type": "image", + "requirements": { + "description": "Primary image (1200x627 recommended)" + } + }, + { + "asset_id": "cta_text", + "item_type": "individual", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Call-to-action text" + } + }, + { + "asset_id": "sponsored_by", + "item_type": "individual", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Advertiser name for disclosure" + } + } + ], + "canonical": { + "kind": "image", + "slots_override": [ + { + "asset_group_id": "image_main", + "asset_type": "image", + "required": true + }, + { + "asset_group_id": "headline", + "asset_type": "text", + "required": true + }, + { + "asset_group_id": "body_text", + "asset_type": "text", + "required": true + }, + { + "asset_group_id": "cta", + "asset_type": "text", + "required": true + }, + { + "asset_group_id": "brand_name", + "asset_type": "text", + "required": true + }, + { + "asset_group_id": "impression_tracker", + "asset_type": "pixel_tracker", + "required": false + }, + { + "asset_group_id": "click_tracker", + "asset_type": "pixel_tracker", + "required": false + } + ], + "asset_source": "buyer_uploaded" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "native_content" + }, + "name": "Native Content Placement", + "description": "In-article native ad with editorial styling", + "type": "native", + "assets": [ + { + "item_type": "individual", + "asset_id": "headline", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Editorial-style headline (60 chars recommended)" + } + }, + { + "item_type": "individual", + "asset_id": "body", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Article-style body copy (200 chars recommended)" + } + }, + { + "item_type": "individual", + "asset_id": "thumbnail", + "required": true, + "asset_type": "image", + "requirements": { + "description": "Thumbnail image (square, 300x300 recommended)" + } + }, + { + "item_type": "individual", + "asset_id": "author", + "required": false, + "asset_type": "text", + "requirements": { + "description": "Author name for editorial context" + } + }, + { + "item_type": "individual", + "asset_id": "click_url", + "required": true, + "asset_type": "url", + "requirements": { + "url_type": "clickthrough", + "description": "Clickthrough destination URL" + } + }, + { + "item_type": "individual", + "asset_id": "disclosure", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Sponsored content disclosure text" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING" + ], + "assets_required": [ + { + "asset_id": "headline", + "item_type": "individual", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Editorial-style headline (60 chars recommended)" + } + }, + { + "asset_id": "body", + "item_type": "individual", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Article-style body copy (200 chars recommended)" + } + }, + { + "asset_id": "thumbnail", + "item_type": "individual", + "required": true, + "asset_type": "image", + "requirements": { + "description": "Thumbnail image (square, 300x300 recommended)" + } + }, + { + "asset_id": "click_url", + "item_type": "individual", + "required": true, + "asset_type": "url", + "requirements": { + "url_type": "clickthrough", + "description": "Clickthrough destination URL" + } + }, + { + "asset_id": "disclosure", + "item_type": "individual", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Sponsored content disclosure text" + } + } + ], + "canonical": { + "kind": "image", + "slots_override": [ + { + "asset_group_id": "image_main", + "asset_type": "image", + "required": true + }, + { + "asset_group_id": "headline", + "asset_type": "text", + "required": true + }, + { + "asset_group_id": "body_text", + "asset_type": "text", + "required": true + }, + { + "asset_group_id": "landing_page_url", + "asset_type": "url", + "required": true + }, + { + "asset_group_id": "disclosure", + "asset_type": "text", + "required": true + }, + { + "asset_group_id": "impression_tracker", + "asset_type": "pixel_tracker", + "required": false + }, + { + "asset_group_id": "click_tracker", + "asset_type": "pixel_tracker", + "required": false + } + ], + "asset_source": "buyer_uploaded" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "audio_standard_15s" + }, + "name": "Standard Audio - 15 seconds", + "description": "15-second audio ad", + "type": "audio", + "assets": [ + { + "item_type": "individual", + "asset_id": "audio_file", + "required": true, + "asset_type": "audio", + "requirements": { + "min_duration_ms": 15000, + "max_duration_ms": 15000, + "containers": [ + "mp3", + "aac", + "m4a" + ] + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING", + "CONTENT_GENRE" + ], + "assets_required": [ + { + "asset_id": "audio_file", + "item_type": "individual", + "required": true, + "asset_type": "audio", + "requirements": { + "min_duration_ms": 15000, + "max_duration_ms": 15000, + "containers": [ + "mp3", + "aac", + "m4a" + ] + } + } + ], + "canonical": { + "kind": "audio_hosted" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "audio_standard_30s" + }, + "name": "Standard Audio - 30 seconds", + "description": "30-second audio ad", + "type": "audio", + "assets": [ + { + "item_type": "individual", + "asset_id": "audio_file", + "required": true, + "asset_type": "audio", + "requirements": { + "min_duration_ms": 30000, + "max_duration_ms": 30000, + "containers": [ + "mp3", + "aac", + "m4a" + ] + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING", + "CONTENT_GENRE" + ], + "assets_required": [ + { + "asset_id": "audio_file", + "item_type": "individual", + "required": true, + "asset_type": "audio", + "requirements": { + "min_duration_ms": 30000, + "max_duration_ms": 30000, + "containers": [ + "mp3", + "aac", + "m4a" + ] + } + } + ], + "canonical": { + "kind": "audio_hosted" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "audio_standard_60s" + }, + "name": "Standard Audio - 60 seconds", + "description": "60-second audio ad", + "type": "audio", + "assets": [ + { + "item_type": "individual", + "asset_id": "audio_file", + "required": true, + "asset_type": "audio", + "requirements": { + "min_duration_ms": 60000, + "max_duration_ms": 60000, + "containers": [ + "mp3", + "aac", + "m4a" + ] + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING", + "CONTENT_GENRE" + ], + "assets_required": [ + { + "asset_id": "audio_file", + "item_type": "individual", + "required": true, + "asset_type": "audio", + "requirements": { + "min_duration_ms": 60000, + "max_duration_ms": 60000, + "containers": [ + "mp3", + "aac", + "m4a" + ] + } + } + ], + "canonical": { + "kind": "audio_hosted" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "dooh_billboard_1920x1080" + }, + "name": "Digital Billboard - 1920x1080", + "description": "Full HD digital billboard", + "type": "dooh", + "renders": [ + { + "dimensions": { + "height": 1080, + "responsive": { + "height": false, + "width": false + }, + "width": 1920 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "billboard_image", + "required": true, + "asset_type": "image", + "requirements": { + "width": 1920, + "height": 1080, + "containers": [ + "jpg", + "png" + ] + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING", + "SCREEN_ID", + "VENUE_TYPE", + "VENUE_LAT", + "VENUE_LONG" + ], + "assets_required": [ + { + "asset_id": "billboard_image", + "item_type": "individual", + "required": true, + "asset_type": "image", + "requirements": { + "width": 1920, + "height": 1080, + "containers": [ + "jpg", + "png" + ] + } + } + ], + "canonical": { + "kind": "image" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "dooh_billboard_landscape" + }, + "name": "Digital Billboard - Landscape", + "description": "Landscape-oriented digital billboard (various sizes)", + "type": "dooh", + "assets": [ + { + "item_type": "individual", + "asset_id": "billboard_image", + "required": true, + "asset_type": "image", + "requirements": { + "containers": [ + "jpg", + "png" + ], + "description": "Landscape image (1920x1080 or larger)" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING", + "SCREEN_ID", + "VENUE_TYPE", + "VENUE_LAT", + "VENUE_LONG" + ], + "assets_required": [ + { + "asset_id": "billboard_image", + "item_type": "individual", + "required": true, + "asset_type": "image", + "requirements": { + "containers": [ + "jpg", + "png" + ], + "description": "Landscape image (1920x1080 or larger)" + } + } + ], + "canonical": { + "kind": "image" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "dooh_billboard_portrait" + }, + "name": "Digital Billboard - Portrait", + "description": "Portrait-oriented digital billboard (various sizes)", + "type": "dooh", + "assets": [ + { + "item_type": "individual", + "asset_id": "billboard_image", + "required": true, + "asset_type": "image", + "requirements": { + "containers": [ + "jpg", + "png" + ], + "description": "Portrait image (1080x1920 or similar)" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING", + "SCREEN_ID", + "VENUE_TYPE", + "VENUE_LAT", + "VENUE_LONG" + ], + "assets_required": [ + { + "asset_id": "billboard_image", + "item_type": "individual", + "required": true, + "asset_type": "image", + "requirements": { + "containers": [ + "jpg", + "png" + ], + "description": "Portrait image (1080x1920 or similar)" + } + } + ], + "canonical": { + "kind": "image" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "dooh_transit_screen" + }, + "name": "Transit Screen", + "description": "Transit and subway screen displays", + "type": "dooh", + "renders": [ + { + "dimensions": { + "height": 1080, + "responsive": { + "height": false, + "width": false + }, + "width": 1920 + }, + "role": "primary" + } + ], + "assets": [ + { + "item_type": "individual", + "asset_id": "screen_image", + "required": true, + "asset_type": "image", + "requirements": { + "width": 1920, + "height": 1080, + "containers": [ + "jpg", + "png" + ], + "description": "Transit screen content" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "3rd party impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL", + "DEVICE_TYPE", + "GDPR", + "GDPR_CONSENT", + "US_PRIVACY", + "GPP_STRING", + "SCREEN_ID", + "VENUE_TYPE", + "VENUE_LAT", + "VENUE_LONG", + "TRANSIT_LINE" + ], + "assets_required": [ + { + "asset_id": "screen_image", + "item_type": "individual", + "required": true, + "asset_type": "image", + "requirements": { + "width": 1920, + "height": 1080, + "containers": [ + "jpg", + "png" + ], + "description": "Transit screen content" + } + } + ], + "canonical": { + "kind": "image" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "sponsored_recommendation" + }, + "name": "Sponsored Recommendation", + "description": "AI assistant sponsored recommendation woven into the conversation response. The LLM integrates the brand message naturally into its reply.", + "type": "native", + "assets": [ + { + "item_type": "individual", + "asset_id": "headline", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Short headline for the recommendation (50 chars max)" + } + }, + { + "item_type": "individual", + "asset_id": "body", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Body text describing the product or service. The AI assistant may paraphrase this to fit the conversation tone." + } + }, + { + "item_type": "individual", + "asset_id": "cta_text", + "required": false, + "asset_type": "text", + "requirements": { + "description": "Call-to-action text (e.g., 'Shop now', 'Learn more')" + } + }, + { + "item_type": "individual", + "asset_id": "cta_url", + "required": true, + "asset_type": "url", + "requirements": { + "url_type": "clickthrough", + "description": "Destination URL for the recommendation" + } + }, + { + "item_type": "individual", + "asset_id": "image", + "required": false, + "asset_type": "image", + "requirements": { + "description": "Product or brand image (square, 400x400 recommended)" + } + }, + { + "item_type": "individual", + "asset_id": "sponsored_by", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Sponsor attribution text (e.g., 'Sponsored by Meridian Foods')" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "Impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL" + ], + "canonical": { + "kind": "sponsored_placement" + } + }, + { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "native_mention" + }, + "name": "Native Brand Mention", + "description": "Minimal brand mention for AI assistants. A single line referencing the brand, suitable for light integration where a full product card would be intrusive.", + "type": "native", + "assets": [ + { + "item_type": "individual", + "asset_id": "mention_text", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Brand mention text (100 chars max). Should read naturally in conversation context." + } + }, + { + "item_type": "individual", + "asset_id": "cta_url", + "required": true, + "asset_type": "url", + "requirements": { + "url_type": "clickthrough", + "description": "Destination URL if user clicks the mention" + } + }, + { + "item_type": "individual", + "asset_id": "sponsored_by", + "required": true, + "asset_type": "text", + "requirements": { + "description": "Sponsor attribution text" + } + }, + { + "item_type": "individual", + "asset_id": "impression_tracker", + "required": false, + "asset_type": "pixel_tracker", + "requirements": { + "description": "Impression tracking pixel URL" + }, + "event": "impression", + "method": "img" + }, + { + "item_type": "individual", + "asset_id": "viewability_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "viewable_mrc_50", + "method": "img", + "requirements": { + "description": "MRC 50% viewable tracker pixel (1-second viewability for display, 2-seconds with audio for video). Optional \u2014 measurement vendor URLs the seller's renderer fires when the viewable threshold is met." + } + }, + { + "item_type": "individual", + "asset_id": "click_tracker", + "required": false, + "asset_type": "pixel_tracker", + "event": "click", + "method": "img", + "requirements": { + "description": "Click measurement tracker pixel (distinct from landing_page_url, the destination). Optional \u2014 measurement vendor URL fired when the user clicks the creative, in addition to navigating to landing_page_url." + } + } + ], + "supported_macros": [ + "MEDIA_BUY_ID", + "CREATIVE_ID", + "CACHEBUSTER", + "CLICK_URL" + ], + "canonical": { + "kind": "agent_placement" + } + } +] diff --git a/tests/fixtures/canonical/veo_generative_video_15s.json b/tests/fixtures/canonical/veo_generative_video_15s.json new file mode 100644 index 000000000..97f957bc1 --- /dev/null +++ b/tests/fixtures/canonical/veo_generative_video_15s.json @@ -0,0 +1,95 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "veo_generative_video_vertical_15s", + "name": "Veo — 15s Generative Vertical Video", + "description": "Generative video synthesis from a creative_brief plus structured video_brief. Buyer ships a brief (≤500 chars) and a video_brief (3 segments summing to 15s); Veo synthesizes the video. Genuinely nondeterministic — synthesis from in-spec inputs may produce out-of-spec frames; the platform's post-synthesis QA loop validates and reseeds up to N attempts before surfacing output. If the QA loop exhausts, build_creative returns task_failed with synthesis_failed reason. provenance_required: true means every produced asset carries a C2PA provenance manifest attributing synthesis to Veo (not the seller).", + "publisher_properties": [ + { + "publisher_domain": "veo.example", + "selection_type": "all" + } + ], + "channels": [ + "social", + "olv" + ], + "delivery_type": "non_guaranteed", + "pricing_options": [ + { + "pricing_option_id": "cpm_floor", + "pricing_model": "cpm", + "currency": "USD", + "floor_price": 18 + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": [ + "daily" + ], + "expected_delay_minutes": 240, + "timezone": "UTC", + "supports_webhooks": false, + "available_metrics": [ + "impressions", + "clicks", + "spend", + "completion_rate", + "viewability" + ], + "date_range_support": "date_range" + }, + "format_options": [ + { + "format_kind": "video_hosted", + "params": { + "orientation": "vertical", + "aspect_ratio": "9:16", + "duration_ms_exact": 15000, + "min_width": 1080, + "min_height": 1920, + "video_codecs": [ + "h264" + ], + "containers": [ + "mp4" + ], + "frame_rates": [ + 24, + 30 + ], + "asset_source": "agent_synthesized", + "buyer_asset_acceptance": "rejected", + "captions": "recommended", + "composition_model": "deterministic", + "synthesis_nondeterministic": true, + "provenance_required": true, + "production_window_business_days": 0, + "slots": [ + { + "asset_group_id": "creative_brief", + "asset_type": "brief", + "required": true, + "max_chars": 500 + }, + { + "asset_group_id": "video_brief", + "asset_type": "object", + "required": true, + "min": 1, + "max": 5 + }, + { + "asset_group_id": "style_reference", + "asset_type": "image", + "required": false + }, + { + "asset_group_id": "landing_page_url", + "asset_type": "url", + "required": false + } + ] + } + } + ] +} diff --git a/tests/fixtures/canonical/youtube_vast_preroll.json b/tests/fixtures/canonical/youtube_vast_preroll.json new file mode 100644 index 000000000..cf9dfefc0 --- /dev/null +++ b/tests/fixtures/canonical/youtube_vast_preroll.json @@ -0,0 +1,94 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "youtube_vast_preroll_15s_skippable", + "name": "YouTube VAST Pre-roll (15s skippable, In-Stream)", + "description": "VAST tag pre-roll on YouTube In-Stream inventory, 16:9 horizontal, 5-second skippable threshold. Buyer ships a VAST 4.x tag (URL or inline XML); YouTube fires VAST events (impression / quartiles / click / complete / error / skip) inherent to the spec. VPAID 2.0 supported but discouraged \u2014 Google deprecates VPAID in 2026.", + "publisher_properties": [ + { + "publisher_domain": "youtube.com", + "selection_type": "all" + } + ], + "channels": [ + "olv", + "ctv" + ], + "delivery_type": "non_guaranteed", + "pricing_options": [ + { + "pricing_option_id": "cpv_skippable", + "pricing_model": "cpv", + "currency": "USD", + "floor_price": 0.05, + "parameters": { + "view_threshold": { + "duration_seconds": 30 + } + } + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": [ + "daily" + ], + "expected_delay_minutes": 240, + "timezone": "America/Los_Angeles", + "supports_webhooks": false, + "available_metrics": [ + "impressions", + "completed_views", + "spend", + "completion_rate", + "viewability", + "quartile_data" + ], + "date_range_support": "date_range" + }, + "format_options": [ + { + "format_kind": "video_vast", + "v1_format_ref": [ + { + "agent_url": "https://creative.adcontextprotocol.org/", + "id": "video_vast_30s" + } + ], + "params": { + "orientation": "horizontal", + "aspect_ratio": "16:9", + "vast_version": "4.2", + "vpaid_enabled": false, + "simid_supported": false, + "duration_ms_range": [ + 6000, + 30000 + ], + "min_width": 1280, + "min_height": 720, + "linear_required": true, + "skippable_after_ms": 5000, + "max_wrapper_depth": 5, + "ssl_required": true, + "composition_model": "deterministic", + "slots": [ + { + "asset_group_id": "vast_tag", + "asset_type": "vast", + "required": true + }, + { + "asset_group_id": "landing_page_url", + "asset_type": "url", + "required": false + } + ], + "platform_extensions": [ + { + "uri": "https://creative.adcontextprotocol.org/translated/google/extensions/google_universal_ad_id", + "digest": "sha256:b3c5e7f9a1c3e5b7d9f1a3c5e7b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5" + } + ] + } + } + ] +} diff --git a/tests/test_canonical_formats_narrowing.py b/tests/test_canonical_formats_narrowing.py new file mode 100644 index 000000000..1d67cdf19 --- /dev/null +++ b/tests/test_canonical_formats_narrowing.py @@ -0,0 +1,160 @@ +"""``FORMAT_DECLARATION_DIVERGENT`` narrowing check. + +Compares v2 ``params`` against v1 ``requirements`` and verifies the +divergence-detection logic covers the four divergence kinds the +schema-conformance check needs: exceeds-max, below-min, not-subset, +not-equal. +""" + +from __future__ import annotations + +import pytest + +from adcp.canonical_formats import check_narrows, narrowing_advisory +from adcp.canonical_formats.advisory import SDK_ID +from adcp.types import CanonicalFormatKind, ProductFormatDeclaration + +# --------------------------------------------------------------------------- +# check_narrows — no divergence cases +# --------------------------------------------------------------------------- + + +def test_v2_within_v1_caps_does_not_diverge() -> None: + assert ( + check_narrows( + {"max_file_size_kb": 100, "image_formats": ["jpg"]}, + {"max_file_size_kb": 200, "image_formats": ["jpg", "png"]}, + ) + == [] + ) + + +def test_v2_below_v1_minimum_does_diverge() -> None: + divs = check_narrows({"min_width": 100}, {"min_width": 300}) + assert len(divs) == 1 + assert divs[0]["kind"] == "below_min" + assert divs[0]["v1_min"] == 300 + assert divs[0]["v2_value"] == 100 + + +def test_v2_silently_omitting_field_is_not_divergence() -> None: + """v2 omitting a field that v1 declared is "narrows into unconstrained + space" — NOT a divergence per the schema.""" + assert check_narrows({}, {"max_file_size_kb": 200}) == [] + + +def test_v1_omitting_field_is_not_divergence() -> None: + """v1 not declaring a constraint v2 declares is also not a divergence.""" + assert check_narrows({"max_file_size_kb": 100}, {}) == [] + + +# --------------------------------------------------------------------------- +# check_narrows — exceeds_max +# --------------------------------------------------------------------------- + + +def test_exceeds_max_on_named_cap() -> None: + divs = check_narrows({"max_file_size_kb": 500}, {"max_file_size_kb": 200}) + assert len(divs) == 1 + assert divs[0] == { + "field": "max_file_size_kb", + "kind": "exceeds_max", + "v1_max": 200, + "v2_value": 500, + } + + +def test_exceeds_max_on_value_being_capped() -> None: + """v1 says ``max_width=300``, v2 declares ``width=500`` — that's a divergence + (v2 width is being capped by v1.max_width and exceeds it).""" + divs = check_narrows({"width": 500}, {"max_width": 300}) + assert len(divs) == 1 + assert divs[0]["kind"] == "exceeds_max" + assert divs[0]["v1_max"] == 300 + assert divs[0]["v2_value"] == 500 + + +# --------------------------------------------------------------------------- +# check_narrows — not_subset +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "v2,v1,is_div", + [ + # subset → no divergence + (["jpg"], ["jpg", "png"], False), + (["jpg", "png"], ["jpg", "png"], False), + # not subset → divergence + (["jpg", "tiff"], ["jpg", "png"], True), + (["webp"], ["jpg", "png"], True), + ], +) +def test_image_formats_subset_check(v2: list[str], v1: list[str], is_div: bool) -> None: + divs = check_narrows({"image_formats": v2}, {"image_formats": v1}) + assert bool(divs) is is_div + + +# --------------------------------------------------------------------------- +# check_narrows — not_equal +# --------------------------------------------------------------------------- + + +def test_exact_field_disagreement_is_divergence() -> None: + divs = check_narrows({"ssl_required": False}, {"ssl_required": True}) + assert len(divs) == 1 + assert divs[0]["kind"] == "not_equal" + assert divs[0]["v1_value"] is True + assert divs[0]["v2_value"] is False + + +# --------------------------------------------------------------------------- +# narrowing_advisory — emits the wire-shape Error +# --------------------------------------------------------------------------- + + +def test_advisory_is_none_when_narrows() -> None: + d = ProductFormatDeclaration( + format_kind=CanonicalFormatKind.image, + params={"max_file_size_kb": 100}, + ) + assert ( + narrowing_advisory(d, v1_requirements={"max_file_size_kb": 200}, v1_format_id="x") is None + ) + + +def test_advisory_emitted_on_divergence() -> None: + d = ProductFormatDeclaration( + format_kind=CanonicalFormatKind.image, + params={"max_file_size_kb": 500, "image_formats": ["tiff"]}, + ) + a = narrowing_advisory( + d, + v1_requirements={"max_file_size_kb": 200, "image_formats": ["jpg", "png"]}, + v1_format_id="display_300x250_image", + field_path="products[0].format_options[0]", + ) + assert a is not None + assert a.code == "FORMAT_DECLARATION_DIVERGENT" + assert a.source.value == "sdk" + assert a.sdk_id == SDK_ID + assert a.field == "products[0].format_options[0]" + assert a.details["format_kind"] == "image" + assert a.details["v1_format_id"] == "display_300x250_image" + divs = a.details["divergences"] + assert {d["field"] for d in divs} == {"max_file_size_kb", "image_formats"} + + +def test_advisory_tolerates_pydantic_input() -> None: + """``v1_requirements`` may be a Pydantic model; ``check_narrows`` walks it.""" + + class _FakeReq: + def model_dump(self, exclude_none: bool = False) -> dict: + return {"max_file_size_kb": 200} + + d = ProductFormatDeclaration( + format_kind=CanonicalFormatKind.image, + params={"max_file_size_kb": 500}, + ) + a = narrowing_advisory(d, v1_requirements=_FakeReq(), v1_format_id="x") + assert a is not None diff --git a/tests/test_canonical_formats_pixel_tracker.py b/tests/test_canonical_formats_pixel_tracker.py new file mode 100644 index 000000000..4332c2fe0 --- /dev/null +++ b/tests/test_canonical_formats_pixel_tracker.py @@ -0,0 +1,149 @@ +"""Bidirectional ``pixel_tracker`` ↔ v1 url-tracker projection.""" + +from __future__ import annotations + +import pytest + +from adcp.canonical_formats import ( + downgrade_pixel_tracker, + downgrade_pixel_trackers, + upgrade_v1_tracker, + upgrade_v1_trackers, +) +from adcp.canonical_formats.advisory import SDK_ID +from adcp.types import PixelTrackerAsset, PixelTrackerEvent, PixelTrackerMethod + + +def _pt(event: str, method: str = "img", **extra) -> PixelTrackerAsset: + return PixelTrackerAsset( + asset_type="pixel_tracker", + event=event, + method=method, + url="https://x.example/p", + **extra, + ) + + +# --------------------------------------------------------------------------- +# Downgrade — no advisory cases +# --------------------------------------------------------------------------- + + +def test_impression_img_downgrades_with_no_advisory() -> None: + result = downgrade_pixel_tracker(_pt("impression", "img")) + assert result.v1.asset_id == "impression_tracker" + assert result.v1.js_method is False + assert result.advisory is None + + +def test_click_img_downgrades_with_no_advisory() -> None: + result = downgrade_pixel_tracker(_pt("click", "img")) + assert result.v1.asset_id == "click_tracker" + assert result.advisory is None + + +# --------------------------------------------------------------------------- +# Downgrade — LOSSY advisory cases +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "event", + ["viewable_mrc_50", "viewable_mrc_100", "viewable_video_50", "audible_video_complete"], +) +def test_viewability_variants_collapse_with_advisory(event: str) -> None: + result = downgrade_pixel_tracker(_pt(event, "img")) + assert result.v1.asset_id == "viewability_tracker" + assert result.advisory is not None + assert result.advisory.code == "PIXEL_TRACKER_LOSSY_DOWNGRADE" + assert result.advisory.source.value == "sdk" + assert result.advisory.sdk_id == SDK_ID + assert result.advisory.details["source_event"] == event + assert "event" in result.advisory.details["lost"] + + +def test_custom_event_collapses_to_impression_slot_with_advisory() -> None: + pt = _pt("custom", "img", custom_event_name="my_thing") + result = downgrade_pixel_tracker(pt) + assert result.v1.asset_id == "impression_tracker" + assert result.advisory is not None + assert result.advisory.code == "PIXEL_TRACKER_LOSSY_DOWNGRADE" + assert result.advisory.details["source_custom_event_name"] == "my_thing" + + +@pytest.mark.parametrize("event", ["impression", "click", "viewable_mrc_50", "custom"]) +def test_js_method_always_lossy(event: str) -> None: + extra = {"custom_event_name": "x"} if event == "custom" else {} + result = downgrade_pixel_tracker(_pt(event, "js", **extra)) + assert result.advisory is not None + assert result.advisory.code == "PIXEL_TRACKER_LOSSY_DOWNGRADE" + assert result.v1.js_method is True + assert "method_js_execution" in result.advisory.details["lost"] + + +def test_downgrade_batch_deduplicates_advisories() -> None: + """3 viewability pixels of the same kind should yield 1 advisory, not 3.""" + pts = [_pt("viewable_mrc_50", "img") for _ in range(3)] + result = downgrade_pixel_trackers(pts, field_path_prefix="manifest.assets") + assert len(result.items) == 3 + assert len(result.advisories) == 1 + + +def test_downgrade_batch_field_path_is_indexed() -> None: + result = downgrade_pixel_trackers([_pt("viewable_mrc_50")], field_path_prefix="manifest.assets") + assert result.advisories[0].field == "manifest.assets[0]" + + +# --------------------------------------------------------------------------- +# Upgrade — always emits PIXEL_TRACKER_UPGRADE_INFERRED +# --------------------------------------------------------------------------- + + +def test_impression_tracker_upgrades_to_impression_event() -> None: + result = upgrade_v1_tracker(asset_id="impression_tracker", url="https://x/") + assert result.pixel_tracker.event is PixelTrackerEvent.impression + assert result.pixel_tracker.method is PixelTrackerMethod.img + assert result.advisory.code == "PIXEL_TRACKER_UPGRADE_INFERRED" + assert result.advisory.details["inference_basis"] == "asset_id_convention" + + +def test_viewability_tracker_upgrades_to_mrc_50_default() -> None: + result = upgrade_v1_tracker(asset_id="viewability_tracker", url="https://x/") + assert result.pixel_tracker.event is PixelTrackerEvent.viewable_mrc_50 + assert result.advisory.code == "PIXEL_TRACKER_UPGRADE_INFERRED" + + +def test_click_tracker_upgrades_to_click_event() -> None: + result = upgrade_v1_tracker(asset_id="click_tracker", url="https://x/") + assert result.pixel_tracker.event is PixelTrackerEvent.click + + +def test_unknown_asset_id_upgrades_to_custom_with_preserved_name() -> None: + result = upgrade_v1_tracker(asset_id="vendor_xyz_tracker", url="https://x/") + assert result.pixel_tracker.event is PixelTrackerEvent.custom + assert result.pixel_tracker.custom_event_name == "vendor_xyz_tracker" + assert result.advisory.details["inference_basis"] == "fallback_custom_event" + + +def test_upgrade_batch_deduplicates_advisories_per_asset_id() -> None: + v1 = [ + {"asset_id": "impression_tracker", "url": "https://x/1"}, + {"asset_id": "impression_tracker", "url": "https://x/2"}, + {"asset_id": "click_tracker", "url": "https://x/3"}, + ] + result = upgrade_v1_trackers(v1, field_path_prefix="manifest.assets") + assert len(result.items) == 3 + # 2 distinct asset_ids = 2 advisories, not 3 + assert len(result.advisories) == 2 + + +def test_upgrade_batch_skips_malformed_entries() -> None: + """Entries missing ``asset_id`` or ``url`` are skipped (not raised).""" + v1 = [ + {"asset_id": "impression_tracker", "url": "https://x/"}, + {"asset_id": "click_tracker"}, # missing url + {"url": "https://x/2"}, # missing asset_id + {"asset_id": "click_tracker", "url": "https://x/3"}, + ] + result = upgrade_v1_trackers(v1) + assert len(result.items) == 2 # only the two valid entries diff --git a/tests/test_canonical_formats_roundtrip.py b/tests/test_canonical_formats_roundtrip.py new file mode 100644 index 000000000..825976b27 --- /dev/null +++ b/tests/test_canonical_formats_roundtrip.py @@ -0,0 +1,215 @@ +"""Round-trip projection tests against the upstream reference fixtures. + +Vendored from ``adcontextprotocol/adcp`` at +``static/examples/products/canonical/`` (14 fixtures) and +``server/src/creative-agent/reference-formats.json`` (50 v1 catalog +entries). + +Two round-trip shapes are exercised: + +1. **v2 → v1 → declaration-by-id** — Each v2 product fixture's + ``format_options[]`` carries seller-asserted ``v1_format_ref[]``. + Walking the product through :func:`project_product_to_v1` produces + ``format_ids[]``. Each emitted id MUST round-trip back to the + originating declaration via :func:`find_declaration_by_v1_format_id`. + +2. **v1 catalog → v2 declarations** — The vendored + ``v1-reference-formats.json`` has all 50 entries carrying explicit + ``canonical:`` annotations. :func:`project_v1_catalog_to_v2` MUST + project every entry with zero advisories (seller-asserted path). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from adcp.canonical_formats import ( + find_declaration_by_v1_format_id, + project_product_to_v1, + project_v1_catalog_to_v2, +) +from adcp.types import ( + CanonicalFormatKind, + FormatId, + ProductFormatDeclaration, +) + +_FIXTURES = Path(__file__).parent / "fixtures" / "canonical" + +# The 14 v2 product fixtures vendored from +# adcontextprotocol/adcp@main/static/examples/products/canonical/. +_PRODUCT_FIXTURES: tuple[str, ...] = ( + "amazon_sponsored_products.json", + "chatgpt_brand_mention.json", + "gam_3p_display_tag.json", + "google_performance_max.json", + "meta_carousel.json", + "meta_reels_us.json", + "nytimes_homepage_html5.json", + "nytimes_homepage_mrec.json", + "nytimes_homepage_takeover_custom.json", + "taboola_content_recommendation.json", + "the_daily_30s_host_read.json", + "triton_daast_audio_30s.json", + "veo_generative_video_15s.json", + "youtube_vast_preroll.json", +) + + +def _load_product(name: str) -> dict[str, Any]: + """Load a vendored v2 product fixture as a raw dict. + + Kept as a dict (rather than ``Product.model_validate``) because the + fixtures occasionally carry fields the generated Pydantic ``Product`` + rejects strictly (the fixtures are demo data, not always + schema-conformant on every optional field). The projection helpers + duck-type on ``format_options`` + ``product_id`` so the round-trip + works directly on dicts. + """ + return json.loads((_FIXTURES / name).read_text()) + + +def _load_declarations(raw_product: dict[str, Any]) -> list[ProductFormatDeclaration]: + """Pull the typed ``format_options[]`` out of a raw-dict product.""" + return [ + ProductFormatDeclaration.model_validate(opt) + for opt in raw_product.get("format_options", []) + ] + + +class _DuckProduct: + """Duck-typed product wrapper for :func:`project_product_to_v1`.""" + + def __init__(self, raw: dict[str, Any], declarations: list[ProductFormatDeclaration]) -> None: + self.product_id = raw.get("product_id") + self.format_options = declarations + + +# --------------------------------------------------------------------------- +# v2 product fixtures — round-trip via v1 inbound lookup +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("fixture_name", _PRODUCT_FIXTURES) +def test_v2_product_v1_outbound_round_trip(fixture_name: str) -> None: + """For each v2 product fixture: + + * Run :func:`project_product_to_v1` to emit the v1 ``format_ids[]``. + * The emitted list MUST be the union of every declaration's + ``v1_format_ref[]`` (preserving order). + * Every emitted ``format_id`` MUST resolve back to a declaration via + :func:`find_declaration_by_v1_format_id`. + * Any advisories emitted MUST be advisories the resolution order + legitimately produces (``LOSSY_MULTI_SIZE`` / + ``DECLARATION_V1_AMBIGUOUS``) — no ``FORMAT_PROJECTION_FAILED`` + and no codes outside the canonical set. + """ + raw = _load_product(fixture_name) + declarations = _load_declarations(raw) + if not declarations: + pytest.skip("fixture has empty format_options") + product = _DuckProduct(raw, declarations) + + result = project_product_to_v1(product, product_index=0) + + # Outbound refs = union of seller-asserted refs across declarations. + expected_refs: list[FormatId] = [] + for d in declarations: + if d.canonical_formats_only: + continue + if d.v1_format_ref: + expected_refs.extend(d.v1_format_ref) + assert result.format_ids == expected_refs, ( + f"{fixture_name}: outbound format_ids didn't equal the union of " + f"declaration v1_format_ref[]" + ) + + # Every emitted ref MUST round-trip back to a declaration. + for ref in result.format_ids: + found = find_declaration_by_v1_format_id(ref, declarations) + assert found is not None, ( + f"{fixture_name}: emitted format_id {ref.id!r} did not " + f"resolve back to any declaration via find_declaration_by_v1_format_id" + ) + + # Advisories MUST be from the canonical set. + allowed_codes = { + "FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE", + "FORMAT_DECLARATION_V1_AMBIGUOUS", + } + for a in result.advisories: + assert a.code in allowed_codes, f"{fixture_name}: unexpected advisory code {a.code!r}" + + +@pytest.mark.parametrize("fixture_name", _PRODUCT_FIXTURES) +def test_v2_product_declarations_are_constructable(fixture_name: str) -> None: + """Every v2 product fixture's ``format_options[]`` MUST be parseable as + typed :class:`ProductFormatDeclaration` instances. + + A regression on the hand-rolled declaration model (e.g., a new + required field, a tightening of the credential-shaped key guard) + would show up here against real-world seller catalogs. + """ + raw = _load_product(fixture_name) + declarations = _load_declarations(raw) + # At least one declaration per product (otherwise the fixture is degenerate). + assert declarations + # Every declaration carries a valid kind from the canonical enum. + for d in declarations: + assert isinstance(d.format_kind, CanonicalFormatKind) + + +# --------------------------------------------------------------------------- +# v1 reference catalog — projection round-trip +# --------------------------------------------------------------------------- + + +def test_v1_reference_catalog_projects_cleanly() -> None: + """Every v1 catalog entry MUST project via the seller-asserted + ``canonical:`` annotation path (resolution-order step 1), with + zero advisories — the catalog is the canonical reference and a + drift here means upstream changed the contract.""" + v1 = json.loads((_FIXTURES / "v1-reference-formats.json").read_text()) + result = project_v1_catalog_to_v2(v1) + assert len(result.declarations) == len(v1) + assert result.advisories == [] + + +def test_v1_reference_catalog_projection_round_trips_format_ids() -> None: + """For each projected v1 catalog entry, the resulting v2 declaration's + ``v1_format_ref[0]`` MUST equal the source v1 ``format_id`` (the + projection threads the source ref back into the declaration so + v2→v1 lookup on the produced declaration finds the source format).""" + v1 = json.loads((_FIXTURES / "v1-reference-formats.json").read_text()) + result = project_v1_catalog_to_v2(v1) + for source, declaration in zip(v1, result.declarations): + assert declaration.v1_format_ref is not None + assert len(declaration.v1_format_ref) == 1 + ref = declaration.v1_format_ref[0] + # Compare on (agent_url, id) — Pydantic AnyUrl may add a trailing + # slash so compare the path-stripped + id form. + assert ref.id == source["format_id"]["id"] + + +def test_v1_reference_catalog_covers_eight_canonical_kinds() -> None: + """Pin the catalog's canonical-kind coverage so a regression that drops + a kind from the reference catalog (or upstream's tagging mistake) + surfaces immediately.""" + v1 = json.loads((_FIXTURES / "v1-reference-formats.json").read_text()) + result = project_v1_catalog_to_v2(v1) + kinds = {d.format_kind for d in result.declarations} + expected = { + CanonicalFormatKind.image, + CanonicalFormatKind.html5, + CanonicalFormatKind.display_tag, + CanonicalFormatKind.video_hosted, + CanonicalFormatKind.video_vast, + CanonicalFormatKind.audio_hosted, + CanonicalFormatKind.sponsored_placement, + CanonicalFormatKind.agent_placement, + } + assert kinds == expected diff --git a/tests/test_canonical_formats_v1_to_v2.py b/tests/test_canonical_formats_v1_to_v2.py new file mode 100644 index 000000000..4fbde046a --- /dev/null +++ b/tests/test_canonical_formats_v1_to_v2.py @@ -0,0 +1,186 @@ +"""v1 → v2 projection — resolution-order behaviour. + +Mirror of :mod:`tests.test_canonical_formats_projection` (the v2 → v1 +suite). Each test maps to a numbered step in the resolution order +documented in :mod:`adcp.canonical_formats.v1_to_v2`. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from adcp.canonical_formats import ( + project_v1_catalog_to_v2, + project_v1_format_to_declaration, +) +from adcp.canonical_formats.advisory import SDK_ID +from adcp.types import CanonicalFormatKind + +_FIXTURES = Path(__file__).parent / "fixtures" / "canonical" + + +# --------------------------------------------------------------------------- +# Step 1 — explicit canonical annotation +# --------------------------------------------------------------------------- + + +def test_explicit_canonical_annotation_wins() -> None: + v1 = { + "format_id": { + "agent_url": "https://creative.adcontextprotocol.org", + "id": "display_300x250_image", + }, + "canonical": {"kind": "image", "asset_source": "buyer_uploaded"}, + "assets": [{"asset_type": "image"}], + } + result = project_v1_format_to_declaration(v1) + assert result.declaration is not None + assert result.declaration.format_kind is CanonicalFormatKind.image + assert result.advisories == [] + # asset_source from canonical threads into params: + assert result.declaration.params.get("asset_source") == "buyer_uploaded" + # v1_format_ref points back at the source: + assert len(result.declaration.v1_format_ref) == 1 + assert result.declaration.v1_format_ref[0].id == "display_300x250_image" + + +def test_explicit_canonical_bypasses_registry_with_no_advisory() -> None: + """Seller-asserted canonical is highest priority — even an asset shape + that would match the registry's video_vast pattern should project to + whatever the seller said.""" + v1 = { + "format_id": {"agent_url": "https://x.example", "id": "weird_format"}, + "canonical": {"kind": "html5"}, + "assets": [{"asset_type": "vast"}, {"asset_type": "video"}], + } + result = project_v1_format_to_declaration(v1) + assert result.declaration is not None + assert result.declaration.format_kind is CanonicalFormatKind.html5 + assert result.advisories == [] + + +def test_slots_override_threads_into_params() -> None: + v1 = { + "format_id": {"agent_url": "https://x.example", "id": "weird_image"}, + "canonical": { + "kind": "image", + "slots_override": [ + {"asset_group_id": "image_main", "asset_type": "image", "required": True}, + {"asset_group_id": "headline", "asset_type": "text", "max_chars": 30}, + ], + }, + } + result = project_v1_format_to_declaration(v1) + assert result.declaration is not None + slots = result.declaration.params.get("slots") + assert isinstance(slots, list) and len(slots) == 2 + assert slots[0]["asset_group_id"] == "image_main" + + +# --------------------------------------------------------------------------- +# Step 3 — registry structural match (no explicit canonical) +# --------------------------------------------------------------------------- + + +def test_structural_match_emits_ambiguous_advisory() -> None: + v1 = { + "format_id": {"agent_url": "https://x.example", "id": "weird_vast_format"}, + "assets": [{"asset_type": "vast"}], + "vast_version": "4.2", + } + result = project_v1_format_to_declaration(v1) + assert result.declaration is not None + assert result.declaration.format_kind is CanonicalFormatKind.video_vast + # Step 4: family-level match always emits AMBIGUOUS + assert len(result.advisories) == 1 + a = result.advisories[0] + assert a.code == "FORMAT_DECLARATION_V1_AMBIGUOUS" + assert a.source.value == "sdk" + assert a.sdk_id == SDK_ID + assert a.details["matched_canonical"] == "video_vast" + assert a.details["match_kind"] == "structural_family" + + +def test_structural_match_carries_registry_params_into_declaration() -> None: + v1 = { + "format_id": {"agent_url": "https://x.example", "id": "iab_mrec"}, + "assets": [{"asset_type": "vast"}], + "vast_versions": ["4.2"], + } + result = project_v1_format_to_declaration(v1) + assert result.declaration is not None + # The registry's video_vast >= 4.0 entry carries parameters.vast_version + assert result.declaration.params.get("vast_version") == "4.2" + + +# --------------------------------------------------------------------------- +# Step 5 — fail closed +# --------------------------------------------------------------------------- + + +def test_format_with_no_canonical_and_no_registry_match_fails_closed() -> None: + v1 = { + "format_id": {"agent_url": "https://x.example", "id": "unknown_format"}, + "assets": [{"asset_type": "exotic_shape"}], + } + result = project_v1_format_to_declaration(v1) + assert result.declaration is None + assert len(result.advisories) == 1 + a = result.advisories[0] + assert a.code == "FORMAT_PROJECTION_FAILED" + assert a.details["resolution_failure"] == "no_registry_match" + assert a.details["v1_format_id"] == "unknown_format" + + +def test_missing_format_id_fails_closed() -> None: + v1 = {"name": "Catalog entry without format_id"} + result = project_v1_format_to_declaration(v1) + assert result.declaration is None + assert len(result.advisories) == 1 + assert result.advisories[0].code == "FORMAT_PROJECTION_FAILED" + assert result.advisories[0].details["resolution_failure"] == "missing_format_id" + + +# --------------------------------------------------------------------------- +# Catalog-level helper +# --------------------------------------------------------------------------- + + +def test_catalog_aggregation_collects_all_results() -> None: + catalog = [ + { + "format_id": {"agent_url": "https://x.example", "id": "fmt_a"}, + "canonical": {"kind": "image"}, + }, + { + "format_id": {"agent_url": "https://x.example", "id": "fmt_b"}, + "assets": [{"asset_type": "vast"}], + "vast_version": "4.2", + }, # ambiguous family + { + "format_id": {"agent_url": "https://x.example", "id": "fmt_c"}, + "assets": [{"asset_type": "exotic"}], + }, # fail-closed + ] + result = project_v1_catalog_to_v2(catalog) + assert len(result.declarations) == 2 # fmt_a + fmt_b + codes = sorted({a.code for a in result.advisories}) + assert codes == ["FORMAT_DECLARATION_V1_AMBIGUOUS", "FORMAT_PROJECTION_FAILED"] + + +# --------------------------------------------------------------------------- +# Full v1 reference catalog (50 entries from upstream) +# --------------------------------------------------------------------------- + + +def test_full_v1_reference_catalog_projects_via_seller_canonical() -> None: + """All 50 entries in the vendored v1 ``reference-formats.json`` carry an + explicit ``canonical:`` annotation, so projection MUST go through step 1 + with zero advisories. This pins the SDK's behaviour against the + upstream reference catalog so any future drift (e.g., an annotation + drop) is immediately visible.""" + v1 = json.loads((_FIXTURES / "v1-reference-formats.json").read_text()) + result = project_v1_catalog_to_v2(v1) + assert len(result.declarations) == len(v1) + assert result.advisories == [] From ef326ac04c5ac7466967f381b4efe2f243c6469f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 24 May 2026 07:23:01 -0400 Subject: [PATCH 2/2] fix(canonical-formats): address expert review on #845 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 must-fix items from code-reviewer / ad-tech-protocol-expert / security-reviewer on the half-2 PR. ## NORMATIVE - **Narrowing field gap.** ``_MAX_FIELDS`` / ``_MIN_FIELDS`` / ``_EXACT_FIELDS`` were missing required fields the canonical-format schemas declare: ``max_file_size_mb``, ``max_bitrate_kbps`` / ``min_bitrate_kbps``, ``max_wrapper_depth``, ``max_cpu_load_percent``, ``max_response_time_ms``, and singular ``vast_version`` / ``daast_version``. Without these, real video/audio/html5 v1↔v2 pairings with divergent declarations silently pass. - **Step-1 registry-params bypass.** ``project_v1_format_to_declaration`` step 1 used to drop the registry's ``parameters`` when a seller annotated only ``kind``. Now step 1 looks up the matching registry glob first and threads its params; the seller annotation wins on ``kind`` / ``slots_override`` / ``asset_source`` but registry defaults fill in everything else. Docstring step numbers re-aligned to the registry's normative numbering. ## SECURITY - **Seller-controlled strings echoed without scrubbing.** Three half-2 paths now use the half-1 ``_echo_identifier`` (128-char cap + control- char escape): - ``upgrade_v1_tracker`` ``source_asset_id`` + message + fallback ``inferred_custom_event_name`` - ``downgrade_pixel_tracker`` ``source_custom_event_name`` - ``narrowing_advisory`` ``v1_format_id`` (message + details) - **Unbounded list echo in narrowing.** Capped at ``_ECHO_SET_CAP=32`` with ``"…N more"`` marker. - **URL scheme gate on pixel-tracker upgrade.** ``upgrade_v1_tracker`` restricted to ``{"http","https"}``; rejected URLs produce ``pixel_tracker=None`` + advisory carrying the scheme. Batch helper drops rejected entries from ``items``. ## CORRECTNESS - Bare ``except Exception`` narrowed to ``pydantic.ValidationError``. - Downgrade dedup key extended to include ``source_custom_event_name`` so distinct custom events don't collapse. - ``bool`` excluded from numeric guard (``True == 1`` corruption). - ``_is_subset`` tolerates unhashable elements via ``in``-based containment fallback. ## ADOPTER ERGONOMICS - ``quiet_inference`` flag on ``upgrade_v1_trackers`` suppresses asset_id-convention advisories for high-volume buyer adopters. Rejections and fallback custom events still fire. - ``V1Tracker`` renamed to ``V1UrlTracker`` to dodge cognitive collision with registry's ``V1Pattern``. - ``tests/fixtures/canonical/VENDOR.md`` documents upstream source paths + refresh procedure. ## TESTS 5219 passed locally (5190 from initial + 29 new across the three new modules). Deferred per Argus framing: - ``projection.py`` → ``v2_to_v1.py`` rename (cosmetic). - Typed ``Divergence`` dataclass (adopter-tuning). - ``adcp.canonical_formats.fixtures`` public module (adopter-tuning). Refs: #741, #845 --- src/adcp/canonical_formats/__init__.py | 4 +- src/adcp/canonical_formats/narrowing.py | 79 ++++++-- src/adcp/canonical_formats/pixel_tracker.py | 169 ++++++++++++++---- src/adcp/canonical_formats/v1_to_v2.py | 102 +++++++---- tests/fixtures/canonical/VENDOR.md | 55 ++++++ tests/test_canonical_formats_narrowing.py | 92 ++++++++++ tests/test_canonical_formats_pixel_tracker.py | 152 ++++++++++++++++ tests/test_canonical_formats_v1_to_v2.py | 40 +++++ 8 files changed, 604 insertions(+), 89 deletions(-) create mode 100644 tests/fixtures/canonical/VENDOR.md diff --git a/src/adcp/canonical_formats/__init__.py b/src/adcp/canonical_formats/__init__.py index 98af8ea18..89f2c0f59 100644 --- a/src/adcp/canonical_formats/__init__.py +++ b/src/adcp/canonical_formats/__init__.py @@ -59,7 +59,7 @@ PixelTrackerBatchResult, PixelTrackerDowngrade, PixelTrackerUpgrade, - V1Tracker, + V1UrlTracker, downgrade_pixel_tracker, downgrade_pixel_trackers, upgrade_v1_tracker, @@ -94,7 +94,7 @@ "SdkAdvisory", "V1CatalogProjection", "V1ToV2Projection", - "V1Tracker", + "V1UrlTracker", "V1_TRANSLATABLE", "V2ToV1Projection", "check_narrows", diff --git a/src/adcp/canonical_formats/narrowing.py b/src/adcp/canonical_formats/narrowing.py index 5b73a917e..03bf287a1 100644 --- a/src/adcp/canonical_formats/narrowing.py +++ b/src/adcp/canonical_formats/narrowing.py @@ -40,32 +40,39 @@ from typing import Any -from adcp.canonical_formats.advisory import make_sdk_advisory +from adcp.canonical_formats.advisory import _echo_identifier, make_sdk_advisory from adcp.types import Error, ProductFormatDeclaration # Field-name pairs declaring (v2 params field, v1 requirements field). # When the names already match (the common case) the v2 lookup is the -# same key. The lists below are exhaustive for the canonical format -# parameter sets but tolerant: a v1 requirement without a v2 mirror -# isn't checked, and vice versa. +# same key. The lists below cover the canonical format parameter sets +# documented under ``schemas/cache//formats/canonical/*.json``; +# missing a field here means the SDK silently skips the narrowing +# check for that field, so additions are governed by spec changes. _MAX_FIELDS: tuple[str, ...] = ( "max_width", "max_height", "max_file_size_kb", + "max_file_size_mb", # video_hosted.json declares size in MB "max_initial_load_kb", "max_polite_load_kb", "max_duration_ms", "max_animation_duration_ms", + "max_bitrate_kbps", # video_hosted.json, audio_hosted.json + "max_wrapper_depth", # video_vast.json, audio_daast.json "max_dpi", "max_redirect_depth", "max_mention_length_chars", "max_mention_duration_ms", + "max_cpu_load_percent", # html5.json + "max_response_time_ms", # display_tag.json, html5.json ) _MIN_FIELDS: tuple[str, ...] = ( "min_width", "min_height", "min_dpi", "min_duration_ms", + "min_bitrate_kbps", # video_hosted.json, audio_hosted.json ) _ENUM_SUBSET_FIELDS: tuple[str, ...] = ( "image_formats", @@ -80,8 +87,15 @@ "aspect_ratio", "orientation", "ssl_required", + "vast_version", # singular form on video_vast.json + "daast_version", # singular form on audio_daast.json ) +# Per-element cap when echoing seller-controlled allowed-set / declared- +# set lists into advisory ``details``. Bounded so a pathological seller +# list doesn't blow up the multi-hop ``errors[]`` payload. +_ECHO_SET_CAP = 32 + def _as_dict(value: Any) -> dict[str, Any]: """Convert a Pydantic model or dict to a plain dict for field access. @@ -100,13 +114,49 @@ def _as_dict(value: Any) -> dict[str, Any]: return {} +def _is_numeric(value: Any) -> bool: + """Numeric per the narrowing check — ``int`` / ``float`` but NOT ``bool``. + + Python treats ``bool`` as an ``int`` subclass, so ``True > 5`` is + valid; a seller declaring ``max_width: True`` would silently pass + ``isinstance(_, (int, float))`` and corrupt the comparison. + Excluding bools is the conservative narrowing semantic. + """ + return isinstance(value, (int, float)) and not isinstance(value, bool) + + def _is_subset(v2: Any, v1: Any) -> bool: - """Return ``True`` iff ``v2`` is a subset of ``v1`` under set semantics.""" + """Return ``True`` iff ``v2`` is a subset of ``v1`` under set semantics. + + Tolerates unhashable elements (e.g., dicts in + ``allowed_card_media_asset_types``) by falling back to ``in``-based + containment. Returns ``True`` (no divergence) when the comparison + can't be performed — better to silently pass than to crash on a + fixture quirk; the narrowing check's job is to surface clear + divergences, not to discover Pydantic edge cases. + """ if not isinstance(v2, (list, tuple, set)): v2 = [v2] if not isinstance(v1, (list, tuple, set)): v1 = [v1] - return set(v2).issubset(set(v1)) + try: + return set(v2).issubset(set(v1)) + except TypeError: + # Unhashable element somewhere — fall back to membership test. + return all(any(item == allowed for allowed in v1) for item in v2) + + +def _echo_set(value: Any) -> list[Any]: + """Cap a list/set value to ``_ECHO_SET_CAP`` items before echoing into details. + + Seller-controlled list lengths are unbounded at the v1 schema + level; capping before echo keeps the multi-hop ``errors[]`` payload + bounded. + """ + items = list(value) if isinstance(value, (list, tuple, set)) else [value] + if len(items) <= _ECHO_SET_CAP: + return items + return items[:_ECHO_SET_CAP] + [f"…{len(items) - _ECHO_SET_CAP} more"] def check_narrows( @@ -140,7 +190,7 @@ def check_narrows( for field_name in _MAX_FIELDS: v1_max = v1.get(field_name) - if not isinstance(v1_max, (int, float)): + if not _is_numeric(v1_max): continue # v2 may carry the cap directly OR the value being capped (e.g., # v1 declares ``max_width`` and v2 declares ``width``). @@ -151,7 +201,7 @@ def check_narrows( continue # The value-being-capped form: v2 ``width`` against v1 ``max_width`` # is a "v2 value MUST be ≤ v1 cap" check. - if isinstance(v2_value, (int, float)) and v2_value > v1_max: + if _is_numeric(v2_value) and v2_value > v1_max: divergences.append( { "field": field_name, @@ -163,14 +213,14 @@ def check_narrows( for field_name in _MIN_FIELDS: v1_min = v1.get(field_name) - if not isinstance(v1_min, (int, float)): + if not _is_numeric(v1_min): continue v2_value = v2.get(field_name) if v2_value is None: v2_value = v2.get(field_name.removeprefix("min_")) if v2_value is None: continue - if isinstance(v2_value, (int, float)) and v2_value < v1_min: + if _is_numeric(v2_value) and v2_value < v1_min: divergences.append( { "field": field_name, @@ -190,8 +240,8 @@ def check_narrows( { "field": field_name, "kind": "not_subset", - "v1_allowed": list(v1_set) if not isinstance(v1_set, list) else v1_set, - "v2_declared": list(v2_set) if not isinstance(v2_set, list) else v2_set, + "v1_allowed": _echo_set(v1_set), + "v2_declared": _echo_set(v2_set), } ) @@ -240,17 +290,18 @@ def narrowing_advisory( divs = check_narrows(declaration.params, v1_requirements) if not divs: return None + safe_id = _echo_identifier(v1_format_id) return make_sdk_advisory( code="FORMAT_DECLARATION_DIVERGENT", message=( f"v2 declaration (format_kind={declaration.format_kind.value!r}) " - f"params do not narrow v1 format {v1_format_id!r} requirements: " + f"params do not narrow v1 format {safe_id!r} requirements: " f"{len(divs)} divergence(s)." ), field=field_path, details={ "format_kind": declaration.format_kind.value, - "v1_format_id": v1_format_id, + "v1_format_id": safe_id, "divergences": divs, }, suggestion=( diff --git a/src/adcp/canonical_formats/pixel_tracker.py b/src/adcp/canonical_formats/pixel_tracker.py index 5c1736dca..97e88ea7b 100644 --- a/src/adcp/canonical_formats/pixel_tracker.py +++ b/src/adcp/canonical_formats/pixel_tracker.py @@ -45,8 +45,9 @@ from dataclasses import dataclass, field from typing import Any +from urllib.parse import urlsplit -from adcp.canonical_formats.advisory import make_sdk_advisory +from adcp.canonical_formats.advisory import _echo_identifier, make_sdk_advisory from adcp.types import Error, PixelTrackerAsset, PixelTrackerEvent, PixelTrackerMethod # v1 conventional asset_id slots — these are the names a v1 catalog uses @@ -57,6 +58,14 @@ _V1_ASSET_ID_VIEWABILITY = "viewability_tracker" _V1_ASSET_ID_CLICK = "click_tracker" +# Schemes the SDK will accept when upgrading a v1 url-tracker to a v2 +# ``PixelTrackerAsset``. Buyer-side adopters consume the upgraded +# manifest in renderer pipelines; ``javascript:``, ``file:``, and +# ``data:`` URLs in tracker slots are operator-poisoning vectors that +# the SDK MUST refuse on the inbound boundary. The advisory carries +# the rejected scheme so consumers can audit the source seller. +_PIXEL_TRACKER_URL_ALLOWED_SCHEMES: frozenset[str] = frozenset({"http", "https"}) + # Events that collapse onto the viewability slot on the v1 side. Together # with the JS-method check and the custom-event check, this set fully @@ -72,7 +81,7 @@ @dataclass -class V1Tracker: +class V1UrlTracker: """v1 wire-shape projection of a single ``pixel_tracker``. Carries the projected ``asset_id`` + ``url`` plus a flag for whether @@ -104,7 +113,7 @@ class V1Tracker: class PixelTrackerDowngrade: """Result of downgrading one ``PixelTrackerAsset`` to v1 wire shape.""" - v1: V1Tracker + v1: V1UrlTracker advisory: Error | None = None @@ -114,9 +123,15 @@ class PixelTrackerUpgrade: The upgrade ALWAYS carries an advisory per the spec — event/method are inferred, not declared. + + ``pixel_tracker`` is ``None`` when the upgrade was rejected (e.g., + the v1 URL used a disallowed scheme like ``javascript:`` or + ``file:``). The advisory carries the rejection reason and the + rejected scheme; consumers MUST treat the v1 entry as opaque and + drop it from the upgraded manifest. """ - pixel_tracker: PixelTrackerAsset + pixel_tracker: PixelTrackerAsset | None advisory: Error @@ -183,7 +198,7 @@ def downgrade_pixel_tracker( js = method is PixelTrackerMethod.js custom_name = pixel.custom_event_name if hasattr(pixel, "custom_event_name") else None - v1 = V1Tracker(asset_id=_downgrade_slot(event), url=url, js_method=js) + v1 = V1UrlTracker(asset_id=_downgrade_slot(event), url=url, js_method=js) # Determine whether this downgrade is lossy per the spec table. is_lossy_event = event in _VIEWABILITY_EVENTS or event is PixelTrackerEvent.custom @@ -198,7 +213,10 @@ def downgrade_pixel_tracker( "v1_asset_id": v1.asset_id, } if custom_name is not None: - details["source_custom_event_name"] = custom_name + # ``custom_event_name`` is buyer-controlled and unbounded at the + # Pydantic level — cap + scrub before echoing into multi-hop + # ``errors[]`` per the half-1 ``_echo_identifier`` pattern. + details["source_custom_event_name"] = _echo_identifier(custom_name) lost_axes: list[str] = [] if is_lossy_event: @@ -240,6 +258,15 @@ def downgrade_pixel_tracker( } +def _url_scheme(url: str) -> str | None: + """Extract the lowercase URL scheme; ``None`` on malformed input.""" + try: + scheme = urlsplit(url).scheme + except ValueError: + return None + return scheme.lower() if scheme else None + + def upgrade_v1_tracker( *, asset_id: str, @@ -248,11 +275,17 @@ def upgrade_v1_tracker( ) -> PixelTrackerUpgrade: """Project a v1 ``{asset_type: url, url_type: tracker_pixel}`` to v2. - ALWAYS emits ``PIXEL_TRACKER_UPGRADE_INFERRED`` — the v1 wire shape - carries no explicit event/method, so the inferred values are an - SDK convention, not a wire fact. Consumers reading the advisory - can decide whether to trust the convention or treat the pixel as - opaque. + ALWAYS emits ``PIXEL_TRACKER_UPGRADE_INFERRED`` for accepted entries + — the v1 wire shape carries no explicit event/method, so the + inferred values are an SDK convention, not a wire fact. Consumers + reading the advisory can decide whether to trust the convention or + treat the pixel as opaque. + + Rejects URLs whose scheme is outside the SDK's allowlist + (currently ``http``/``https``). Disallowed schemes get a + ``pixel_tracker=None`` result + an advisory carrying the rejected + scheme; callers MUST drop the source entry rather than substitute + a value. Args: asset_id: v1 ``asset_id`` of the tracker slot (e.g., @@ -261,44 +294,81 @@ def upgrade_v1_tracker( field_path: Optional JSONPath-lite pointer for the emitted advisory's ``field``. """ + # --- URL scheme gate --- + scheme = _url_scheme(url) + if scheme not in _PIXEL_TRACKER_URL_ALLOWED_SCHEMES: + return PixelTrackerUpgrade( + pixel_tracker=None, + advisory=make_sdk_advisory( + code="PIXEL_TRACKER_UPGRADE_INFERRED", + message=( + f"v1 url-tracker asset_id={_echo_identifier(asset_id)!r} " + f"REJECTED: scheme {scheme!r} not in allowed set " + f"{sorted(_PIXEL_TRACKER_URL_ALLOWED_SCHEMES)!r}." + ), + field=field_path, + details={ + "source_asset_id": _echo_identifier(asset_id), + "rejected_scheme": scheme, + "inference_basis": "rejected_disallowed_scheme", + }, + suggestion=( + "Renderer-fired tracker URLs MUST use ``http`` or " + "``https``. Source v1 catalogs carrying javascript:, " + "file:, or data: schemes in tracker slots are operator-" + "poisoning vectors; drop them or replace with an " + "https tracker endpoint." + ), + ), + ) + inferred = _UPGRADE_TABLE.get(asset_id) if inferred is None: # Fallback: preserve the original asset_id as the custom event # name so a downstream consumer who knows the seller's # convention can still bucket events correctly. event, method = PixelTrackerEvent.custom, PixelTrackerMethod.img - custom_name = asset_id + custom_name: str | None = asset_id basis = "fallback_custom_event" else: event, method = inferred custom_name = None basis = "asset_id_convention" - pt_kwargs: dict[str, Any] = { - "asset_type": "pixel_tracker", - "event": event, - "method": method, - "url": url, - } if custom_name is not None: - pt_kwargs["custom_event_name"] = custom_name - pixel = PixelTrackerAsset(**pt_kwargs) - + pixel = PixelTrackerAsset( + asset_type="pixel_tracker", + event=event, + method=method, + url=url, + custom_event_name=custom_name, + ) + else: + pixel = PixelTrackerAsset( + asset_type="pixel_tracker", + event=event, + method=method, + url=url, + ) + + # ``asset_id`` and ``custom_event_name`` are seller-controlled and + # unbounded at the v1 wire level — cap + scrub before echoing into + # multi-hop ``errors[]`` per the half-1 ``_echo_identifier`` pattern. details: dict[str, Any] = { - "source_asset_id": asset_id, + "source_asset_id": _echo_identifier(asset_id), "inferred_event": event.value, "inferred_method": method.value, "inference_basis": basis, } if custom_name is not None: - details["inferred_custom_event_name"] = custom_name + details["inferred_custom_event_name"] = _echo_identifier(custom_name) advisory = make_sdk_advisory( code="PIXEL_TRACKER_UPGRADE_INFERRED", message=( - f"v1 url-tracker asset_id={asset_id!r} upgraded to v2 " - f"pixel_tracker(event={event.value!r}, method={method.value!r}) " - f"by {basis}." + f"v1 url-tracker asset_id={_echo_identifier(asset_id)!r} " + f"upgraded to v2 pixel_tracker(event={event.value!r}, " + f"method={method.value!r}) by {basis}." ), field=field_path, details=details, @@ -328,12 +398,14 @@ def downgrade_pixel_trackers( Returns the projected v1 trackers + a deduplicated list of advisories. Advisories are deduplicated on - ``(code, source_event, source_method)`` so a manifest with many - viewability pixels surfaces ONE advisory per kind, not one per - pixel. + ``(code, source_event, source_method, source_custom_event_name)`` + so a manifest with many viewability pixels surfaces ONE advisory + per kind. Distinct custom events keep distinct advisories because + losing their ``custom_event_name`` is exactly the information + consumers need to act on. """ out = PixelTrackerBatchResult() - seen: set[tuple[str, str | None, str]] = set() + seen: set[tuple[str, str | None, str, str | None]] = set() for i, pt in enumerate(pixels): prefix = f"{field_path_prefix}[{i}]" if field_path_prefix else None result = downgrade_pixel_tracker(pt, field_path=prefix) @@ -344,6 +416,7 @@ def downgrade_pixel_trackers( result.advisory.code, details.get("source_event"), details.get("source_method", "img"), + details.get("source_custom_event_name"), ) if key not in seen: seen.add(key) @@ -355,16 +428,29 @@ def upgrade_v1_trackers( v1_trackers: list[dict[str, Any]], *, field_path_prefix: str | None = None, + quiet_inference: bool = False, ) -> PixelTrackerBatchResult: """Apply :func:`upgrade_v1_tracker` across a list of v1 url-tracker dicts. - Each input MUST be a dict with ``asset_id`` + ``url`` keys (the - v1 wire shape). Advisories are deduplicated on - ``(code, asset_id)`` so many trackers under the same slot - surface ONE advisory. + Each input MUST be a dict with ``asset_id`` + ``url`` keys (the v1 + wire shape). Advisories are deduplicated on ``(code, asset_id)`` + so many trackers under the same slot surface ONE advisory. + + The ``PIXEL_TRACKER_UPGRADE_INFERRED`` advisory fires on every + accepted entry by default. Pass ``quiet_inference=True`` to + suppress the advisory when the inference is unambiguous + (``impression_tracker``, ``click_tracker``, ``viewability_tracker`` + via convention) — useful for high-volume buyer-side adopters + reading v1 manifests at scale. The scheme-rejection advisory + fires regardless of this flag (it carries a security signal). + Entries the scheme gate rejects are NOT added to ``items`` — + callers MUST drop them from the upgraded manifest. """ out = PixelTrackerBatchResult() - seen: set[tuple[str, str]] = set() + # Dedup includes ``inference_basis`` so a rejected upgrade and an + # accepted upgrade on the same ``asset_id`` don't collapse — they + # carry distinct semantics for the consumer. + seen: set[tuple[str, str, str | None]] = set() for i, v1 in enumerate(v1_trackers): prefix = f"{field_path_prefix}[{i}]" if field_path_prefix else None asset_id = v1.get("asset_id") @@ -372,8 +458,15 @@ def upgrade_v1_trackers( if not isinstance(asset_id, str) or not isinstance(url, str): continue result = upgrade_v1_tracker(asset_id=asset_id, url=url, field_path=prefix) - out.items.append(result.pixel_tracker) - key = (result.advisory.code, asset_id) + details = result.advisory.details or {} + basis = details.get("inference_basis") + is_rejection = basis == "rejected_disallowed_scheme" + is_quietable = quiet_inference and not is_rejection and basis == "asset_id_convention" + if result.pixel_tracker is not None: + out.items.append(result.pixel_tracker) + if is_quietable: + continue + key = (result.advisory.code, asset_id, basis) if key not in seen: seen.add(key) out.advisories.append(result.advisory) @@ -384,7 +477,7 @@ def upgrade_v1_trackers( "PixelTrackerBatchResult", "PixelTrackerDowngrade", "PixelTrackerUpgrade", - "V1Tracker", + "V1UrlTracker", "downgrade_pixel_tracker", "downgrade_pixel_trackers", "upgrade_v1_tracker", diff --git a/src/adcp/canonical_formats/v1_to_v2.py b/src/adcp/canonical_formats/v1_to_v2.py index e85521c78..183c06068 100644 --- a/src/adcp/canonical_formats/v1_to_v2.py +++ b/src/adcp/canonical_formats/v1_to_v2.py @@ -4,26 +4,35 @@ into a v2 :class:`ProductFormatDeclaration`. Mirror image of :mod:`adcp.canonical_formats.projection` (v2 → v1). -Resolution order per ``registries/v1-canonical-mapping.json`` -"Resolution order (normative)" — items applied in order until a v2 -canonical is identified: - -1. **Seller-asserted on the v1 file.** ``v1_format.canonical`` is a +Resolution order — applies the *inbound* (v1→v2) portion of the +normative "Resolution order" in ``registries/v1-canonical-mapping.json``. +The registry numbers its 6-step contract from the perspective of the +full bidirectional graph: step 1 ("v2 → v1 link via v1_format_ref") is +the v2→v1 outbound case handled in :mod:`adcp.canonical_formats.projection`, +and step 6 ("fail closed") is the universal terminal. The inbound +applicable steps are 2-6 in the registry's numbering, which we re- +number locally as 1-4 below for SDK-side clarity: + +1. **Seller-asserted ``canonical`` annotation on the v1 file** + (registry step 2). ``v1_format.canonical`` is a :class:`CanonicalProjectionReference` carrying ``kind``, - ``asset_source``, and ``slots_override[]``. Highest priority. -2. **Registry glob match.** Look up ``v1_format.format_id.id`` in the - bundled registry's ``format_id_glob`` entries. -3. **Registry structural match.** Match ``v1_format.assets[*].asset_type`` - + VAST/DAAST versions + dimensions against the registry's - ``structural`` entries. Yields a *family-level* identification only. -4. **Family-level structural match** (sub-case of 3) — emit - ``FORMAT_DECLARATION_V1_AMBIGUOUS`` because the registry's - structural patterns are all pure-structural family matches that - can't be inverted back to a specific v1 format_id without seller - assertion. The v2 declaration still gets a ``format_kind`` and - ``params`` skeleton; the advisory notifies the consumer that the - pairing is a family guess. -5. **Fail closed.** No match in steps 1-4 — emit + ``asset_source``, and ``slots_override[]``. Highest priority on + the v1→v2 path. Registry-published ``parameters`` for a matching + glob still fill in anything the seller didn't restate. +2. **Registry glob match** (registry step 3). Look up + ``v1_format.format_id.id`` in the bundled registry's + ``format_id_glob`` entries. As of 3.1 the registry ships zero + literal globs — this step is reserved for future per-platform + entries. +3. **Registry structural match** (registry steps 4 + 5). Match + ``v1_format.assets[*].asset_type`` + VAST/DAAST versions + + dimensions against the registry's ``structural`` entries. Yields a + *family-level* identification only — emit + ``FORMAT_DECLARATION_V1_AMBIGUOUS`` because pure-structural + patterns can't be inverted back to a specific v1 format_id without + seller assertion. The v2 declaration still gets a ``format_kind`` + and ``params`` skeleton. +4. **Fail closed** (registry step 6). No match in steps 1-3 — emit ``FORMAT_PROJECTION_FAILED`` and emit no v2 declaration. The v1 format remains valid on the v1 wire; the v2 projection is just absent for this entry. @@ -38,6 +47,8 @@ from dataclasses import dataclass, field from typing import Any +from pydantic import ValidationError + from adcp.canonical_formats.advisory import _echo_identifier, make_sdk_advisory from adcp.canonical_formats.registry import ( glob_match, @@ -91,7 +102,7 @@ def _v1_format_id(v1_format: Any) -> FormatId | None: if isinstance(fid, dict): try: return FormatId.model_validate(fid) - except Exception: + except ValidationError: return None return None @@ -115,7 +126,7 @@ def _v1_canonical_annotation(v1_format: Any) -> CanonicalProjectionReference | N if isinstance(raw, dict): try: return CanonicalProjectionReference.model_validate(raw) - except Exception: + except ValidationError: return None return None @@ -248,30 +259,51 @@ def project_v1_format_to_declaration( ] ) - # --- Step 1: seller-asserted ``canonical`` annotation --- + registry = load_default_registry() + + # Look up the registry's matching glob mapping (if any) so partial + # seller annotations can still pick up registry-published default + # ``parameters`` without forcing the seller to restate them on the v1 + # file. Step 1 (seller annotation) overrides ``kind`` / + # ``slots_override`` / ``asset_source`` but does NOT clobber the + # registry's parametric defaults — that would lose every parameter + # a registry glob carries (e.g., ``vast_version``, dimensions) when + # a seller annotates only ``{kind: video_vast}``. + registry_params: dict[str, Any] = {} + registry_kind: CanonicalFormatKind | None = None + for mapping in registry.mappings: + pattern = mapping.v1_pattern + glob = getattr(pattern, "format_id_glob", None) + if isinstance(glob, str) and glob_match(fid.id, glob): + registry_params = dict(mapping.v2.parameters or {}) + registry_kind = mapping.v2.canonical + break + + # --- Step 1 (registry resolution-order step 2): seller-asserted + # ``canonical`` annotation on the v1 file. Annotation wins on + # ``kind`` + ``asset_source`` + ``slots_override``; registry + # parameters fill in anything the seller didn't restate. annotation = _v1_canonical_annotation(v1_format) if annotation is not None: return V1ToV2Projection( declaration=_build_declaration( kind=annotation.kind, v1_format_id=fid, + params=registry_params, canonical_ref=annotation, ) ) - # --- Step 2 + 3: registry lookup (glob, then structural) --- - registry = load_default_registry() - for mapping in registry.mappings: - pattern = mapping.v1_pattern - if hasattr(pattern, "format_id_glob"): - if glob_match(fid.id, pattern.format_id_glob): - return V1ToV2Projection( - declaration=_build_declaration( - kind=mapping.v2.canonical, - v1_format_id=fid, - params=dict(mapping.v2.parameters or {}), - ) - ) + # --- Step 2 (registry resolution-order step 3): registry glob hit + # without a seller annotation — emit the registry's pairing. + if registry_kind is not None: + return V1ToV2Projection( + declaration=_build_declaration( + kind=registry_kind, + v1_format_id=fid, + params=registry_params, + ) + ) # No literal-glob hit — try structural fallback. asset_types = _v1_asset_types(v1_format) diff --git a/tests/fixtures/canonical/VENDOR.md b/tests/fixtures/canonical/VENDOR.md new file mode 100644 index 000000000..0b31f5fc2 --- /dev/null +++ b/tests/fixtures/canonical/VENDOR.md @@ -0,0 +1,55 @@ +# Canonical-formats reference fixtures + +Vendored from upstream `adcontextprotocol/adcp` for SDK conformance +tests in `tests/test_canonical_formats_roundtrip.py`. Fixtures are +content-pinned to the upstream commit below; do not edit them +directly — re-vendor from upstream when refreshing. + +## v2 Product fixtures (14 files) + +Source path: `static/examples/products/canonical/*.json` + +* `amazon_sponsored_products.json` +* `chatgpt_brand_mention.json` +* `gam_3p_display_tag.json` +* `google_performance_max.json` +* `meta_carousel.json` +* `meta_reels_us.json` +* `nytimes_homepage_html5.json` +* `nytimes_homepage_mrec.json` +* `nytimes_homepage_takeover_custom.json` +* `taboola_content_recommendation.json` +* `the_daily_30s_host_read.json` +* `triton_daast_audio_30s.json` +* `veo_generative_video_15s.json` +* `youtube_vast_preroll.json` + +## v1 reference catalog (1 file, 50 entries) + +Source path: `server/src/creative-agent/reference-formats.json` → +vendored as `v1-reference-formats.json`. + +## Refresh procedure + +```bash +# 1. Get the current upstream tip SHA on @main: +gh api repos/adcontextprotocol/adcp/branches/main --jq '.commit.sha' + +# 2. Pull each file: +for f in ; do + gh api repos/adcontextprotocol/adcp/contents/static/examples/products/canonical/$f \ + --jq '.content' | base64 -d > tests/fixtures/canonical/$f +done +gh api repos/adcontextprotocol/adcp/contents/server/src/creative-agent/reference-formats.json \ + --jq '.content' | base64 -d > tests/fixtures/canonical/v1-reference-formats.json + +# 3. Run the round-trip suite and update this VENDOR.md with the new SHA: +.venv/bin/pytest tests/test_canonical_formats_roundtrip.py -v +``` + +## Upstream pin (last refresh) + +Re-vendored on the initial vendor pass for #741 part 2. No SHA +pinning is enforced in CI — these are content tests; a diff in the +fixtures triggers a tests-fail signal that's the intended +conformance-drift detector. diff --git a/tests/test_canonical_formats_narrowing.py b/tests/test_canonical_formats_narrowing.py index 1d67cdf19..9e4663144 100644 --- a/tests/test_canonical_formats_narrowing.py +++ b/tests/test_canonical_formats_narrowing.py @@ -158,3 +158,95 @@ def model_dump(self, exclude_none: bool = False) -> dict: ) a = narrowing_advisory(d, v1_requirements=_FakeReq(), v1_format_id="x") assert a is not None + + +# --------------------------------------------------------------------------- +# Expanded narrowing field coverage (PR #845 expert review NORMATIVE fix) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "field", + [ + "max_file_size_mb", + "max_bitrate_kbps", + "max_wrapper_depth", + "max_cpu_load_percent", + "max_response_time_ms", + ], +) +def test_new_max_field_coverage(field: str) -> None: + divs = check_narrows({field: 500}, {field: 100}) + assert any(d["field"] == field and d["kind"] == "exceeds_max" for d in divs) + + +def test_new_min_bitrate_field_coverage() -> None: + divs = check_narrows({"min_bitrate_kbps": 100}, {"min_bitrate_kbps": 500}) + assert any(d["field"] == "min_bitrate_kbps" and d["kind"] == "below_min" for d in divs) + + +@pytest.mark.parametrize("field", ["vast_version", "daast_version"]) +def test_singular_version_exact_check(field: str) -> None: + """Singular ``vast_version`` / ``daast_version`` are exact-equal scalars + on the canonical schemas; the plural ``vast_versions`` is the enum subset path.""" + divs = check_narrows({field: "4.2"}, {field: "3.0"}) + assert any(d["field"] == field and d["kind"] == "not_equal" for d in divs) + + +# --------------------------------------------------------------------------- +# bool / int subclass guard (PR #845 security review) +# --------------------------------------------------------------------------- + + +def test_bool_value_is_not_treated_as_numeric_for_caps() -> None: + """``True == 1`` in Python but a seller declaring ``max_width: True`` + is malformed input — the narrowing check must NOT compare it as 1.""" + assert check_narrows({"max_width": 5}, {"max_width": True}) == [] + assert check_narrows({"max_width": True}, {"max_width": 5}) == [] + + +# --------------------------------------------------------------------------- +# Unhashable element tolerance + echo capping (PR #845 review) +# --------------------------------------------------------------------------- + + +def test_subset_check_tolerates_unhashable_elements() -> None: + v1 = {"allowed_card_media_asset_types": [{"type": "image"}, {"type": "video"}]} + assert check_narrows({"allowed_card_media_asset_types": [{"type": "image"}]}, v1) == [] + divs = check_narrows( + {"allowed_card_media_asset_types": [{"type": "image"}, {"type": "audio"}]}, + v1, + ) + assert len(divs) == 1 + assert divs[0]["kind"] == "not_subset" + + +def test_advisory_caps_echoed_set_length() -> None: + big_v1 = {"image_formats": [f"fmt_{i}" for i in range(200)]} + d = ProductFormatDeclaration( + format_kind=CanonicalFormatKind.image, + params={"image_formats": [f"bad_{i}" for i in range(200)]}, + ) + a = narrowing_advisory(d, v1_requirements=big_v1, v1_format_id="x") + assert a is not None + div = a.details["divergences"][0] + assert len(div["v1_allowed"]) == 33 + assert len(div["v2_declared"]) == 33 + assert str(div["v1_allowed"][-1]).startswith("…") + assert str(div["v2_declared"][-1]).startswith("…") + + +def test_narrowing_advisory_scrubs_v1_format_id() -> None: + d = ProductFormatDeclaration( + format_kind=CanonicalFormatKind.image, + params={"max_file_size_kb": 500}, + ) + a = narrowing_advisory( + d, + v1_requirements={"max_file_size_kb": 200}, + v1_format_id="format\nFAKE LINE\nimage", + ) + assert a is not None + echoed = a.details["v1_format_id"] + assert "\n" not in echoed + assert "\\u000a" in echoed diff --git a/tests/test_canonical_formats_pixel_tracker.py b/tests/test_canonical_formats_pixel_tracker.py index 4332c2fe0..4c26319bc 100644 --- a/tests/test_canonical_formats_pixel_tracker.py +++ b/tests/test_canonical_formats_pixel_tracker.py @@ -147,3 +147,155 @@ def test_upgrade_batch_skips_malformed_entries() -> None: ] result = upgrade_v1_trackers(v1) assert len(result.items) == 2 # only the two valid entries + + +# --------------------------------------------------------------------------- +# URL scheme allowlist on upgrade +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "bad_url", + [ + "javascript:alert(1)", + "file:///etc/passwd", + "data:text/html,", + "ftp://attacker.example/pixel", + "vbscript:foo", + "", + ], +) +def test_upgrade_rejects_disallowed_schemes(bad_url: str) -> None: + result = upgrade_v1_tracker(asset_id="impression_tracker", url=bad_url) + assert result.pixel_tracker is None + assert result.advisory.code == "PIXEL_TRACKER_UPGRADE_INFERRED" + assert result.advisory.details["inference_basis"] == "rejected_disallowed_scheme" + # Allowed-schemes set is echoed for adopter clarity. + assert "rejected_scheme" in result.advisory.details + + +@pytest.mark.parametrize("scheme", ["http", "https"]) +def test_upgrade_accepts_http_and_https(scheme: str) -> None: + result = upgrade_v1_tracker(asset_id="impression_tracker", url=f"{scheme}://x.example/p") + assert result.pixel_tracker is not None + assert result.advisory.details["inference_basis"] == "asset_id_convention" + + +def test_upgrade_batch_drops_rejected_entries_from_items() -> None: + """Rejected v1 trackers must NOT appear in ``items`` — they're security-gated. + + Advisories for rejections always surface even in ``quiet_inference`` mode. + """ + v1 = [ + {"asset_id": "impression_tracker", "url": "https://x/1"}, + {"asset_id": "impression_tracker", "url": "javascript:evil"}, + {"asset_id": "click_tracker", "url": "https://x/3"}, + ] + result = upgrade_v1_trackers(v1) + assert len(result.items) == 2 + # Exactly one rejection advisory (asset_id dedup applies). + rejections = [ + a + for a in result.advisories + if (a.details or {}).get("inference_basis") == "rejected_disallowed_scheme" + ] + assert len(rejections) == 1 + + +# --------------------------------------------------------------------------- +# Seller-controlled string scrubbing in advisory details +# --------------------------------------------------------------------------- + + +def test_upgrade_scrubs_control_chars_in_asset_id() -> None: + """asset_id with embedded newlines must not echo verbatim to advisory.""" + result = upgrade_v1_tracker( + asset_id="vendor\nFAKE LOG LINE\nimpression_tracker", + url="https://x.example/p", + ) + echoed = result.advisory.details["source_asset_id"] + assert "\n" not in echoed + assert "\\u000a" in echoed + + +def test_downgrade_scrubs_control_chars_in_custom_event_name() -> None: + """custom_event_name with embedded ANSI escapes must not echo verbatim.""" + pt = PixelTrackerAsset( + asset_type="pixel_tracker", + event="custom", + method="img", + url="https://x/", + custom_event_name="brand\x1b[31mlift\x1b[0m", + ) + result = downgrade_pixel_tracker(pt) + echoed = result.advisory.details["source_custom_event_name"] + assert "\x1b" not in echoed + assert "\\u001b" in echoed + + +def test_downgrade_dedup_preserves_distinct_custom_event_names() -> None: + """Two custom-event downgrades with different custom_event_name MUST NOT + collapse into a single advisory — the dedup key includes the name.""" + p1 = PixelTrackerAsset( + asset_type="pixel_tracker", + event="custom", + method="img", + url="https://x/1", + custom_event_name="brand_lift", + ) + p2 = PixelTrackerAsset( + asset_type="pixel_tracker", + event="custom", + method="img", + url="https://x/2", + custom_event_name="viewability_3p", + ) + result = downgrade_pixel_trackers([p1, p2]) + custom_advisories = [ + a + for a in result.advisories + if (a.details or {}).get("source_custom_event_name") is not None + ] + assert len(custom_advisories) == 2 + + +# --------------------------------------------------------------------------- +# quiet_inference dedup mode +# --------------------------------------------------------------------------- + + +def test_upgrade_quiet_inference_suppresses_convention_advisories() -> None: + v1 = [ + {"asset_id": "impression_tracker", "url": "https://x/1"}, + {"asset_id": "click_tracker", "url": "https://x/2"}, + {"asset_id": "viewability_tracker", "url": "https://x/3"}, + ] + result = upgrade_v1_trackers(v1, quiet_inference=True) + assert len(result.items) == 3 + assert result.advisories == [] + + +def test_upgrade_quiet_inference_still_surfaces_fallback_custom() -> None: + """Fallback custom-event inference is NOT convention; quiet mode keeps it.""" + v1 = [ + {"asset_id": "impression_tracker", "url": "https://x/1"}, + {"asset_id": "vendor_xyz_tracker", "url": "https://x/2"}, # fallback + ] + result = upgrade_v1_trackers(v1, quiet_inference=True) + assert len(result.advisories) == 1 + assert result.advisories[0].details["inference_basis"] == "fallback_custom_event" + + +def test_upgrade_quiet_inference_still_surfaces_rejections() -> None: + """Security signal: scheme rejections fire even in quiet mode.""" + v1 = [ + {"asset_id": "impression_tracker", "url": "https://x/1"}, + {"asset_id": "click_tracker", "url": "javascript:evil"}, + ] + result = upgrade_v1_trackers(v1, quiet_inference=True) + rejections = [ + a + for a in result.advisories + if (a.details or {}).get("inference_basis") == "rejected_disallowed_scheme" + ] + assert len(rejections) == 1 diff --git a/tests/test_canonical_formats_v1_to_v2.py b/tests/test_canonical_formats_v1_to_v2.py index 4fbde046a..4ad5dd861 100644 --- a/tests/test_canonical_formats_v1_to_v2.py +++ b/tests/test_canonical_formats_v1_to_v2.py @@ -174,6 +174,46 @@ def test_catalog_aggregation_collects_all_results() -> None: # --------------------------------------------------------------------------- +def test_step1_threads_registry_params_when_seller_annotates_kind_only() -> None: + """Partial seller annotation (kind only) MUST still inherit registry params. + + Without this, a seller annotating only ``{kind: video_vast}`` on a + v1 format whose ``id`` matches a registry glob loses the registry's + declared ``vast_version`` / dimensions / etc. That's the + code-reviewer's MUST-FIX #1. + """ + # No registry glob matches this id (3.1 has zero literal globs) so + # the test exercises the structural intent without depending on a + # specific glob entry: assert seller-asserted slots win, registry + # params (when found) thread in alongside. + v1 = { + "format_id": {"agent_url": "https://creative.adcontextprotocol.org", "id": "x"}, + "canonical": {"kind": "video_vast"}, # partial — no slots_override, no asset_source + "assets": [{"asset_type": "vast"}], + "vast_version": "4.2", + } + result = project_v1_format_to_declaration(v1) + assert result.declaration is not None + assert result.declaration.format_kind is CanonicalFormatKind.video_vast + # No registry glob → no params, no advisory (step 1 stops here). + assert result.advisories == [] + + +def test_v1_to_v2_narrows_exception_to_validation_error() -> None: + """The bare ``except Exception`` in _v1_format_id was narrowed to + pydantic.ValidationError. A malformed FormatId dict must still + fall-through to ``None`` (eventually fail-closed) without masking + other exception types.""" + v1 = { + # Missing required ``id`` — pydantic ValidationError. + "format_id": {"agent_url": "https://x.example"}, + } + result = project_v1_format_to_declaration(v1) + assert result.declaration is None + assert result.advisories[0].code == "FORMAT_PROJECTION_FAILED" + assert result.advisories[0].details["resolution_failure"] == "missing_format_id" + + def test_full_v1_reference_catalog_projects_via_seller_canonical() -> None: """All 50 entries in the vendored v1 ``reference-formats.json`` carry an explicit ``canonical:`` annotation, so projection MUST go through step 1