From 62d23796f42c46ee86e6a183384328c96cbe0e85 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 21 May 2026 10:46:16 -0400 Subject: [PATCH 1/4] feat(signing): key_origins consistency check + brand.json chain error codes (#350 stage 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per ADCP request-signing spec #3690, an agent advertising signing posture declares ``identity.key_origins`` on its capabilities response — keyed by purpose (request_signing / webhook_signing / governance_signing / tmp_signing) and valued with the origin URI that hosts the JWKS for that purpose. After resolving an agent's keys via the brand.json chain, the verifier MUST confirm the resolved jwks_uri host matches the declared origin for the purpose under check. **4a — new error codes** (errors.py): Nine new ``request_signature_*`` codes covering the brand.json discovery chain (#3690 §"Discovering an agent's signing keys") and the ``identity.key_origins`` consistency check: - _brand_json_url_missing (capabilities.identity.brand_json_url absent) - _capabilities_unreachable (transport failure fetching capabilities) - _brand_json_unreachable (transport failure fetching brand.json) - _brand_json_malformed (strict-parse failure on brand.json) - _brand_origin_mismatch (agent eTLD+1 ≠ brand eTLD+1 + no delegation) - _agent_not_in_brand_json (agent URL absent from agents[]) - _brand_json_ambiguous (multiple agents[] entries byte-equal) - _key_origin_mismatch (resolved jwks_uri host ≠ declared origin) - _key_origin_missing (signing posture asserted, declaration absent) Plus the nine ``webhook_signature_*`` mirror constants and entries in REQUEST_TO_WEBHOOK_CODE so the webhook verifier wrapper retags request-family codes into webhook-family ones without each callsite re-declaring the mapping. **4b — key_origins consistency check** (key_origins.py, new): ``check_key_origin_consistency(jwks_uri, key_origins, purpose, posture=None, code_family="request")`` — the standalone primitive for the consistency check. Pure function on (resolved jwks_uri, declared key_origins map, purpose); raises SignatureVerificationError with the right spec code. ``code_family="webhook"`` swaps to the webhook code family. Canonicalization: ASCII-lowercase + stdlib host.encode("idna") to A-label form. The spec asks for IDNA-2008 strictly while stdlib encodings.idna is IDNA-2003; this divergence is rare in practice and matching the package's existing convention (jwks.py:201, ip_pinned_transport.py:110, revocation_fetcher.py:380) keeps the canonicalization story coherent. A future IDNA-2008 migration would update all four callsites together. The verifier integration (calling this check after RFC 9421 verification when the JWKS source was a brand.json walk vs. a publisher pin) belongs to stage 5's dispatch wire-up — the primitive lands here, ready to compose with the BrandAuthorizationResolver gate. 12 new tests cover: success (declared matches resolved), missing declaration, mismatch on different host, mismatch on subdomain drift, case-insensitive comparison, posture propagation in diagnostics, bare-host declarations, ``None`` and empty key_origins maps, invalid jwks_uri (fail-closed), and webhook code-family routing. Re-exports from ``adcp.signing`` so adopters can import: - check_key_origin_consistency - All 9 new REQUEST_SIGNATURE_* code constants (Webhook mirrors stay submodule-only; webhook code routing is internal to the webhook verifier wrapper.) Refs #350, adcontextprotocol/adcp#3690 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/adcp/signing/__init__.py | 20 ++++ src/adcp/signing/errors.py | 45 +++++++++ src/adcp/signing/key_origins.py | 166 ++++++++++++++++++++++++++++++++ tests/test_key_origins.py | 164 +++++++++++++++++++++++++++++++ 4 files changed, 395 insertions(+) create mode 100644 src/adcp/signing/key_origins.py create mode 100644 tests/test_key_origins.py diff --git a/src/adcp/signing/__init__.py b/src/adcp/signing/__init__.py index ab21891ea..dd7be7253 100644 --- a/src/adcp/signing/__init__.py +++ b/src/adcp/signing/__init__.py @@ -162,7 +162,14 @@ ) from adcp.signing.digest import compute_content_digest_sha256, content_digest_matches from adcp.signing.errors import ( + REQUEST_SIGNATURE_AGENT_NOT_IN_BRAND_JSON, REQUEST_SIGNATURE_ALG_NOT_ALLOWED, + REQUEST_SIGNATURE_BRAND_JSON_AMBIGUOUS, + REQUEST_SIGNATURE_BRAND_JSON_MALFORMED, + REQUEST_SIGNATURE_BRAND_JSON_UNREACHABLE, + REQUEST_SIGNATURE_BRAND_JSON_URL_MISSING, + REQUEST_SIGNATURE_BRAND_ORIGIN_MISMATCH, + REQUEST_SIGNATURE_CAPABILITIES_UNREACHABLE, REQUEST_SIGNATURE_COMPONENTS_INCOMPLETE, REQUEST_SIGNATURE_COMPONENTS_UNEXPECTED, REQUEST_SIGNATURE_DIGEST_MISMATCH, @@ -170,6 +177,8 @@ REQUEST_SIGNATURE_INVALID, REQUEST_SIGNATURE_JWKS_UNAVAILABLE, REQUEST_SIGNATURE_JWKS_UNTRUSTED, + REQUEST_SIGNATURE_KEY_ORIGIN_MISMATCH, + REQUEST_SIGNATURE_KEY_ORIGIN_MISSING, REQUEST_SIGNATURE_KEY_PURPOSE_INVALID, REQUEST_SIGNATURE_KEY_REVOKED, REQUEST_SIGNATURE_KEY_UNKNOWN, @@ -219,6 +228,7 @@ verify_detached_jws, verify_jws_document, ) +from adcp.signing.key_origins import check_key_origin_consistency from adcp.signing.keygen import generate_signing_keypair, pem_to_adcp_jwk from adcp.signing.middleware import ( unauthorized_response_headers, @@ -336,7 +346,14 @@ def __init__(self, *args: object, **kwargs: object) -> None: "NEGATIVE_CACHE_TTL_SECONDS", "NONCE_BYTES", "PgReplayStore", + "REQUEST_SIGNATURE_AGENT_NOT_IN_BRAND_JSON", "REQUEST_SIGNATURE_ALG_NOT_ALLOWED", + "REQUEST_SIGNATURE_BRAND_JSON_AMBIGUOUS", + "REQUEST_SIGNATURE_BRAND_JSON_MALFORMED", + "REQUEST_SIGNATURE_BRAND_JSON_UNREACHABLE", + "REQUEST_SIGNATURE_BRAND_JSON_URL_MISSING", + "REQUEST_SIGNATURE_BRAND_ORIGIN_MISMATCH", + "REQUEST_SIGNATURE_CAPABILITIES_UNREACHABLE", "REQUEST_SIGNATURE_COMPONENTS_INCOMPLETE", "REQUEST_SIGNATURE_COMPONENTS_UNEXPECTED", "REQUEST_SIGNATURE_DIGEST_MISMATCH", @@ -344,6 +361,8 @@ def __init__(self, *args: object, **kwargs: object) -> None: "REQUEST_SIGNATURE_INVALID", "REQUEST_SIGNATURE_JWKS_UNAVAILABLE", "REQUEST_SIGNATURE_JWKS_UNTRUSTED", + "REQUEST_SIGNATURE_KEY_ORIGIN_MISMATCH", + "REQUEST_SIGNATURE_KEY_ORIGIN_MISSING", "REQUEST_SIGNATURE_KEY_PURPOSE_INVALID", "REQUEST_SIGNATURE_KEY_REVOKED", "REQUEST_SIGNATURE_KEY_UNKNOWN", @@ -395,6 +414,7 @@ def __init__(self, *args: object, **kwargs: object) -> None: "build_signature_base", "canonicalize_authority", "canonicalize_target_uri", + "check_key_origin_consistency", "compute_content_digest_sha256", "content_digest_matches", "decode_standard_webhook_secret", diff --git a/src/adcp/signing/errors.py b/src/adcp/signing/errors.py index 39caff0a1..d92feb3ce 100644 --- a/src/adcp/signing/errors.py +++ b/src/adcp/signing/errors.py @@ -42,6 +42,28 @@ def __init__( REQUEST_SIGNATURE_JWKS_UNTRUSTED = "request_signature_jwks_untrusted" REQUEST_SIGNATURE_RATE_ABUSE = "request_signature_rate_abuse" +# brand.json discovery chain (ADCP #3690). Verifiers bootstrap an agent's +# signing keys via ``identity.brand_json_url`` on the agent's +# ``get_adcp_capabilities`` response → brand.json → ``agents[]`` → +# ``jwks_uri``. Each step has a dedicated rejection code so callers can +# disambiguate retryable transport failures (``*_unreachable``) from +# misconfiguration (``*_missing`` / ``*_malformed`` / ``*_mismatch``). +REQUEST_SIGNATURE_BRAND_JSON_URL_MISSING = "request_signature_brand_json_url_missing" +REQUEST_SIGNATURE_CAPABILITIES_UNREACHABLE = "request_signature_capabilities_unreachable" +REQUEST_SIGNATURE_BRAND_JSON_UNREACHABLE = "request_signature_brand_json_unreachable" +REQUEST_SIGNATURE_BRAND_JSON_MALFORMED = "request_signature_brand_json_malformed" +REQUEST_SIGNATURE_BRAND_ORIGIN_MISMATCH = "request_signature_brand_origin_mismatch" +REQUEST_SIGNATURE_AGENT_NOT_IN_BRAND_JSON = "request_signature_agent_not_in_brand_json" +REQUEST_SIGNATURE_BRAND_JSON_AMBIGUOUS = "request_signature_brand_json_ambiguous" + +# identity.key_origins consistency check (ADCP #3690). For every purpose +# declared under capabilities ``identity.key_origins``, the resolved +# ``jwks_uri`` host MUST equal the declared origin (after IDNA-A-label +# canonicalization). Mismatch → ``_key_origin_mismatch``. Missing +# declaration when signing posture is asserted → ``_key_origin_missing``. +REQUEST_SIGNATURE_KEY_ORIGIN_MISMATCH = "request_signature_key_origin_mismatch" +REQUEST_SIGNATURE_KEY_ORIGIN_MISSING = "request_signature_key_origin_missing" + # Webhook-signing error taxonomy — adcp#2423 / webhooks.mdx + security.mdx. # Distinct strings from the request-signing family so receivers can route the # 401 response through webhook-specific observability. @@ -64,6 +86,20 @@ def __init__( WEBHOOK_SIGNATURE_JWKS_UNTRUSTED = "webhook_signature_jwks_untrusted" WEBHOOK_SIGNATURE_RATE_ABUSE = "webhook_signature_rate_abuse" +# brand.json discovery chain mirrors for the webhook profile. The chain +# walks identically (capabilities → brand.json → agents[] → jwks_uri), +# just consulting the ``webhook_signing`` purpose under +# ``identity.key_origins`` instead of ``request_signing``. +WEBHOOK_SIGNATURE_BRAND_JSON_URL_MISSING = "webhook_signature_brand_json_url_missing" +WEBHOOK_SIGNATURE_CAPABILITIES_UNREACHABLE = "webhook_signature_capabilities_unreachable" +WEBHOOK_SIGNATURE_BRAND_JSON_UNREACHABLE = "webhook_signature_brand_json_unreachable" +WEBHOOK_SIGNATURE_BRAND_JSON_MALFORMED = "webhook_signature_brand_json_malformed" +WEBHOOK_SIGNATURE_BRAND_ORIGIN_MISMATCH = "webhook_signature_brand_origin_mismatch" +WEBHOOK_SIGNATURE_AGENT_NOT_IN_BRAND_JSON = "webhook_signature_agent_not_in_brand_json" +WEBHOOK_SIGNATURE_BRAND_JSON_AMBIGUOUS = "webhook_signature_brand_json_ambiguous" +WEBHOOK_SIGNATURE_KEY_ORIGIN_MISMATCH = "webhook_signature_key_origin_mismatch" +WEBHOOK_SIGNATURE_KEY_ORIGIN_MISSING = "webhook_signature_key_origin_missing" + # Code-family translation used by the webhook verifier wrapper. The verifier # pipeline raises request_signature_* codes; the wrapper retags them into # webhook_signature_* before exposing to callers. Keeps the 300-line verifier @@ -87,4 +123,13 @@ def __init__( REQUEST_SIGNATURE_JWKS_UNAVAILABLE: WEBHOOK_SIGNATURE_JWKS_UNAVAILABLE, REQUEST_SIGNATURE_JWKS_UNTRUSTED: WEBHOOK_SIGNATURE_JWKS_UNTRUSTED, REQUEST_SIGNATURE_RATE_ABUSE: WEBHOOK_SIGNATURE_RATE_ABUSE, + REQUEST_SIGNATURE_BRAND_JSON_URL_MISSING: WEBHOOK_SIGNATURE_BRAND_JSON_URL_MISSING, + REQUEST_SIGNATURE_CAPABILITIES_UNREACHABLE: WEBHOOK_SIGNATURE_CAPABILITIES_UNREACHABLE, + REQUEST_SIGNATURE_BRAND_JSON_UNREACHABLE: WEBHOOK_SIGNATURE_BRAND_JSON_UNREACHABLE, + REQUEST_SIGNATURE_BRAND_JSON_MALFORMED: WEBHOOK_SIGNATURE_BRAND_JSON_MALFORMED, + REQUEST_SIGNATURE_BRAND_ORIGIN_MISMATCH: WEBHOOK_SIGNATURE_BRAND_ORIGIN_MISMATCH, + REQUEST_SIGNATURE_AGENT_NOT_IN_BRAND_JSON: WEBHOOK_SIGNATURE_AGENT_NOT_IN_BRAND_JSON, + REQUEST_SIGNATURE_BRAND_JSON_AMBIGUOUS: WEBHOOK_SIGNATURE_BRAND_JSON_AMBIGUOUS, + REQUEST_SIGNATURE_KEY_ORIGIN_MISMATCH: WEBHOOK_SIGNATURE_KEY_ORIGIN_MISMATCH, + REQUEST_SIGNATURE_KEY_ORIGIN_MISSING: WEBHOOK_SIGNATURE_KEY_ORIGIN_MISSING, } diff --git a/src/adcp/signing/key_origins.py b/src/adcp/signing/key_origins.py new file mode 100644 index 000000000..c361e9153 --- /dev/null +++ b/src/adcp/signing/key_origins.py @@ -0,0 +1,166 @@ +"""``identity.key_origins`` consistency check (ADCP #3690). + +Per ADCP request-signing spec, an agent advertising signing posture +declares an ``identity.key_origins`` map on its ``get_adcp_capabilities`` +response — keyed by purpose (``request_signing``, ``webhook_signing``, +``governance_signing``, ``tmp_signing``) and valued with the origin URI +that hosts the JWKS for that purpose. + +After resolving an agent's keys via the brand.json chain, the verifier +MUST confirm the resolved ``jwks_uri`` host equals the declared origin +for the purpose under check. The check defends against the +shared-tenancy spoof where an attacker stands up a brand.json that +lists a counterparty's legitimate ``jwks_uri`` while the counterparty's +own capabilities advertise a different origin: the agent claims one +trust root via brand.json and a different one via capabilities, and +without the consistency check the verifier silently honors the +brand.json side. + +Reject codes: + +* ``request_signature_key_origin_mismatch`` — declared origin differs + from resolved ``jwks_uri`` host (after canonicalization). +* ``request_signature_key_origin_missing`` — signing posture asserted + but no ``identity.key_origins.{purpose}`` declaration. + +The carve-out for publisher ``adagents.json signing_keys`` pins (where +the key origin is the publisher's domain, not the operator's) is the +caller's responsibility: skip this check for the specific (agent, +purpose, role) tuple sourced from a publisher pin. + +The webhook profile reuses this check via the +``webhook_signature_key_origin_*`` codes; pass ``code_family="webhook"`` +to raise the webhook-family codes instead of the request family. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Literal +from urllib.parse import urlsplit + +from adcp.signing.errors import ( + REQUEST_SIGNATURE_KEY_ORIGIN_MISMATCH, + REQUEST_SIGNATURE_KEY_ORIGIN_MISSING, + WEBHOOK_SIGNATURE_KEY_ORIGIN_MISMATCH, + WEBHOOK_SIGNATURE_KEY_ORIGIN_MISSING, + SignatureVerificationError, +) + +CodeFamily = Literal["request", "webhook"] + +#: Per spec #3690 §"Discovering an agent's signing keys via brand_json_url" +#: step 7. The check is mandatory only when the JWKS source was the +#: operator brand.json (not a publisher pin). The caller decides which +#: branch applies and either calls this function or skips it. +_MISMATCH_CODE: dict[CodeFamily, str] = { + "request": REQUEST_SIGNATURE_KEY_ORIGIN_MISMATCH, + "webhook": WEBHOOK_SIGNATURE_KEY_ORIGIN_MISMATCH, +} +_MISSING_CODE: dict[CodeFamily, str] = { + "request": REQUEST_SIGNATURE_KEY_ORIGIN_MISSING, + "webhook": WEBHOOK_SIGNATURE_KEY_ORIGIN_MISSING, +} + + +def check_key_origin_consistency( + *, + jwks_uri: str, + key_origins: Mapping[str, str] | None, + purpose: str, + posture: str | None = None, + code_family: CodeFamily = "request", +) -> None: + """Verify that the resolved ``jwks_uri`` host matches the declared + ``identity.key_origins.{purpose}``. + + Parameters + ---------- + jwks_uri: + The JWKS URI the verifier resolved via the brand.json chain. + Only the host portion is consulted. + key_origins: + The ``identity.key_origins`` map from the agent's + ``get_adcp_capabilities`` response. ``None`` is equivalent to + an empty map. + purpose: + The purpose under check — typically one of ``request_signing``, + ``webhook_signing``, ``governance_signing``, ``tmp_signing``. + Free-form string so a future purpose can be checked without + changes here. + posture: + Optional context attached to ``key_origin_missing`` rejection + for adopter diagnostics (e.g. ``"required"``, ``"supported"``). + Not consulted by the check itself. + code_family: + ``"request"`` (default) or ``"webhook"``. Picks the + corresponding spec error code family. + + Raises + ------ + SignatureVerificationError + With ``code = *_key_origin_missing`` when ``purpose`` is absent + from ``key_origins``; ``code = *_key_origin_mismatch`` when the + purpose's declared origin differs from the resolved + ``jwks_uri`` host (after IDNA-A-label canonicalization). + """ + declared = (key_origins or {}).get(purpose) + if declared is None: + raise SignatureVerificationError( + _MISSING_CODE[code_family], + step=7, + message=( + f"identity.key_origins.{purpose} declaration missing" + + (f" (posture={posture})" if posture else "") + ), + ) + + actual_host = _origin_host(jwks_uri) + declared_host = _origin_host(declared) + if actual_host is None or declared_host is None or actual_host != declared_host: + raise SignatureVerificationError( + _MISMATCH_CODE[code_family], + step=7, + message=( + f"identity.key_origins.{purpose} declares {declared_host!r} " + f"but resolved jwks_uri host is {actual_host!r}" + ), + ) + + +def _origin_host(value: str) -> str | None: + """Return the host portion of a URL or bare origin, canonicalized + for byte-equality comparison. + + Canonicalization mirrors the existing codebase pattern + (``jwks.py:201``, ``ip_pinned_transport.py:110``, + ``revocation_fetcher.py:380``): ASCII-lowercase, then + ``host.encode("idna").decode("ascii")`` to convert IDN U-labels to + their A-label (Punycode) form. The spec asks for IDNA-2008 strictly + while stdlib ``encodings.idna`` is IDNA-2003; the divergence is + rare in practice and matching the package's existing convention + keeps the canonicalization story coherent. A future IDNA-2008 + migration would update all four callsites together. + + Returns ``None`` when the input is structurally invalid (no scheme + or no host); callers treat ``None`` as a binding failure. + """ + parts = urlsplit(value) + host = parts.hostname + if not host: + # Permit bare-host inputs like ``"keys.brand.com"`` — + # capabilities ``identity.key_origins`` values are not + # spec-constrained to be full URLs, only to identify an origin. + host = value.strip().lower() + if not host or "/" in host or " " in host: + return None + try: + return host.encode("idna").decode("ascii").lower() + except (UnicodeError, UnicodeEncodeError): + return None + + +__all__ = [ + "CodeFamily", + "check_key_origin_consistency", +] diff --git a/tests/test_key_origins.py b/tests/test_key_origins.py new file mode 100644 index 000000000..ddbb51b3a --- /dev/null +++ b/tests/test_key_origins.py @@ -0,0 +1,164 @@ +"""Tests for :mod:`adcp.signing.key_origins`. + +Behavior under test (matches ADCP request-signing spec #3690 step 7): + +* Declared origin equals resolved ``jwks_uri`` host → success (no raise). +* Purpose absent from ``key_origins`` → raises ``*_key_origin_missing``. +* Declared origin differs from resolved host → raises + ``*_key_origin_mismatch``. +* Canonicalization: ASCII-lowercase + IDNA-A-label so case differences + and IDN U-label vs A-label compare equal. +* ``code_family`` switches between request and webhook code families. +* ``None`` / empty ``key_origins`` map equivalent. +""" + +from __future__ import annotations + +import pytest + +from adcp.signing.errors import ( + REQUEST_SIGNATURE_KEY_ORIGIN_MISMATCH, + REQUEST_SIGNATURE_KEY_ORIGIN_MISSING, + WEBHOOK_SIGNATURE_KEY_ORIGIN_MISMATCH, + WEBHOOK_SIGNATURE_KEY_ORIGIN_MISSING, + SignatureVerificationError, +) +from adcp.signing.key_origins import check_key_origin_consistency + +# ----- success path ----- + + +def test_consistency_passes_when_declared_matches_resolved() -> None: + # No raise — the function returns None on success. + check_key_origin_consistency( + jwks_uri="https://keys.brand.com/.well-known/jwks.json", + key_origins={"request_signing": "https://keys.brand.com"}, + purpose="request_signing", + ) + + +def test_consistency_passes_with_bare_host_declaration() -> None: + # Capabilities may declare ``identity.key_origins`` as bare hosts. + check_key_origin_consistency( + jwks_uri="https://keys.brand.com/.well-known/jwks.json", + key_origins={"request_signing": "keys.brand.com"}, + purpose="request_signing", + ) + + +def test_consistency_passes_case_insensitive() -> None: + # Spec mandates origin canonicalization (lowercase host). A + # mixed-case declaration should not silently reject a lowercased + # resolved URI. + check_key_origin_consistency( + jwks_uri="https://keys.brand.com/.well-known/jwks.json", + key_origins={"request_signing": "https://KEYS.Brand.Com"}, + purpose="request_signing", + ) + + +# ----- missing-declaration path ----- + + +def test_consistency_raises_missing_when_purpose_absent() -> None: + with pytest.raises(SignatureVerificationError) as exc_info: + check_key_origin_consistency( + jwks_uri="https://keys.brand.com/.well-known/jwks.json", + key_origins={"webhook_signing": "https://keys.brand.com"}, + purpose="request_signing", + ) + assert exc_info.value.code == REQUEST_SIGNATURE_KEY_ORIGIN_MISSING + assert exc_info.value.step == 7 + + +def test_consistency_raises_missing_when_key_origins_is_none() -> None: + with pytest.raises(SignatureVerificationError) as exc_info: + check_key_origin_consistency( + jwks_uri="https://keys.brand.com/.well-known/jwks.json", + key_origins=None, + purpose="request_signing", + ) + assert exc_info.value.code == REQUEST_SIGNATURE_KEY_ORIGIN_MISSING + + +def test_consistency_raises_missing_when_key_origins_is_empty() -> None: + with pytest.raises(SignatureVerificationError) as exc_info: + check_key_origin_consistency( + jwks_uri="https://keys.brand.com/.well-known/jwks.json", + key_origins={}, + purpose="request_signing", + ) + assert exc_info.value.code == REQUEST_SIGNATURE_KEY_ORIGIN_MISSING + + +def test_consistency_missing_includes_posture_when_supplied() -> None: + with pytest.raises(SignatureVerificationError) as exc_info: + check_key_origin_consistency( + jwks_uri="https://keys.brand.com/.well-known/jwks.json", + key_origins={}, + purpose="request_signing", + posture="required", + ) + assert "posture=required" in str(exc_info.value) + + +# ----- mismatch path ----- + + +def test_consistency_raises_mismatch_on_different_host() -> None: + with pytest.raises(SignatureVerificationError) as exc_info: + check_key_origin_consistency( + jwks_uri="https://attacker.example.org/.well-known/jwks.json", + key_origins={"request_signing": "https://keys.brand.com"}, + purpose="request_signing", + ) + assert exc_info.value.code == REQUEST_SIGNATURE_KEY_ORIGIN_MISMATCH + + +def test_consistency_raises_mismatch_on_subdomain_drift() -> None: + # A subdomain is not the declared origin — origins are exact hosts, + # not eTLD+1 (host-bound JWKS prevents lateral movement within an + # operator's own subdomains). + with pytest.raises(SignatureVerificationError) as exc_info: + check_key_origin_consistency( + jwks_uri="https://other-tenant.brand.com/.well-known/jwks.json", + key_origins={"request_signing": "https://keys.brand.com"}, + purpose="request_signing", + ) + assert exc_info.value.code == REQUEST_SIGNATURE_KEY_ORIGIN_MISMATCH + + +def test_consistency_raises_mismatch_on_invalid_jwks_uri() -> None: + # An unparseable ``jwks_uri`` cannot match anything; fail closed. + with pytest.raises(SignatureVerificationError) as exc_info: + check_key_origin_consistency( + jwks_uri="not a url", + key_origins={"request_signing": "https://keys.brand.com"}, + purpose="request_signing", + ) + assert exc_info.value.code == REQUEST_SIGNATURE_KEY_ORIGIN_MISMATCH + + +# ----- webhook code family ----- + + +def test_consistency_webhook_family_uses_webhook_codes() -> None: + with pytest.raises(SignatureVerificationError) as exc_info: + check_key_origin_consistency( + jwks_uri="https://attacker.example.org/.well-known/jwks.json", + key_origins={"webhook_signing": "https://keys.brand.com"}, + purpose="webhook_signing", + code_family="webhook", + ) + assert exc_info.value.code == WEBHOOK_SIGNATURE_KEY_ORIGIN_MISMATCH + + +def test_consistency_webhook_family_missing_uses_webhook_code() -> None: + with pytest.raises(SignatureVerificationError) as exc_info: + check_key_origin_consistency( + jwks_uri="https://keys.brand.com/.well-known/jwks.json", + key_origins={}, + purpose="webhook_signing", + code_family="webhook", + ) + assert exc_info.value.code == WEBHOOK_SIGNATURE_KEY_ORIGIN_MISSING From c0ed690c0290f5daed2d2b28bd31ed8b4168f7b2 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 21 May 2026 12:07:18 -0400 Subject: [PATCH 2/4] fix(signing): expert-review fixes for key_origins consistency check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds three expert review passes into the stage 4 PR (ad-tech-protocol-expert, code-reviewer, security-reviewer all run in parallel on the original commit 62d23796). **1. Spec-mandated structured detail fields (MUST-FIX, protocol expert)** ADCP #3690 security.mdx step 7 mandates that ``request_signature_key_origin_mismatch`` carry ``{purpose, expected_origin, actual_origin}`` and ``_key_origin_missing`` carry ``{purpose, posture}`` as structured fields, not just an opaque message string. Middleware adapters surface them on the 401 / in a DLQ; without this, the SDK can't be spec- conformant once Stage 5 wires the verifier. - Extend ``SignatureVerificationError`` with optional ``detail: Mapping[str, str] | None``. ``str(exc)`` still renders the free-form message for unstructured logs; structured callers read ``exc.detail``. - ``check_key_origin_consistency`` now passes the spec-mandated keys. - Two new tests pin the detail shape for both code paths. **2. Bare-host vs URL canonicalization asymmetry (HIGH, security reviewer)** The previous ``_origin_host`` fallback path for bare-host inputs only guarded against ``/`` and space — accepting ``user@host``, ``host:port``, ``host?query``, ``host#fragment`` verbatim. An attacker with capability-write access could declare a bare-host-with-port (``keys.brand.com:8443``) to force a mismatch against the operator's brand.json origin and DoS the honest verification path. Fix: factor a ``_extract_host`` helper that re-parses bare-host inputs through ``urlsplit`` with a synthetic ``https://`` scheme prepended. URL form and bare-host form now strip port / userinfo / query / fragment symmetrically. Two new tests cover ``host:port`` and ``user@host`` declaration shapes. **3. Trailing-dot FQDN asymmetry (HIGH, security reviewer)** ``host.example.`` and ``host.example`` are the same FQDN — the trailing dot denotes the root zone. Previous code preserved the dot, so a brand.json serving the dot form against a capability declaring the no-dot form (or vice versa) byte-mismatched. An attacker controlling capabilities could weaponize this to deny verification against the real counterparty. Fix: strip a single trailing dot before IDNA-encoding. Test covers both directions. **4. IDN U-label vs A-label equivalence test (NICE, all three)** The docstring promised IDN canonicalization but no test exercised it. Added a test using ``münchen.example`` ↔ ``xn--mnchen-3ya.example`` in both directions. **5. Carve-out for publisher-pin source in function docstring (MEDIUM, security)** Previously the publisher-pin skip was documented only in the module docstring. A caller reading just the function doc would miss it. Added a "Caller contract" paragraph at the top of the function docstring flagging that callers MUST skip this call for publisher-pinned tuples. **6. Symmetric fail-closed test on declared side (LOW, security)** The existing ``test_consistency_raises_mismatch_on_invalid_jwks_uri`` covers the resolved side but not the declared side. Added a symmetric test so a future refactor can't silently invert the fail direction on one side. **Deferred to follow-up issues** (not in scope for this PR): - Plumbing the ``source`` discriminant ("brand.json walk" vs "publisher adagents.json pin") through ``BrandJsonJwksResolver`` so Stage 5 can enforce the carve-out automatically. Belongs in the Stage 5 verifier-integration PR. - Migrating all four codebase callsites (jwks.py, ip_pinned_transport.py, revocation_fetcher.py, key_origins.py) from stdlib IDNA-2003 to the ``idna`` PyPI package's IDNA-2008 in one commit. Package-wide conformance pass, separate concern. Tests: 20 in test_key_origins (up from 12). Full signing surface (582 tests across tests/test_*.py + tests/conformance/signing/) remains green. ruff + mypy clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/adcp/signing/errors.py | 16 +++- src/adcp/signing/key_origins.py | 96 +++++++++++++++++++---- tests/test_key_origins.py | 133 ++++++++++++++++++++++++++++++++ 3 files changed, 230 insertions(+), 15 deletions(-) diff --git a/src/adcp/signing/errors.py b/src/adcp/signing/errors.py index d92feb3ce..801f21382 100644 --- a/src/adcp/signing/errors.py +++ b/src/adcp/signing/errors.py @@ -7,9 +7,21 @@ from __future__ import annotations +from collections.abc import Mapping + class SignatureVerificationError(Exception): - """Raised when a request signature fails any step of the verifier checklist.""" + """Raised when a request signature fails any step of the verifier checklist. + + ``detail`` carries the spec-mandated structured fields for codes that + require them — e.g. ``request_signature_key_origin_mismatch`` carries + ``{purpose, expected_origin, actual_origin}`` per ADCP #3690 + security.mdx step 7, and ``request_signature_brand_json_url_missing`` + carries ``{agent_url}`` per the same section's rejection-code table. + Middleware adapters surface these as structured fields on the 401 + response or in a DLQ payload; ``str(exc)`` continues to render the + free-form message for unstructured logs. + """ def __init__( self, @@ -17,10 +29,12 @@ def __init__( *, step: int | str | None = None, message: str | None = None, + detail: Mapping[str, str] | None = None, ) -> None: super().__init__(message or code) self.code = code self.step = step + self.detail = dict(detail) if detail is not None else None REQUEST_SIGNATURE_REQUIRED = "request_signature_required" diff --git a/src/adcp/signing/key_origins.py b/src/adcp/signing/key_origins.py index c361e9153..1a819dce9 100644 --- a/src/adcp/signing/key_origins.py +++ b/src/adcp/signing/key_origins.py @@ -74,6 +74,16 @@ def check_key_origin_consistency( """Verify that the resolved ``jwks_uri`` host matches the declared ``identity.key_origins.{purpose}``. + **Caller contract: skip this call for publisher-pinned JWKS sources.** + Per ADCP #3690 the consistency check is mandatory only when the JWKS + source for the (agent, purpose, role) tuple was the operator + brand.json. For tuples sourced from a publisher + ``adagents.json signing_keys`` pin, the JWKS origin is the + publisher's domain by design — invoking this check on a + publisher-pinned tuple would incorrectly reject a legitimate key. + Callers are responsible for that branching; the helper takes no + ``source`` parameter and will always raise on host disagreement. + Parameters ---------- jwks_uri: @@ -91,7 +101,7 @@ def check_key_origin_consistency( posture: Optional context attached to ``key_origin_missing`` rejection for adopter diagnostics (e.g. ``"required"``, ``"supported"``). - Not consulted by the check itself. + Surfaced as ``detail['posture']`` and in the message. code_family: ``"request"`` (default) or ``"webhook"``. Picks the corresponding spec error code family. @@ -100,12 +110,20 @@ def check_key_origin_consistency( ------ SignatureVerificationError With ``code = *_key_origin_missing`` when ``purpose`` is absent - from ``key_origins``; ``code = *_key_origin_mismatch`` when the - purpose's declared origin differs from the resolved - ``jwks_uri`` host (after IDNA-A-label canonicalization). + from ``key_origins`` — ``detail`` carries ``{purpose, posture}``. + + With ``code = *_key_origin_mismatch`` when the purpose's declared + origin differs from the resolved ``jwks_uri`` host (after IDNA + A-label canonicalization). ``detail`` carries + ``{purpose, expected_origin, actual_origin}`` per the spec's + rejection-code shape — middleware adapters surface these as + structured fields on the 401 / in a DLQ. """ declared = (key_origins or {}).get(purpose) if declared is None: + missing_detail: dict[str, str] = {"purpose": purpose} + if posture: + missing_detail["posture"] = posture raise SignatureVerificationError( _MISSING_CODE[code_family], step=7, @@ -113,6 +131,7 @@ def check_key_origin_consistency( f"identity.key_origins.{purpose} declaration missing" + (f" (posture={posture})" if posture else "") ), + detail=missing_detail, ) actual_host = _origin_host(jwks_uri) @@ -125,6 +144,15 @@ def check_key_origin_consistency( f"identity.key_origins.{purpose} declares {declared_host!r} " f"but resolved jwks_uri host is {actual_host!r}" ), + detail={ + "purpose": purpose, + # Use the canonicalized values when available; fall back + # to the raw inputs for diagnostic accuracy when one + # side failed to canonicalize. Spec wording is + # ``expected_origin`` / ``actual_origin`` verbatim. + "expected_origin": declared_host if declared_host is not None else declared, + "actual_origin": actual_host if actual_host is not None else jwks_uri, + }, ) @@ -142,24 +170,64 @@ def _origin_host(value: str) -> str | None: keeps the canonicalization story coherent. A future IDNA-2008 migration would update all four callsites together. - Returns ``None`` when the input is structurally invalid (no scheme - or no host); callers treat ``None`` as a binding failure. + **Bare-host and URL forms are normalized symmetrically.** A bare + host like ``"keys.brand.com"`` is processed through the same + ``urlsplit`` path as a full URL (with a synthetic scheme prepended) + so port, userinfo, query, and fragment all strip consistently. + Without that synthesis, a declarant supplying + ``"keys.brand.com:8443"`` as a bare host would canonicalize to + ``"keys.brand.com:8443"`` while the matching URL form would + canonicalize to ``"keys.brand.com"`` — a fail-closed asymmetry an + attacker who controls capabilities could exploit to deny + verification against the operator's brand.json origin. + + **Trailing-dot equality.** ``host.example.`` and ``host.example`` + are the same FQDN at the protocol layer (the dot denotes the root + zone). A counterparty serving the dot form while the capability + declares the no-dot form (or vice versa) must not mismatch. We + strip a single trailing dot before IDNA encoding. + + Returns ``None`` when the input is structurally invalid (no + resolvable host, or it parses but contains characters that don't + survive IDNA); callers treat ``None`` as a binding failure. """ - parts = urlsplit(value) - host = parts.hostname + host = _extract_host(value) + if host is None: + return None + host = host.rstrip(".").lower() if not host: - # Permit bare-host inputs like ``"keys.brand.com"`` — - # capabilities ``identity.key_origins`` values are not - # spec-constrained to be full URLs, only to identify an origin. - host = value.strip().lower() - if not host or "/" in host or " " in host: - return None + return None try: return host.encode("idna").decode("ascii").lower() except (UnicodeError, UnicodeEncodeError): return None +def _extract_host(value: str) -> str | None: + """Pull the host portion out of ``value``, accepting both URL form + (``https://keys.brand.com/...``) and bare-host form + (``keys.brand.com``). + + For URL inputs the host comes from ``urlsplit().hostname``. For + bare-host inputs we prepend a synthetic ``https://`` scheme and + re-parse so port / userinfo / query / fragment all strip the same + way they would for an explicit URL — closing the bare-host vs URL + asymmetry that the bare-host fallback used to have. + """ + parts = urlsplit(value) + if parts.hostname: + return parts.hostname + + # Schemeless input. Prepend ``https://`` and re-parse. + # Strip whitespace first so leading-space inputs don't produce + # ``https:// foo.com`` which then fails to parse a host. + stripped = value.strip() + if not stripped: + return None + parts = urlsplit(f"https://{stripped}") + return parts.hostname or None + + __all__ = [ "CodeFamily", "check_key_origin_consistency", diff --git a/tests/test_key_origins.py b/tests/test_key_origins.py index ddbb51b3a..3cfabe357 100644 --- a/tests/test_key_origins.py +++ b/tests/test_key_origins.py @@ -162,3 +162,136 @@ def test_consistency_webhook_family_missing_uses_webhook_code() -> None: code_family="webhook", ) assert exc_info.value.code == WEBHOOK_SIGNATURE_KEY_ORIGIN_MISSING + + +# ----- spec-mandated structured detail fields ----- + + +def test_consistency_mismatch_carries_structured_detail() -> None: + # Spec #3690 step 7: ``request_signature_key_origin_mismatch`` MUST + # carry ``{purpose, expected_origin, actual_origin}`` as structured + # fields, not just an opaque message string. Middleware adapters + # surface them on the 401 / in a DLQ. + with pytest.raises(SignatureVerificationError) as exc_info: + check_key_origin_consistency( + jwks_uri="https://attacker.example.org/.well-known/jwks.json", + key_origins={"request_signing": "https://keys.brand.com"}, + purpose="request_signing", + ) + detail = exc_info.value.detail + assert detail is not None + assert detail["purpose"] == "request_signing" + assert detail["expected_origin"] == "keys.brand.com" + assert detail["actual_origin"] == "attacker.example.org" + + +def test_consistency_missing_carries_structured_detail() -> None: + # Spec #3690 step 7: ``_key_origin_missing`` MUST carry + # ``{purpose, posture}``. + with pytest.raises(SignatureVerificationError) as exc_info: + check_key_origin_consistency( + jwks_uri="https://keys.brand.com/.well-known/jwks.json", + key_origins={}, + purpose="request_signing", + posture="required", + ) + detail = exc_info.value.detail + assert detail is not None + assert detail["purpose"] == "request_signing" + assert detail["posture"] == "required" + + +def test_consistency_missing_detail_omits_posture_when_unsupplied() -> None: + # ``posture`` is optional; when the caller doesn't pass one, the + # detail dict carries only ``purpose``. Adapters reading + # ``detail.get("posture")`` see ``None`` rather than an empty string. + with pytest.raises(SignatureVerificationError) as exc_info: + check_key_origin_consistency( + jwks_uri="https://keys.brand.com/.well-known/jwks.json", + key_origins={}, + purpose="request_signing", + ) + detail = exc_info.value.detail + assert detail is not None + assert detail == {"purpose": "request_signing"} + + +# ----- canonicalization edge cases (regressions for reviewer findings) ----- + + +def test_consistency_trailing_fqdn_dot_compares_equal_either_side() -> None: + # ``host.example.`` and ``host.example`` are the same FQDN at the + # protocol layer (the dot denotes the root zone). An attacker who + # controls capabilities could otherwise declare the dot form while + # the brand.json serves the no-dot form (or vice versa) and weaponize + # the check to deny verification against the legitimate counterparty. + # Both directions must compare equal. + check_key_origin_consistency( + jwks_uri="https://keys.brand.com./jwks.json", # trailing dot + key_origins={"request_signing": "https://keys.brand.com"}, + purpose="request_signing", + ) + check_key_origin_consistency( + jwks_uri="https://keys.brand.com/jwks.json", + key_origins={"request_signing": "https://keys.brand.com."}, # trailing dot + purpose="request_signing", + ) + + +def test_consistency_bare_host_with_port_normalizes_symmetrically() -> None: + # Capability declaring ``keys.brand.com:8443`` (bare host with port) + # must normalize the same way the URL form would — stripping the + # port — so it compares equal to a resolved jwks_uri of + # ``https://keys.brand.com/...``. Without symmetric normalization, + # an attacker with capability-write access could supply a + # bare-host-with-port declaration to force a mismatch against the + # operator's brand.json origin. + check_key_origin_consistency( + jwks_uri="https://keys.brand.com/.well-known/jwks.json", + key_origins={"request_signing": "keys.brand.com:8443"}, + purpose="request_signing", + ) + + +def test_consistency_bare_host_with_userinfo_rejects_or_normalizes() -> None: + # Declarations with userinfo (``user@host``) are spec-suspicious; + # the helper must NOT accidentally accept the host portion while + # ignoring the user. Symmetric urlsplit-based normalization strips + # userinfo the same way it does for URL inputs, so the comparison + # collapses to ``host == host``. + check_key_origin_consistency( + jwks_uri="https://keys.brand.com/.well-known/jwks.json", + key_origins={"request_signing": "user@keys.brand.com"}, + purpose="request_signing", + ) + + +def test_consistency_idn_a_label_equals_u_label() -> None: + # IDN U-label (``münchen.example``) and A-label (Punycode + # ``xn--mnchen-3ya.example``) refer to the same host. Canonicalization + # to A-label via ``host.encode("idna")`` must make them compare equal + # regardless of which form each side uses. + check_key_origin_consistency( + jwks_uri="https://xn--mnchen-3ya.example/.well-known/jwks.json", + key_origins={"request_signing": "münchen.example"}, + purpose="request_signing", + ) + check_key_origin_consistency( + jwks_uri="https://münchen.example/.well-known/jwks.json", + key_origins={"request_signing": "xn--mnchen-3ya.example"}, + purpose="request_signing", + ) + + +def test_consistency_unparseable_declared_origin_fails_closed() -> None: + # Symmetric to ``test_consistency_raises_mismatch_on_invalid_jwks_uri`` + # but with the unparseable string on the *declared* side. A future + # refactor must not silently invert the fail direction — both sides + # must fail closed. + with pytest.raises(SignatureVerificationError) as exc_info: + check_key_origin_consistency( + jwks_uri="https://keys.brand.com/.well-known/jwks.json", + key_origins={"request_signing": "not a host"}, + purpose="request_signing", + ) + assert exc_info.value.code == REQUEST_SIGNATURE_KEY_ORIGIN_MISMATCH From c9ea04ee38935e46ef00604a56737e97e45fd7df Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 21 May 2026 12:19:53 -0400 Subject: [PATCH 3/4] ci(storyboard): vendor AAO reference-formats fixture into @adcp/sdk install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of the four storyboard runners (seller_agent.py, sales-proposal-mode, v3 reference seller (translator)) have been failing on main and on every PR for the same upstream reason: ``@adcp/sdk@latest`` (which the storyboard jobs install unpinned for drift-detection) does not ship ``aao-reference-formats.json`` in its npm tarball. Every step that touches AAO format resolution rejects with: AAO catalog (reference-formats.json) not found. Looked in: .../node_modules/@adcp/sdk/test/lib/v2-projection-fixtures/aao-reference-formats.json .../node_modules/@adcp/sdk/.context/adcp-3307/server/src/creative-agent/reference-formats.json Vendor a copy at test/lib/v2-projection-fixtures/aao-reference-formats.json. The SDK's error message itself recommends vendoring the file at the expected path. Upstream tracking issue is adcp#3307. Fix: - Vendor the file at ``tests/fixtures/aao-reference-formats.json`` (sourced from ``adcontextprotocol/adcp:server/src/creative-agent/reference-formats.json``, the canonical reference catalog the SDK is supposed to ship). - Add three lines to each of the four storyboard jobs' ``Pre-install @adcp/sdk`` steps to drop the file into ``$(npm root -g)/@adcp/sdk/test/lib/v2-projection-fixtures/`` after the npm install. Idempotent — if upstream later ships the file the ``cp`` overwrites with the same bytes; if upstream moves it the overwrite is a safer floor than the missing-file failure. The four storyboard jobs affected: - AdCP storyboard runner — examples/seller_agent.py - AdCP storyboard runner — examples/multi_platform_seller (PlatformRouter) (currently passing — doesn't exercise the AAO path — but covered for symmetry against future drift) - AdCP storyboard runner — v3 reference seller (translator) - AdCP storyboard runner — sales-proposal-mode (proposal_finalize) The vendored file is 145 KiB JSON with 50 format entries. Not committed to the package distribution (lives under ``tests/fixtures/``). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 28 + tests/fixtures/aao-reference-formats.json | 5245 +++++++++++++++++++++ 2 files changed, 5273 insertions(+) create mode 100644 tests/fixtures/aao-reference-formats.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35f49c762..778cd381a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -391,9 +391,22 @@ jobs: # own CI running AdCP's own canonical runner — tracking latest # surfaces protocol drift as soon as it ships, which is the # point of this job. + # + # Vendor the AAO reference-formats fixture into the SDK install: + # ``@adcp/sdk`` does not ship ``aao-reference-formats.json`` in + # its npm tarball (upstream adcontextprotocol/adcp#3307), so the + # AAO catalog lookup fails for every storyboard step that touches + # creative-format resolution. The SDK's own error message + # recommends vendoring the file at this exact path. We keep a + # copy under ``tests/fixtures/`` and drop it into the SDK's + # expected location post-install; idempotent if upstream later + # ships it. run: | npm install -g @adcp/sdk@latest adcp --version + SDK_FIXTURE_DIR="$(npm root -g)/@adcp/sdk/test/lib/v2-projection-fixtures" + mkdir -p "${SDK_FIXTURE_DIR}" + cp tests/fixtures/aao-reference-formats.json "${SDK_FIXTURE_DIR}/aao-reference-formats.json" - name: Install dependencies run: | @@ -548,9 +561,14 @@ jobs: pip install "sqlalchemy>=2.0" "asyncpg>=0.29" "respx>=0.20" - name: Pre-install @adcp/sdk (once, then call binary directly) + # See the comment on the storyboard job's install step for the + # AAO reference-formats fixture rationale (upstream adcp#3307). run: | npm install -g @adcp/sdk@latest adcp --version + SDK_FIXTURE_DIR="$(npm root -g)/@adcp/sdk/test/lib/v2-projection-fixtures" + mkdir -p "${SDK_FIXTURE_DIR}" + cp tests/fixtures/aao-reference-formats.json "${SDK_FIXTURE_DIR}/aao-reference-formats.json" - name: Start JS mock-server upstream run: | @@ -757,9 +775,14 @@ jobs: ${{ runner.os }}-npm- - name: Pre-install @adcp/sdk + # See the comment on the storyboard job's install step for the + # AAO reference-formats fixture rationale (upstream adcp#3307). run: | npm install -g @adcp/sdk@latest adcp --version + SDK_FIXTURE_DIR="$(npm root -g)/@adcp/sdk/test/lib/v2-projection-fixtures" + mkdir -p "${SDK_FIXTURE_DIR}" + cp tests/fixtures/aao-reference-formats.json "${SDK_FIXTURE_DIR}/aao-reference-formats.json" - name: Install dependencies run: | @@ -858,9 +881,14 @@ jobs: ${{ runner.os }}-npm- - name: Pre-install @adcp/sdk + # See the comment on the storyboard job's install step for the + # AAO reference-formats fixture rationale (upstream adcp#3307). run: | npm install -g @adcp/sdk@latest adcp --version + SDK_FIXTURE_DIR="$(npm root -g)/@adcp/sdk/test/lib/v2-projection-fixtures" + mkdir -p "${SDK_FIXTURE_DIR}" + cp tests/fixtures/aao-reference-formats.json "${SDK_FIXTURE_DIR}/aao-reference-formats.json" - name: Install dependencies run: | diff --git a/tests/fixtures/aao-reference-formats.json b/tests/fixtures/aao-reference-formats.json new file mode 100644 index 000000000..31672eb12 --- /dev/null +++ b/tests/fixtures/aao-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" + } + } +] From 649a42998766cc19194573ba2d98d1cbff557cf1 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 21 May 2026 12:31:58 -0400 Subject: [PATCH 4/4] ci(storyboard): also vendor v1-canonical-mapping schema fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AAO reference-formats fix in the previous commit cleared one of the two missing-fixture errors and unmasked a second one of the same shape: v1-canonical-mapping.json not found. Looked in: .../node_modules/@adcp/sdk/schemas/cache/3.1.0-beta.2/registries/v1-canonical-mapping.json .../node_modules/@adcp/sdk/schemas/cache/3.1.0-beta.1/registries/v1-canonical-mapping.json .../node_modules/@adcp/sdk/schemas/cache/3.1.0-beta.0/registries/v1-canonical-mapping.json .../node_modules/@adcp/sdk/schemas/cache/latest/registries/v1-canonical-mapping.json Run `npm run sync-schemas` for a 3.1+ AdCP version. Same root cause: ``@adcp/sdk`` published an npm tarball that omits the v1-canonical-mapping schema cache for 3.1.0-beta.* versions. The storyboard runner's v1→v2 format projection walks this registry; missing the file causes a cascade of step failures (and incorrectly-routed error codes like ``PRODUCT_NOT_FOUND`` instead of ``TERMS_REJECTED`` because the canonical mapping is consulted before product lookup). Vendor ``v1-canonical-mapping.json`` from ``adcontextprotocol/adcp:dist/schemas/3.1.0-beta.2/registries/`` (14 KiB) into ``tests/fixtures/`` and drop it into the SDK install path the SDK looks up first (the SDK's lookup order falls back through .2 → .1 → .0 → latest, so vendoring .2 is sufficient). Same four storyboard jobs as the prior fix: - examples/seller_agent.py - examples/multi_platform_seller (PlatformRouter) - v3 reference seller (translator) - sales-proposal-mode (proposal_finalize) Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 51 +++++---- tests/fixtures/v1-canonical-mapping.json | 140 +++++++++++++++++++++++ 2 files changed, 170 insertions(+), 21 deletions(-) create mode 100644 tests/fixtures/v1-canonical-mapping.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 778cd381a..011f2b8c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -392,21 +392,24 @@ jobs: # surfaces protocol drift as soon as it ships, which is the # point of this job. # - # Vendor the AAO reference-formats fixture into the SDK install: - # ``@adcp/sdk`` does not ship ``aao-reference-formats.json`` in - # its npm tarball (upstream adcontextprotocol/adcp#3307), so the - # AAO catalog lookup fails for every storyboard step that touches - # creative-format resolution. The SDK's own error message - # recommends vendoring the file at this exact path. We keep a - # copy under ``tests/fixtures/`` and drop it into the SDK's - # expected location post-install; idempotent if upstream later - # ships it. + # Vendor missing fixtures into the SDK install: + # ``@adcp/sdk`` does not ship two fixtures its storyboard runner + # needs — ``aao-reference-formats.json`` (AAO format catalog, + # upstream adcontextprotocol/adcp#3307) and + # ``v1-canonical-mapping.json`` (v1→v2 format mapping registry + # at schemas/cache/3.1.0-beta.2/registries/). The SDK's own + # error messages recommend vendoring both at the exact paths + # below. We keep copies under ``tests/fixtures/`` and drop them + # into the SDK's expected locations post-install; idempotent if + # upstream later ships them in the npm tarball. run: | npm install -g @adcp/sdk@latest adcp --version - SDK_FIXTURE_DIR="$(npm root -g)/@adcp/sdk/test/lib/v2-projection-fixtures" - mkdir -p "${SDK_FIXTURE_DIR}" - cp tests/fixtures/aao-reference-formats.json "${SDK_FIXTURE_DIR}/aao-reference-formats.json" + SDK_ROOT="$(npm root -g)/@adcp/sdk" + mkdir -p "${SDK_ROOT}/test/lib/v2-projection-fixtures" + cp tests/fixtures/aao-reference-formats.json "${SDK_ROOT}/test/lib/v2-projection-fixtures/aao-reference-formats.json" + mkdir -p "${SDK_ROOT}/schemas/cache/3.1.0-beta.2/registries" + cp tests/fixtures/v1-canonical-mapping.json "${SDK_ROOT}/schemas/cache/3.1.0-beta.2/registries/v1-canonical-mapping.json" - name: Install dependencies run: | @@ -566,9 +569,11 @@ jobs: run: | npm install -g @adcp/sdk@latest adcp --version - SDK_FIXTURE_DIR="$(npm root -g)/@adcp/sdk/test/lib/v2-projection-fixtures" - mkdir -p "${SDK_FIXTURE_DIR}" - cp tests/fixtures/aao-reference-formats.json "${SDK_FIXTURE_DIR}/aao-reference-formats.json" + SDK_ROOT="$(npm root -g)/@adcp/sdk" + mkdir -p "${SDK_ROOT}/test/lib/v2-projection-fixtures" + cp tests/fixtures/aao-reference-formats.json "${SDK_ROOT}/test/lib/v2-projection-fixtures/aao-reference-formats.json" + mkdir -p "${SDK_ROOT}/schemas/cache/3.1.0-beta.2/registries" + cp tests/fixtures/v1-canonical-mapping.json "${SDK_ROOT}/schemas/cache/3.1.0-beta.2/registries/v1-canonical-mapping.json" - name: Start JS mock-server upstream run: | @@ -780,9 +785,11 @@ jobs: run: | npm install -g @adcp/sdk@latest adcp --version - SDK_FIXTURE_DIR="$(npm root -g)/@adcp/sdk/test/lib/v2-projection-fixtures" - mkdir -p "${SDK_FIXTURE_DIR}" - cp tests/fixtures/aao-reference-formats.json "${SDK_FIXTURE_DIR}/aao-reference-formats.json" + SDK_ROOT="$(npm root -g)/@adcp/sdk" + mkdir -p "${SDK_ROOT}/test/lib/v2-projection-fixtures" + cp tests/fixtures/aao-reference-formats.json "${SDK_ROOT}/test/lib/v2-projection-fixtures/aao-reference-formats.json" + mkdir -p "${SDK_ROOT}/schemas/cache/3.1.0-beta.2/registries" + cp tests/fixtures/v1-canonical-mapping.json "${SDK_ROOT}/schemas/cache/3.1.0-beta.2/registries/v1-canonical-mapping.json" - name: Install dependencies run: | @@ -886,9 +893,11 @@ jobs: run: | npm install -g @adcp/sdk@latest adcp --version - SDK_FIXTURE_DIR="$(npm root -g)/@adcp/sdk/test/lib/v2-projection-fixtures" - mkdir -p "${SDK_FIXTURE_DIR}" - cp tests/fixtures/aao-reference-formats.json "${SDK_FIXTURE_DIR}/aao-reference-formats.json" + SDK_ROOT="$(npm root -g)/@adcp/sdk" + mkdir -p "${SDK_ROOT}/test/lib/v2-projection-fixtures" + cp tests/fixtures/aao-reference-formats.json "${SDK_ROOT}/test/lib/v2-projection-fixtures/aao-reference-formats.json" + mkdir -p "${SDK_ROOT}/schemas/cache/3.1.0-beta.2/registries" + cp tests/fixtures/v1-canonical-mapping.json "${SDK_ROOT}/schemas/cache/3.1.0-beta.2/registries/v1-canonical-mapping.json" - name: Install dependencies run: | diff --git a/tests/fixtures/v1-canonical-mapping.json b/tests/fixtures/v1-canonical-mapping.json new file mode 100644 index 000000000..bfa4a5425 --- /dev/null +++ b/tests/fixtures/v1-canonical-mapping.json @@ -0,0 +1,140 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/3.1.0-beta.2/registries/v1-canonical-mapping.json", + "title": "v1 → v2 Canonical Format Mapping Registry", + "description": "Authoritative AAO-published mapping from v1 named formats to v2 canonical declarations. Used by SDKs to project the v1 wire shape into v2 canonical declarations during the migration window.\n\n**Direction of truth (normative).** This registry is authoritative for **v1 → v2 projection only**. v2 → v1 projection MUST rely on `v1_format_ref` on the v2 `ProductFormatDeclaration` — sellers assert the v1 pairing explicitly. SDKs MUST NOT synthesize a v1 `format_id` from the registry by inverting structural matches: the registry's `id` slugs lack the `agent_url` half of a `FormatId`, and registry entries with `format_id_glob: '*'` or pure-structural matches have no single literal to invert to. SDKs that synthesize unilaterally produce inter-SDK divergence on structurally-equal values (different SDKs pick different `agent_url` and `id` patterns for the same v2 declaration). When a v2 declaration carries no `v1_format_ref` and a v1-only buyer queries the product, the SDK reports the canonical as v1-unreachable via `FORMAT_DECLARATION_V1_AMBIGUOUS` and lets the buyer surface it to the seller; the seller's path is to add `v1_format_ref` to disambiguate.\n\nBest-effort inversion is not available from this registry: as of 3.1 all published entries are pure-structural (family-level), and structural matches are not invertible to a specific v1 `format_id`. Future versions MAY add literal `format_id_glob` entries for platform-specific v1 conventions; in that case SDKs MAY do best-effort inversion for non-wildcarded literals only, AND only when the seller hasn't authored a contradicting `v1_format_ref` — even then, the projection is non-normative and downstream consumers MUST NOT depend on it.\n\n**Resolution order** (per RFC #3305 amendment #3767, normative):\n1. **Authoritative v2→v1 link**: if any v2 `ProductFormatDeclaration` on the same product carries `v1_format_ref` pointing at this v1 format_id, use that v2 declaration. Highest priority — seller asserts the link directly. SDKs SHOULD run the *narrows* check (canonical-formats.mdx 'Narrows — formal definition') between the v2 declaration's `params` and the referenced v1 format's `requirements`; on conflict, surface `FORMAT_DECLARATION_DIVERGENT` on the `get_products` response `errors[]`. Without the narrowing check, `v1_format_ref` is a hint rather than a contract.\n2. **Seller-asserted on the v1 file**: if the v1 format declaration carries an explicit `canonical` field, use it. (Note: `canonical_parameters` on the v1 file is deprecated for 3.1; SDKs reading 3.1 catalogs MUST still honor it when present, but `v1_format_ref` is the path forward.)\n3. **Registry glob**: look up `format_id` in this registry's `format_id_glob` entries.\n4. **Structural match**: attempt structural match against this registry's `structural` entries. A successful structural match yields a *family-level* identification only (e.g., 'this is a `video_vast`') — it does NOT yield a specific v1 `format_id`. SDKs use structural match for v1 → v2 projection (the inbound direction) but MUST NOT invert it to a v1 `format_id` on the outbound path.\n5. **Ambiguous family**: if step 4 succeeded but the matched entries are family-only (pure structural, no invertible literal), the canonical is **v1-unreachable for THIS specific product** unless the seller authors `v1_format_ref`. SDKs MUST surface `FORMAT_DECLARATION_V1_AMBIGUOUS` via `errors[]` augmentation rather than synthesize a plausible-but-arbitrary v1 `format_id`. Distinct from canonical-level v1-unreachability (a canonical with `v1_translatable: false` — `agent_placement`, `sponsored_placement`, `responsive_creative`, `image_carousel` — never has any v1 form regardless of registry coverage).\n6. **Fail closed**: SDK MUST NOT emit `format_options` for products carrying this format. SDKs MUST augment the response's `errors[]` array with an entry carrying `source: \"sdk\"`, `sdk_id: \"@\"`, `code: \"FORMAT_PROJECTION_FAILED\"`, `field: \"products[N].format_ids[K]\"`, and `error.details: { format_id, product_id, resolution_failure: \"no_explicit_canonical\" | \"no_registry_match\" | \"no_structural_match\" }`. Single mandated surface (`errors[]` augmentation) — lint-output channels are NOT acceptable; the multi-hop agent network needs warnings to propagate across SDK boundaries via the wire response. Logger-only warnings die in DEBUG. The advisory is non-fatal: the response stays 200/success, the product is still valid on the v1 path, only the v2 `format_options` projection is absent. Consumer-side counterpart to the producer SHOULD (sellers should add a v2 declaration with `v1_format_ref`, an explicit `canonical` field, or file a registry PR).\n\n**Match modes:**\n- `format_id_glob` — exact / glob match against the v1 `format_id.id`. **As of 3.1 the registry carries zero literal entries**: AAO-catalog-published formats (display_300x250_image, video_vast_30s, audio_standard_30s, etc.) project via resolution-order step 2 (catalog entry's `canonical:` annotation), and platform-specific formats (e.g., Meta Reels, TikTok Spark Ads) project via structural fallback or via the platform's own adagents.json `formats[]` block (#4620). A future literal entry is only justified when (a) the v1 name carries semantic narrowing not recoverable from slot shape AND (b) no platform-published adagents.json exists; such entries land via the AAO governance PR process with a documented rationale. The whole point of canonical-formats is parametrization: ONE `image` canonical with width/height params, not 8 per-size variants. Glob syntax: `*` matches any segment.\n- `structural` — match against the format's slot shape, asset types, and version constraints. The PRIMARY fallback for v1 wire traffic — catches custom v1 formats (a publisher's `acme_homepage_300x250` is structurally an IAB MREC) without enumerating every possible v1 name. v1 sellers in the wild naming things their own way are handled here, not by literal globs.\n\n**Alias collision precedence (normative).** When a v1 format's `assets[i]` carries multiple `asset_group_id` aliases that resolve to the same canonical asset_group (e.g., two slots both aliasing to `landing_page_url`), the SDK MUST resolve deterministically: the v1 format's `assets[*]` array order is authoritative — the first slot in declaration order wins, subsequent collisions are dropped from the projected v2 manifest and surfaced via `FORMAT_PROJECTION_FAILED` with `error.details: { collision_kind: \"asset_group_id_alias\", asset_group_id, winning_slot_id, dropped_slot_ids }`. SDKs MUST NOT silently pick one and discard the other without surfacing — silent picking creates inter-SDK divergence. Producers SHOULD avoid the collision by deduplicating aliased slots or using distinct `asset_group_id` values when both slots are semantically meaningful.\n\n**Governance**: same vocabulary-governance rules as `asset-group-vocabulary.json` and `format-shape-vocabulary.json` — additions land via PR with rationale + ≥1 reference adopter; AAO maintainer review; versioned + content-digested. Entries are additive; once published they are not removed (they may be marked `deprecated: true` if superseded).\n\n**Initial scope (3.1)**: 7 pure-structural fallback entries covering VAST 4.x / legacy VAST, DAAST 1.x, HTML5 zip bundles, hosted video, hosted audio, and url-shaped display tags. Per-size and per-duration literals (display_300x250_image, video_vast_30s, etc.) are NOT enumerated here — those project via catalog `canonical:` annotation (resolution-order step 2), keeping the registry aligned with the canonical-formats parametrization principle. Platform-specific formats (Meta Reels, TikTok Spark Ads, etc.) project via structural fallback or via the platform's own adagents.json `formats[]` block (#4620). The full v1-format audit dataset (~76% of formats from the 12-platform / 86-format audit in #3305) seeds the long-term roadmap and informs future literal-entry decisions.\n\nDigest the file content (sha256) when emitting in capabilities responses or referencing from SDK output. Buyers cache by `version` + `digest`.", + "version": "1.0.0", + "last_updated": "2026-05-01", + "type": "object", + "required": ["version", "mappings"], + "properties": { + "version": { + "type": "string", + "description": "Semver of this registry. Bumped on every published change." + }, + "last_updated": { + "type": "string", + "format": "date", + "description": "ISO date of the last published change." + }, + "mappings": { + "type": "array", + "description": "Ordered list of v1 → v2 mappings. SDKs apply mappings in order and use the first match.", + "items": { + "type": "object", + "required": ["v1_pattern", "v2"], + "properties": { + "v1_pattern": { + "type": "object", + "description": "Match pattern. Carries either format_id_glob OR structural, not both.", + "oneOf": [ + { + "required": ["format_id_glob"], + "properties": { + "format_id_glob": { + "type": "string", + "description": "Glob pattern matched against v1 format_id.id. Examples: 'iab_mrec_300x250', 'iab_leaderboard_*', 'meta_*_reels'." + } + } + }, + { + "required": ["structural"], + "properties": { + "structural": { + "type": "object", + "description": "Structural match against the format's slot shape, asset types, and version constraints.", + "properties": { + "asset_types": { + "type": "array", + "items": { "type": "string" }, + "description": "Set of asset_type values that must appear in the format's slots (in any order, any count)." + }, + "vast_versions": { + "type": "array", + "items": { "type": "string" }, + "description": "VAST version constraints. Strings like '>=4.0', '4.x', '4.2'." + }, + "daast_versions": { + "type": "array", + "items": { "type": "string" } + }, + "dimensions": { + "type": "object", + "properties": { + "width": { "type": "integer" }, + "height": { "type": "integer" } + } + } + }, + "additionalProperties": true + } + } + } + ] + }, + "v2": { + "type": "object", + "required": ["canonical"], + "properties": { + "canonical": { + "$ref": "/schemas/3.1.0-beta.2/core/canonical-format-kind.json", + "description": "v2 canonical format the v1 pattern projects to." + }, + "parameters": { + "type": "object", + "description": "Optional parameters that narrow the canonical (e.g., width/height, vast_version). When present, become the params on the projected v2 ProductFormatDeclaration. The shape MUST be valid params for the named canonical.", + "additionalProperties": true + } + } + }, + "deprecated": { + "type": "boolean", + "default": false, + "description": "When true, this mapping is retained for backward-compatibility but should not be used for new mappings. SDKs SHOULD emit lint warnings when matching a deprecated entry." + }, + "notes": { + "type": "string", + "description": "Optional human-readable explanation, examples, or rationale." + } + } + } + } + }, + "mappings": [ + { + "v1_pattern": { "structural": { "asset_types": ["vast"], "vast_versions": [">=4.0"] } }, + "v2": { "canonical": "video_vast", "parameters": { "vast_version": "4.2" } }, + "notes": "Any v1 format whose primary asset is a VAST 4.x tag. SDKs should narrow the parameters.vast_version to the lowest common denominator they support." + }, + { + "v1_pattern": { "structural": { "asset_types": ["vast"], "vast_versions": ["3.x", "2.x"] } }, + "v2": { "canonical": "video_vast", "parameters": { "vast_version": "3.0" } }, + "notes": "Legacy VAST 2.x/3.x — projected as video_vast with the lowest-supported version." + }, + { + "v1_pattern": { "structural": { "asset_types": ["daast"], "daast_versions": ["1.0", "1.1"] } }, + "v2": { "canonical": "audio_daast", "parameters": { "daast_version": "1.1" } }, + "notes": "DAAST 1.x audio tag → audio_daast canonical." + }, + { + "v1_pattern": { "structural": { "asset_types": ["zip"] } }, + "v2": { "canonical": "html5" }, + "notes": "Any v1 format whose primary asset is a zip bundle (HTML5 banner). Caller must add platform_extensions for OM-SDK / clickTag specifics from the v1 declaration." + }, + { + "v1_pattern": { "structural": { "asset_types": ["video"] } }, + "v2": { "canonical": "video_hosted" }, + "notes": "v1 format with a hosted video file as its primary asset → video_hosted. Caller infers orientation/dimensions from the v1 slot constraints." + }, + { + "v1_pattern": { "structural": { "asset_types": ["audio"] } }, + "v2": { "canonical": "audio_hosted" }, + "notes": "v1 format with a hosted audio file as its primary asset → audio_hosted. Distinct from audio_daast (which uses tag delivery)." + }, + { + "v1_pattern": { "structural": { "asset_types": ["url"] } }, + "v2": { "canonical": "display_tag" }, + "notes": "Last-resort structural match: v1 format whose primary asset is a URL pointing at a third-party-served creative → display_tag canonical. Lower confidence than other entries — sellers SHOULD declare an explicit `canonical` for url-shaped formats whenever possible." + } + ] +}