diff --git a/.changeset/add-ctv-experience-profiles.md b/.changeset/add-ctv-experience-profiles.md new file mode 100644 index 0000000000..f352ae39a1 --- /dev/null +++ b/.changeset/add-ctv-experience-profiles.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Add AdCP 3.2 CTV experience profiles implementing the IAB CTV Ad Portfolio without new channel-named canonicals. A shared `ctv_ad_experience` vocabulary (menu, pause, screensaver, overlay, squeezeback, in_scene) pairs each experience with the canonical contracts sellers ingest in practice: `native_in_feed` gains a menu profile with a Native 1.2 video slot, `menu_placement` (tile or headline banner), and `focus_behavior`; `video_vast` gains an explicit `creative_type` (linear, nonlinear, either) superseding `linear_required`, with nonlinear required for the five video-backed experiences and per-experience constraints (overlay and squeezeback need 10s minimum duration, in_scene needs 3s and forbids interactivity, pause is unfloored); `image` accepts pause and screensaver (the image-plus-copy contract major pause-ad sellers ingest); `video_hosted` accepts screensaver; `sponsored_placement` accepts catalog-derived menu tiles, squeezeback, and in_scene. Shared `motion_level` (AdCOM attributes 21–23) and `activation_method` (QR, deep link, push, email, tune-in, SMS) vocabularies carry the interaction layer; activations are engagement events, never impressions. Pairings outside the experience matrix fail validation. The IAB OpenRTB/AdCOM signaling mapping (plcmt 5–9, playbackmethod 8–11, motion attributes, Native plcmttype) is documented as a bridge annex. Includes worked menu/pause/overlay examples and compliance vectors. diff --git a/docs/building/verification/conformance.mdx b/docs/building/verification/conformance.mdx index 2b6d876f52..76da9b085b 100644 --- a/docs/building/verification/conformance.mdx +++ b/docs/building/verification/conformance.mdx @@ -137,6 +137,7 @@ Every agent MUST pass every storyboard below. | [`idempotency`](/compliance/latest/universal/idempotency.yaml) | `idempotency_key` scoping, replay semantics, [`IDEMPOTENCY_CONFLICT`](/docs/building/verification/compliance-catalog#error-code-idempotency-conflict), `replayed: true`, declared TTL | | [`read_tool_idempotency`](/compliance/latest/universal/read-tool-idempotency.yaml) | Read-only task wrappers and the 3.x [`get_products`](/docs/media-buy/task-reference/get_products) compatibility facade accept an optional `idempotency_key`; verifies the keyless capabilities bootstrap | | [`canonical_format_validate_input`](/compliance/latest/universal/canonical-format-validate-input.yaml) | Canonical-format [`validate_input`](https://adcontextprotocol.org/schemas/v3/creative/validate-input-request.json) result semantics: structural pass/fail across required slots and `unvalidatable_nondeterministic` for seeded product declarations. Gated on agents that advertise `validate_input`; the seeded-product branch also requires `comply_test_controller` seeding support. | +| [`ctv_experience_validate_input`](/compliance/latest/universal/ctv-experience-validate-input.yaml) | [CTV experience profile](/docs/creative/ctv-experiences) `validate_input` semantics: the six `ctv_ad_experience` values against their permitted canonicals, the `overlay`/`squeezeback` duration floor, `in_scene`'s no-interactivity rule, and the menu focus/video slot pairing. Gated on agents that advertise `validate_input` and `comply_test_controller` seeding support. | | [`security_baseline`](/compliance/latest/universal/security.yaml) | Unauth rejection, static-credential enforcement (Bearer API key or HTTP Basic), OAuth discovery + RFC 9728 audience binding | | [`oauth_setup`](/compliance/latest/universal/oauth-setup.yaml) | Complete RFC 9728/RFC 8414 discovery graph, issuer/resource consistency, and passive endpoint reachability — skipped unless `oauth.supported: true` | | [`trusted_match_publisher_authentication`](/compliance/latest/universal/trusted-match-publisher-authentication.yaml) | TMP router deployment authentication — selected only for agents declaring `trusted_match.core` in `experimental_features`; a credential-profile-neutral runner sends schema-valid Context Match and Identity Match requests with absent and invalid credentials and requires HTTP `401` with `WWW-Authenticate`; a selected deployment with unavailable probe configuration produces `requirement_unmet` | diff --git a/docs/creative/channels/ctv.mdx b/docs/creative/channels/ctv.mdx index 046121e893..8437af5140 100644 --- a/docs/creative/channels/ctv.mdx +++ b/docs/creative/channels/ctv.mdx @@ -30,4 +30,6 @@ For CSAI or third-party delivery, use `video_vast` and a required `vast_tag` slo Preview on the seller/publisher endpoint when device chrome, companion placement, ad-pod context, or remote-control interaction matters. The impression-time CTV renderer remains the seller runtime behind `sync_creatives`. +Beyond linear video, the IAB CTV Ad Portfolio's home-screen and on-glass experiences — Menu, Pause, Screensaver, Overlay, Squeezeback, and In-Scene — layer a `ctv_ad_experience` declaration onto `native_in_feed`, `image`, `video_hosted`, `video_vast`, and `sponsored_placement`. See [CTV experience profiles](/docs/creative/ctv-experiences) for the experience-to-canonical matrix, per-experience duration and interactivity constraints, and the OpenRTB/AdCOM signaling bridge. + See [Video](/docs/creative/channels/video), [Multi-agent orchestration](/docs/creative/multi-agent-orchestration), and [Creative delivery](/docs/creative/task-reference/get_creative_delivery). diff --git a/docs/creative/ctv-experiences.mdx b/docs/creative/ctv-experiences.mdx new file mode 100644 index 0000000000..b81cd443fa --- /dev/null +++ b/docs/creative/ctv-experiences.mdx @@ -0,0 +1,128 @@ +--- +title: CTV Experience Profiles +description: "AdCP 3.2 models the IAB Tech Lab CTV Ad Portfolio's six on-glass experiences as a ctv_ad_experience vocabulary paired with the canonical formats sellers actually ingest." +"og:title": "AdCP — CTV Experience Profiles" +--- + +# CTV Experience Profiles + +The IAB Tech Lab CTV Ad Portfolio defines seven ad experiences for connected-TV home screens and players: Linear, Menu, Pause, Screensaver, Overlay, Squeezeback, and In-Scene. Linear CTV video needs no new modeling — it's ordinary `video_vast` or `video_hosted` with no experience declared. This page covers the other six, which AdCP 3.2 expresses through a shared `ctv_ad_experience` vocabulary layered on existing canonical formats. No `ctv_*` canonical kinds exist; a format option declares at most one experience, and sellers offering several experiences publish sibling format options. + +This is a creative-**supply** contract, not a restatement of IAB's OpenRTB signaling table. The [signaling annex](#openrtb-and-adcom-signaling-annex) below documents that bridge separately, because what a seller signals on the wire (OpenRTB `plcmt`, Native `plcmttype`) and what a buyer ships as a creative manifest are different concerns. See [Canonical formats](/docs/creative/canonical-formats) for the full canonical-format model this page builds on, and [Connected TV](/docs/creative/channels/ctv) for the delivery-model (SSAI/CSAI) guide this page complements. + +## The six experiences + +| Experience | IAB description | +|---|---| +| `menu` | Smart-TV home/menu surfaces — hero tiles and headline banners on the platform's launch screen | +| `pause` | Full-screen unit shown when the viewer pauses content | +| `screensaver` | Ambient looping unit shown on player idle | +| `overlay` | Semi-transparent unit layered over playing content | +| `squeezeback` | Content is visually shrunk and repositioned to share the frame with an ad | +| `in_scene` | Brand integration composited into the content itself (virtual product placement) | + +One format option declares at most one value from this list. + +## Experience-to-canonical matrix + +Validation rejects any `ctv_ad_experience` value on a canonical format that isn't listed below — each of the five listed canonicals enforces its own row via `x-adcp-validation.verifier_constraints.ctv_experience_matrix` schema conditionals; the reference validator's `ctv_experience_matrix` check covers unlisted canonicals (e.g. `html5`) through the semantic layer. The matrix is deliberately plural where the market is: two sellers can both legitimately claim `pause`, one shipping an image and the other a VAST tag. + +| Experience | Permitted canonicals | Why | +|---|---|---| +| `menu` | `native_in_feed` (menu profile, video-capable), `sponsored_placement` (catalog tiles) | IAB signals Menu as OpenRTB Native; Fire TV Feature Rotator and Samsung 1st Screen Masthead are video-mandatory heroes, so the asset-bundle canonical needs a video slot; Fire TV Sponsored Tiles are catalog listings, not asset bundles | +| `pause` | `image` (with copy via `slots_override`), `video_vast` (nonlinear) | Netflix and Disney's largest pause-ad products ingest a raw image plus copy — no VAST tag involved; sellers that do ingest VAST for pause remain covered | +| `screensaver` | `image`, `video_hosted`, `video_vast` (nonlinear) | Seller-composited ambient surfaces accept whichever asset shape the platform renders on idle | +| `overlay` | `native_in_feed` (asset bundle), `video_vast` (nonlinear) | Overlay sellers split on ingestion: asset-bundle sellers take video, logo, imagery, copy, and activation copy without VAST tags (`native_in_feed`); VAST-ingesting pipelines use NonLinearAds (`video_vast`). A seller offering both publishes sibling format options | +| `squeezeback` | `video_vast` (nonlinear), `sponsored_placement` | No public buyer wire format exists for squeezeback; frames are typically seller-composited from a catalog/brief, with `video_vast` covering sellers that do accept a VAST-delivered creative for the squeezed region | +| `in_scene` | `video_vast` (nonlinear), `sponsored_placement` | Brand-placement class; IAB forbids interactivity here regardless of delivery path | + +Linear CTV video declares no `ctv_ad_experience` at all — it's plain `video_vast` or `video_hosted`. + +## Constraint profiles + +Beyond the matrix, each experience carries its own conditional constraints, enforced where noted on the canonical's schema: + +- **`overlay`, `squeezeback`** — minimum duration of 10 seconds (`duration_ms_exact` or the floor of `duration_ms_range` must be at least `10000`). +- **`in_scene`** — minimum 3-second brand-exposure duration, and **no interactivity**: `vpaid_enabled` and `simid_supported` must both be `false` (or absent) on the format option. +- **`pause`** — no duration floor. The unit ends on a viewer or device action (resume, timeout), not a fixed clock. +- **SIMID support is always explicit, never inferred.** A format option sets `simid_supported: true` deliberately; a nonlinear creative that supports SIMID still MUST carry a non-interactive `` or `` fallback so players without SIMID support still render something. + +### `creative_type` and the `linear_required` migration + +`video_vast` format options gained an explicit `creative_type` enum (`linear | nonlinear | either`) that supersedes the older boolean `linear_required`. Any option declaring `ctv_ad_experience` MUST set `creative_type: "nonlinear"` — all five non-`menu` experiences are NonLinearAds. When both fields are present, `creative_type` wins. For options that predate this field: + +- `linear_required: true` with no `creative_type` is read as `creative_type: "linear"`. +- `linear_required: false` with no `creative_type` is read as `creative_type: "either"` — the absence of a linear requirement means either creative class satisfies the option, not that the option somehow became nonlinear-only. + +New format options should set `creative_type` directly and drop `linear_required`. + +## Menu profile on native_in_feed + +`native_in_feed` is the only canonical whose CTV profile lives on an otherwise web/app-native canonical, because IAB signals Menu as OpenRTB Native and the largest platforms (Fire TV, Samsung, Google TV) render menu heroes as video-capable tiles. Three parameters carry the profile, valid only alongside `ctv_ad_experience: "menu"`: + +| Parameter | Values | Maps to | +|---|---|---| +| `menu_placement` | `tile`, `headline_banner` | OpenRTB Native `plcmttype` 1 (tile/feed) and 3 (headline banner) | +| `focus_behavior` | `none`, `autoplay_muted`, `autoplay_sound` | AdCOM `playbackmethod` on the video the remote focus triggers | +| `video` slot | optional VAST document | Native 1.2's `vasttag` asset — the same slot IAB Native uses to carry embedded video, not a separate video canonical | + +A menu hero remains `native_in_feed` rather than `video_hosted` or `video_vast` because the platform assembles the full tile (background image, logo, copy, and the focus-triggered video) as one asset bundle — the video rides inside that bundle rather than being the unit itself. + +`focus_behavior: autoplay_muted` or `autoplay_sound` only makes sense when the format option's effective slots include the `video` asset group. This pairing is documented as a verifier constraint (`focus_video_pairing`) but is **not** enforced by a JSON Schema conditional today — see [Validating CTV manifests](#validating-ctv-manifests) for why, and the note on schema-vs-semantic enforcement in the compliance vectors. + +## Motion level and activation vocabularies + +Two shared vocabularies carry the interaction layer across every CTV-capable canonical. + +**`motion_level`** — one of `static`, `limited_motion`, `full_motion`, mapping 1:1 to the ratified AdCOM creative attributes 21–23. Sellers constrain the accepted level on a format option; buyers declare the level their creative carries. `image` always narrows `motion_level` to `static` or `limited_motion` — `full_motion` maps to Full-Motion Video (AdCOM attr 23), which has no meaning on a static-image canonical regardless of whether a CTV experience is declared. + +**`activation_methods`** — the array of viewer activation mechanisms a format option offers, drawn from `qr_code`, `deep_link`, `push_notification`, `email`, `tune_in`, `text_message`. Formats offering copy-bearing activations declare normative character-limited copy slots via `slots_override`: `push_notification` → `activation_message`; `text_message` → `activation_text_message`; `email` → `activation_email_subject` + `activation_email_body`. Absent a dedicated slot, the activation resolves through the manifest's `landing_page_url`. + +**Activations are engagement events, never impressions.** A viewer scanning a QR code, following a deep link, or responding to a push notification generates an engagement event on top of the one rendered ad impression — it does not create a second impression. See [Measurement](#measurement). + +## Validating CTV manifests + +`ctv_ad_experience`, `menu_placement`, `focus_behavior`, `creative_type`, `motion_level`, and `activation_methods` are all properties of a seller's **format option** — they describe what a product offers, not fields a buyer sets on a creative manifest. A buyer never writes `ctv_ad_experience: "pause"` into a manifest; instead, the buyer selects a product or format option that already declares `pause`, and ships assets that satisfy it. + +This means `validate_input` checks two different things depending on the target: + +- **`targets: [{ kind: "canonical", id: "video_vast" }]`** validates manifest structure only — asset types, VAST delivery shape, and any asset-level hints such as `duration_ms` on the `vast_tag` asset. It cannot evaluate `ctv_ad_experience`, because the manifest doesn't carry it. +- **`targets: [{ kind: "product", id: "..." }]`** validates the manifest against that product's specific format-option declaration, including its experience and duration constraints. A `vast_tag` asset whose `duration_ms` hint is below an `overlay` product's 10-second floor returns `validated_fail` with a `duration_ms_range`-style violation citing the product's declared floor. + +Whether a given format option's `ctv_ad_experience` pairing is legal at all is evaluated when the format option itself is authored, before any buyer manifest exists to check. The enforcement is two-layer: **JSON Schema conditionals** (in the canonical's `allOf`) reject categorically invalid pairings — an unlisted experience value, `menu` on `video_vast`, missing `creative_type: "nonlinear"`, or `vpaid_enabled: true` on `in_scene`; **the semantic validator** (`ctv_experience_matrix` check) enforces everything else — duration floors (overlay/squeezeback ≥ 10 s, in_scene ≥ 3 s), the matrix row for unlisted canonicals such as `html5`, and the `focus_video_pairing` slot check. The `ctv_activation_copy_slots` constraint remains advisory until the validation response has a `warnings[]` channel; it is declared in schema prose but is not currently enforced. Rules appear in one place only: numeric duration constants live exclusively in the semantic validator, not duplicated into schema conditionals. See [Canonical formats — validate_input](/docs/creative/canonical-formats#validation-flow--validate_input) for the general `validate_input` contract. + +## OpenRTB and AdCOM signaling annex + +IAB's own signaling model maps five of the six experiences to OpenRTB Video with VAST NonLinearAds, and Menu to OpenRTB Native. AdCP preserves this mapping as a documentation bridge for AdCP↔OpenRTB adapters; it is not the creative-supply contract described above. + +| AdCOM/OpenRTB field | Values | AdCP experience | +|---|---|---| +| `plcmt` | 5 | `pause` | +| `plcmt` | 6 | `screensaver` | +| `plcmt` | 7 | `overlay` | +| `plcmt` | 8 | `squeezeback` | +| `plcmt` | 9 | `in_scene` | +| `playbackmethod` | 8, 9 | Pause-page-initiated playback, sound on (8) / off (9) | +| `playbackmethod` | 10, 11 | Idle-initiated playback (screensaver-class), sound on (10) / off (11) | +| Creative `attr` | 21 | `motion_level: "static"` | +| Creative `attr` | 22 | `motion_level: "limited_motion"` | +| Creative `attr` | 23 | `motion_level: "full_motion"` | +| Native `plcmttype` | 1 | `menu_placement: "tile"` | +| Native `plcmttype` | 3 | `menu_placement: "headline_banner"` | + +Adapters bridging AdCP to OpenRTB round-trip `pos`, `attr`, and `plcmt` through VAST `` so the experience, motion class, and placement survive a VAST-wrapped delivery path even though VAST itself has no native fields for them. + +## VAST versioning + +`vast_version` supports VAST 2.0 through 4.3. VAST 4.3 ships no XSD of its own — IAB's `vast_4.2.xsd` remains the authoritative schema for 4.3 documents, so a validator MUST NOT reject a tag declaring `version="4.3"` for want of a 4.3-specific schema. VAST 4.4 is deliberately absent from the enum: `vast_4.4.xsd` is still annotated "DRAFT for working group discussion," not a published specification, and AdCP adds it only once IAB publishes a final schema. The NonLinearAds pattern every non-`menu` experience relies on remains valid under 4.2 serialization — sellers don't need 4.3 or a future 4.4 to express `overlay`, `squeezeback`, `in_scene`, `pause`, or `screensaver` creative. + +## Measurement + +Each experience opportunity renders exactly **one** impression. Remote-control selection, QR-code scans, and every value in `activation_methods` are engagement events layered on top of that one impression — they are never counted as additional impressions. + +## See also + +- [Canonical formats](/docs/creative/canonical-formats) — the full canonical-format model, `validate_input` contract, and `ProductFormatDeclaration` shape +- [Connected TV](/docs/creative/channels/ctv) — SSAI/CSAI delivery models and companion-ad guidance +- [Sponsored Placement adapter contracts](/docs/creative/sponsored-placement-adapter-contracts) — per-adopter catalog-driven runtime contracts for the `menu`, `squeezeback`, and `in_scene` rows that route to `sponsored_placement` +- [`validate_input`](/docs/creative/canonical-formats#validation-flow--validate_input) — manifest preflight against canonicals and products diff --git a/docs/snippets/compliance-storyboard-inventory.mdx b/docs/snippets/compliance-storyboard-inventory.mdx index f0c337ab12..aa7957e858 100644 --- a/docs/snippets/compliance-storyboard-inventory.mdx +++ b/docs/snippets/compliance-storyboard-inventory.mdx @@ -20,6 +20,7 @@ description: "Graded universal compliance storyboards that every AdCP agent runs | `collection-lists-pagination-integrity` | Validates the cursor↔has_more invariant by walking a paginated list_collection_lists response from a continuation page to a terminal page. | [YAML](https://adcontextprotocol.org/compliance/latest/universal/collection-lists-pagination-integrity.yaml) | | `comply-controller-mode-gate` | Verifies that a seller exposing comply_test_controller refuses calls from live-mode (non-sandbox) accounts with FORBIDDEN. | [YAML](https://adcontextprotocol.org/compliance/latest/universal/comply-controller-mode-gate.yaml) | | `content-standards-pagination-integrity` | Validates the cursor↔has_more invariant by walking a paginated list_content_standards response from a continuation page to a terminal page. | [YAML](https://adcontextprotocol.org/compliance/latest/universal/content-standards-pagination-integrity.yaml) | +| `ctv-experience-validate-input` | Validates AdCP 3.2 ctv_ad_experience matrix pairings, duration/interactivity constraint profiles, and the menu focus/video pairing via validate_input against seeded CTV products. | [YAML](https://adcontextprotocol.org/compliance/latest/universal/ctv-experience-validate-input.yaml) | | `deterministic-testing` | Uses comply_test_controller to force state transitions and simulate delivery/budget, verifying state machines and reporting. | [YAML](https://adcontextprotocol.org/compliance/latest/universal/deterministic-testing.yaml) | | `error-compliance` | Validates that agents return properly structured AdCP errors with correct codes, recovery hints, and transport bindings. | [YAML](https://adcontextprotocol.org/compliance/latest/universal/error-compliance.yaml) | | `error-compliance-signals` | Validates that signals agents return properly structured AdCP errors with correct codes, recovery hints, and transport bindings. | [YAML](https://adcontextprotocol.org/compliance/latest/universal/error-compliance-signals.yaml) | diff --git a/server/src/training-agent/ctv-experience-matrix.ts b/server/src/training-agent/ctv-experience-matrix.ts new file mode 100644 index 0000000000..57ddaa9ba5 --- /dev/null +++ b/server/src/training-agent/ctv-experience-matrix.ts @@ -0,0 +1,237 @@ +/** + * Semantic validation for AdCP 3.2 CTV ad-experience profiles. + * + * The server does not compile static/schemas/source/formats/canonical/*.json + * with a JSON Schema validator at runtime, so the `x-adcp-validation` + * conditionals authored on video_vast, native_in_feed, image, video_hosted, + * and sponsored_placement (docs/creative/ctv-experiences.mdx) are not + * enforced unless mirrored here. This module is that mirror: the single + * source of truth for CTV experience semantics against a format + * declaration's `params`, called from validateProductTarget in + * task-handlers.ts. + */ + +export type CtvViolation = { + rule: string; + field: string; + expected?: unknown; + predicted?: unknown; +}; + +type EffectiveSlot = { + asset_group_id: string; + asset_type: string; +}; + +/** Permitted ctv_ad_experience values per canonical. Canonicals absent from + * this map (html5, display_tag, image_carousel, audio_hosted, audio_daast, + * responsive_creative, agent_placement, custom, ...) never permit + * ctv_ad_experience — any declared value on those canonicals is a violation. + */ +const CTV_EXPERIENCE_MATRIX: Record = { + video_vast: ['pause', 'screensaver', 'overlay', 'squeezeback', 'in_scene'], + native_in_feed: ['menu', 'overlay'], + image: ['pause', 'screensaver'], + video_hosted: ['screensaver'], + sponsored_placement: ['menu', 'squeezeback', 'in_scene'], +}; + +/** Minimum duration (ms) for video_vast experiences that carry a floor. + * `pause` and `screensaver` have no floor and are omitted. + */ +const VIDEO_VAST_DURATION_FLOOR_MS: Record = { + overlay: 10000, + squeezeback: 10000, + in_scene: 3000, +}; + +function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function meetsDurationFloor(params: Record, floorMs: number): boolean { + const exact = params.duration_ms_exact; + if (typeof exact === 'number' && exact >= floorMs) return true; + const range = params.duration_ms_range; + if (Array.isArray(range) && typeof range[0] === 'number' && range[0] >= floorMs) return true; + return false; +} + +/** + * ctv_experience_matrix: ctv_ad_experience must be one of the values the + * canonical's matrix permits. Canonicals with no entry in the matrix permit + * no experience at all. + */ +export function ctvExperienceMatrixViolation(formatKind: string, params: Record): CtvViolation | null { + const experience = asString(params.ctv_ad_experience); + if (!experience) return null; + const allowed = CTV_EXPERIENCE_MATRIX[formatKind]; + if (allowed?.includes(experience)) return null; + return { + rule: 'ctv_experience_matrix', + field: 'params.ctv_ad_experience', + expected: allowed && allowed.length > 0 ? allowed : `ctv_ad_experience is not permitted on ${formatKind}`, + predicted: experience, + }; +} + +/** + * video_vast per-experience constraints: + * - ctv_ad_experience present -> creative_type required and must be "nonlinear". + * - ctv_duration_floors: overlay/squeezeback >= 10000ms, in_scene >= 3000ms, + * pause/screensaver have no floor. + * - ctv_in_scene_no_interactivity: in_scene forbids vpaid_enabled and + * simid_supported. + * - creative_type_precedence: linear_required: true contradicts + * creative_type nonlinear|either. + */ +export function videoVastCtvViolations(params: Record): CtvViolation[] { + const violations: CtvViolation[] = []; + const experience = asString(params.ctv_ad_experience); + + if (experience) { + const creativeType = params.creative_type; + if (creativeType !== 'nonlinear') { + violations.push({ + rule: 'ctv_experience_matrix', + field: 'params.creative_type', + expected: 'nonlinear', + predicted: creativeType, + }); + } + + const floorMs = VIDEO_VAST_DURATION_FLOOR_MS[experience]; + if (floorMs !== undefined && !meetsDurationFloor(params, floorMs)) { + violations.push({ + rule: 'ctv_duration_floors', + field: params.duration_ms_exact !== undefined ? 'params.duration_ms_exact' : 'params.duration_ms_range', + expected: `duration_ms_exact or duration_ms_range[0] >= ${floorMs}`, + predicted: params.duration_ms_exact ?? params.duration_ms_range, + }); + } + + if (experience === 'in_scene') { + if (params.vpaid_enabled === true) { + violations.push({ + rule: 'ctv_in_scene_no_interactivity', + field: 'params.vpaid_enabled', + expected: false, + predicted: true, + }); + } + if (params.simid_supported === true) { + violations.push({ + rule: 'ctv_in_scene_no_interactivity', + field: 'params.simid_supported', + expected: false, + predicted: true, + }); + } + } + } + + if (params.linear_required === true) { + const creativeType = params.creative_type; + if (creativeType === 'nonlinear' || creativeType === 'either') { + violations.push({ + rule: 'creative_type_precedence', + field: 'params.creative_type', + expected: 'linear, or omit creative_type, when linear_required is true', + predicted: creativeType, + }); + } + } + + return violations; +} + +/** + * native_in_feed menu profile: + * - menu_profile_fields: menu_placement or focus_behavior require + * ctv_ad_experience: "menu". + * - focus_video_pairing: focus_behavior autoplay_muted|autoplay_sound + * requires the effective slots to include a "video" asset group. + * + * The paired "menu + manifest video asset must be asset_type vast" rule is + * enforced by the generic slot-type check in validateManifestSlots once the + * native_in_feed default slots include the video/vast slot (see + * CANONICAL_FORMAT_SLOTS in task-handlers.ts) — no separate check needed here. + */ +export function nativeInFeedCtvViolations( + params: Record, + effectiveSlots: EffectiveSlot[] +): CtvViolation[] { + const violations: CtvViolation[] = []; + const experience = asString(params.ctv_ad_experience); + const menuPlacement = params.menu_placement; + const focusBehavior = asString(params.focus_behavior); + + if ((menuPlacement !== undefined || params.focus_behavior !== undefined) && experience !== 'menu') { + violations.push({ + rule: 'menu_profile_fields', + field: menuPlacement !== undefined ? 'params.menu_placement' : 'params.focus_behavior', + expected: 'ctv_ad_experience: "menu"', + predicted: experience, + }); + } + + if (focusBehavior === 'autoplay_muted' || focusBehavior === 'autoplay_sound') { + const hasVideoSlot = effectiveSlots.some(slot => slot.asset_group_id === 'video'); + if (!hasVideoSlot) { + violations.push({ + rule: 'focus_video_pairing', + field: 'params.focus_behavior', + expected: 'effective slots include a "video" asset group', + predicted: focusBehavior, + }); + } + } + + return violations; +} + +/** + * motion_level_static_canonical: motion_level "full_motion" is rejected on + * the image canonical. + */ +export function imageCtvViolations(params: Record): CtvViolation[] { + if (params.motion_level === 'full_motion') { + return [ + { + rule: 'motion_level_static_canonical', + field: 'params.motion_level', + expected: 'static or limited_motion', + predicted: 'full_motion', + }, + ]; + } + return []; +} + +/** + * Runs every applicable CTV semantic rule for a format declaration and + * returns the combined violation list. `effectiveSlots` is the resolved + * slots[] (params.slots override, or the canonical's default slots). + */ +export function ctvSemanticViolations( + formatKind: string, + params: Record, + effectiveSlots: EffectiveSlot[] +): CtvViolation[] { + const violations: CtvViolation[] = []; + + const matrixViolation = ctvExperienceMatrixViolation(formatKind, params); + if (matrixViolation) violations.push(matrixViolation); + + if (formatKind === 'video_vast') { + violations.push(...videoVastCtvViolations(params)); + } + if (formatKind === 'native_in_feed') { + violations.push(...nativeInFeedCtvViolations(params, effectiveSlots)); + } + if (formatKind === 'image') { + violations.push(...imageCtvViolations(params)); + } + + return violations; +} diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts index 5862b885c6..6ae14d98e6 100644 --- a/server/src/training-agent/task-handlers.ts +++ b/server/src/training-agent/task-handlers.ts @@ -76,6 +76,7 @@ import type { import { CreativeAssetSchema, CreativeManifestSchema, GetProductsRequestSchema } from '@adcp/sdk/schemas'; import { verifyGovernedServiceAuthorization } from './governance-verify.js'; import { getCanonicalBase } from './canonical-base.js'; +import { ctvSemanticViolations } from './ctv-experience-matrix.js'; import { evaluateTrainingProposal, proposalCapabilitiesForProfile, @@ -284,6 +285,7 @@ const CANONICAL_FORMAT_SLOTS: Record = { { asset_group_id: 'display_url', asset_type: 'text' }, { asset_group_id: 'rating', asset_type: 'text' }, { asset_group_id: 'price', asset_type: 'text' }, + { asset_group_id: 'video', asset_type: 'vast' }, { asset_group_id: 'impression_tracker', asset_type: 'pixel_tracker' }, { asset_group_id: 'viewability_tracker', asset_type: 'pixel_tracker' }, { asset_group_id: 'click_tracker', asset_type: 'pixel_tracker' }, @@ -7548,7 +7550,10 @@ function validateProductTarget( ? declaration.params as Record : {}; const slots = normalizeCanonicalSlots(params.slots) ?? CANONICAL_FORMAT_SLOTS[formatKind] ?? []; - const violations = validateManifestSlots(manifest, slots); + const violations = [ + ...validateManifestSlots(manifest, slots), + ...ctvSemanticViolations(formatKind, params, slots), + ]; if (params.synthesis_nondeterministic === true) { const sourceViolation = nondeterministicSourceViolation(params); if (sourceViolation) { diff --git a/server/tests/unit/training-agent.test.ts b/server/tests/unit/training-agent.test.ts index 6ddd3e9234..39549f6904 100644 --- a/server/tests/unit/training-agent.test.ts +++ b/server/tests/unit/training-agent.test.ts @@ -3191,6 +3191,395 @@ describe('validate_input handler', () => { }); }); +// ── validate_input handler: CTV experience profiles (spec #6428) ─── + +describe('validate_input handler: CTV experience profiles', () => { + beforeEach(() => { + invalidateCache(); + clearSessions(); + }); + + afterEach(() => { + clearSessions(); + }); + + let seedCounter = 0; + + async function seedCtvProduct( + server: ReturnType, + formatKind: string, + params: Record, + ): Promise<{ account: Record; productId: string; formatOptionId: string }> { + seedCounter += 1; + const domain = `ctv-experience-${seedCounter}.example`; + const productId = `ctv_experience_product_${seedCounter}`; + const formatOptionId = `ctv_experience_option_${seedCounter}`; + const account = { brand: { domain }, operator: 'pinnacle-agency.example' }; + const seed = await simulateCallTool(server, 'comply_test_controller', { + account, + brand: { domain }, + scenario: 'seed_product', + params: { + product_id: productId, + fixture: { + channels: ['ctv'], + delivery_type: 'guaranteed', + format_options: [{ + format_kind: formatKind, + format_option_id: formatOptionId, + params, + }], + }, + }, + }); + expect(seed.result.success).toBe(true); + return { account, productId, formatOptionId }; + } + + const MANIFEST_ASSETS: Record> = { + video_vast: { + vast_tag: { asset_type: 'vast', delivery_type: 'url', url: 'https://cdn.acme.example/tag.xml' }, + }, + native_in_feed: { + title: { asset_type: 'text', content: 'Menu Hero' }, + advertiser_name: { asset_type: 'text', content: 'Acme' }, + landing_page_url: { asset_type: 'url', url: 'https://acme.example' }, + }, + image: { + image_main: { asset_type: 'image', url: 'https://cdn.acme.example/pause.png', width: 1920, height: 1080 }, + }, + video_hosted: { + video_main: { asset_type: 'video', url: 'https://cdn.acme.example/screensaver.mp4', width: 1920, height: 1080 }, + }, + sponsored_placement: { + source_catalog: { asset_type: 'catalog', type: 'product' }, + }, + html5: { + html5_bundle: { asset_type: 'zip', url: 'https://cdn.acme.example/bundle.zip' }, + }, + }; + + async function validateCtvProduct( + server: ReturnType, + formatKind: string, + seeded: { account: Record; productId: string; formatOptionId: string }, + assetOverrides?: Record, + ) { + const { result } = await simulateCallTool(server, 'validate_input', { + account: seeded.account, + manifest: { + format_kind: formatKind, + format_option_ref: { scope: 'product', format_option_id: seeded.formatOptionId }, + assets: { ...MANIFEST_ASSETS[formatKind], ...assetOverrides }, + }, + targets: [{ kind: 'product', id: seeded.productId }], + }); + return result; + } + + it('rejects menu ctv_ad_experience on video_vast (matrix only permits pause|screensaver|overlay|squeezeback|in_scene)', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const seeded = await seedCtvProduct(server, 'video_vast', { + ctv_ad_experience: 'menu', + creative_type: 'nonlinear', + }); + const result = await validateCtvProduct(server, 'video_vast', seeded); + + const results = result.results as Array>; + expect(results[0].result_kind).toBe('validated_fail'); + expect(results[0].violations).toEqual(expect.arrayContaining([ + expect.objectContaining({ rule: 'ctv_experience_matrix', field: 'params.ctv_ad_experience', predicted: 'menu' }), + ])); + }); + + it('rejects pause ctv_ad_experience on native_in_feed (matrix only permits menu and overlay)', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const seeded = await seedCtvProduct(server, 'native_in_feed', { + ctv_ad_experience: 'pause', + }); + const result = await validateCtvProduct(server, 'native_in_feed', seeded); + + const results = result.results as Array>; + expect(results[0].result_kind).toBe('validated_fail'); + expect(results[0].violations).toEqual(expect.arrayContaining([ + expect.objectContaining({ rule: 'ctv_experience_matrix', field: 'params.ctv_ad_experience', predicted: 'pause' }), + ])); + }); + + it('accepts overlay ctv_ad_experience on native_in_feed (asset-bundle overlay contract)', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const seeded = await seedCtvProduct(server, 'native_in_feed', { + ctv_ad_experience: 'overlay', + activation_methods: ['qr_code'], + }); + const result = await validateCtvProduct(server, 'native_in_feed', seeded); + + const results = result.results as Array>; + expect(results[0].result_kind).toBe('validated_pass'); + }); + + it('accepts pause ctv_ad_experience on image (market-critical pause-frame contract)', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const seeded = await seedCtvProduct(server, 'image', { + ctv_ad_experience: 'pause', + }); + const result = await validateCtvProduct(server, 'image', seeded); + + expect(result.results).toEqual([ + { target: { kind: 'product', id: seeded.productId }, result_kind: 'validated_pass' }, + ]); + }); + + it('accepts overlay on video_vast with creative_type nonlinear and a 15s duration', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const seeded = await seedCtvProduct(server, 'video_vast', { + ctv_ad_experience: 'overlay', + creative_type: 'nonlinear', + duration_ms_exact: 15000, + }); + const result = await validateCtvProduct(server, 'video_vast', seeded); + + expect(result.results).toEqual([ + { target: { kind: 'product', id: seeded.productId }, result_kind: 'validated_pass' }, + ]); + }); + + it('rejects overlay on video_vast when duration is below the 10s floor', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const seeded = await seedCtvProduct(server, 'video_vast', { + ctv_ad_experience: 'overlay', + creative_type: 'nonlinear', + duration_ms_exact: 5000, + }); + const result = await validateCtvProduct(server, 'video_vast', seeded); + + const results = result.results as Array>; + expect(results[0].result_kind).toBe('validated_fail'); + expect(results[0].violations).toEqual(expect.arrayContaining([ + expect.objectContaining({ rule: 'ctv_duration_floors', field: 'params.duration_ms_exact', predicted: 5000 }), + ])); + }); + + it('rejects overlay on video_vast when creative_type is linear', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const seeded = await seedCtvProduct(server, 'video_vast', { + ctv_ad_experience: 'overlay', + creative_type: 'linear', + duration_ms_exact: 15000, + }); + const result = await validateCtvProduct(server, 'video_vast', seeded); + + const results = result.results as Array>; + expect(results[0].result_kind).toBe('validated_fail'); + expect(results[0].violations).toEqual(expect.arrayContaining([ + expect.objectContaining({ rule: 'ctv_experience_matrix', field: 'params.creative_type', predicted: 'linear' }), + ])); + }); + + it('rejects in_scene on video_vast when simid_supported is true', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const seeded = await seedCtvProduct(server, 'video_vast', { + ctv_ad_experience: 'in_scene', + creative_type: 'nonlinear', + duration_ms_exact: 3000, + vpaid_enabled: false, + simid_supported: true, + }); + const result = await validateCtvProduct(server, 'video_vast', seeded); + + const results = result.results as Array>; + expect(results[0].result_kind).toBe('validated_fail'); + expect(results[0].violations).toEqual(expect.arrayContaining([ + expect.objectContaining({ rule: 'ctv_in_scene_no_interactivity', field: 'params.simid_supported', predicted: true }), + ])); + }); + + it('rejects linear_required: true combined with creative_type nonlinear as a contradiction', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const seeded = await seedCtvProduct(server, 'video_vast', { + linear_required: true, + creative_type: 'nonlinear', + }); + const result = await validateCtvProduct(server, 'video_vast', seeded); + + const results = result.results as Array>; + expect(results[0].result_kind).toBe('validated_fail'); + expect(results[0].violations).toEqual(expect.arrayContaining([ + expect.objectContaining({ rule: 'creative_type_precedence', field: 'params.creative_type', predicted: 'nonlinear' }), + ])); + }); + + it('accepts linear_required: true with no creative_type declared (treated as linear)', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const seeded = await seedCtvProduct(server, 'video_vast', { + linear_required: true, + }); + const result = await validateCtvProduct(server, 'video_vast', seeded); + + expect(result.results).toEqual([ + { target: { kind: 'product', id: seeded.productId }, result_kind: 'validated_pass' }, + ]); + }); + + it('rejects focus_behavior autoplay_muted on native_in_feed menu when the effective slots omit video', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const seeded = await seedCtvProduct(server, 'native_in_feed', { + ctv_ad_experience: 'menu', + focus_behavior: 'autoplay_muted', + slots: [ + { asset_group_id: 'title', asset_type: 'text', required: true }, + { asset_group_id: 'advertiser_name', asset_type: 'text', required: true }, + { asset_group_id: 'landing_page_url', asset_type: 'url', required: true }, + ], + }); + const result = await validateCtvProduct(server, 'native_in_feed', seeded); + + const results = result.results as Array>; + expect(results[0].result_kind).toBe('validated_fail'); + expect(results[0].violations).toEqual(expect.arrayContaining([ + expect.objectContaining({ rule: 'focus_video_pairing', field: 'params.focus_behavior', predicted: 'autoplay_muted' }), + ])); + }); + + it('accepts focus_behavior autoplay_muted on native_in_feed menu when the default slots include video', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const seeded = await seedCtvProduct(server, 'native_in_feed', { + ctv_ad_experience: 'menu', + focus_behavior: 'autoplay_muted', + }); + const result = await validateCtvProduct(server, 'native_in_feed', seeded); + + expect(result.results).toEqual([ + { target: { kind: 'product', id: seeded.productId }, result_kind: 'validated_pass' }, + ]); + }); + + it('rejects a menu manifest video asset that is not asset_type vast (Native 1.2 vasttag)', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const seeded = await seedCtvProduct(server, 'native_in_feed', { + ctv_ad_experience: 'menu', + focus_behavior: 'autoplay_muted', + }); + const result = await validateCtvProduct(server, 'native_in_feed', seeded, { + video: { asset_type: 'video', url: 'https://cdn.acme.example/menu-hero.mp4', width: 1920, height: 1080 }, + }); + + const results = result.results as Array>; + expect(results[0].result_kind).toBe('validated_fail'); + expect(results[0].violations).toEqual(expect.arrayContaining([ + expect.objectContaining({ rule: 'asset_type', field: 'assets.video.asset_type', expected: 'vast', predicted: 'video' }), + ])); + }); + + it('rejects menu_placement declared without ctv_ad_experience (schema requires it; semantic layer restates it)', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const seeded = await seedCtvProduct(server, 'native_in_feed', { + menu_placement: 'tile', + }); + const result = await validateCtvProduct(server, 'native_in_feed', seeded); + + const results = result.results as Array>; + expect(results[0].result_kind).toBe('validated_fail'); + expect(results[0].violations).toEqual(expect.arrayContaining([ + expect.objectContaining({ rule: 'menu_profile_fields', field: 'params.menu_placement' }), + ])); + }); + + it('accepts screensaver ctv_ad_experience on video_hosted', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const seeded = await seedCtvProduct(server, 'video_hosted', { + ctv_ad_experience: 'screensaver', + }); + const result = await validateCtvProduct(server, 'video_hosted', seeded); + + expect(result.results).toEqual([ + { target: { kind: 'product', id: seeded.productId }, result_kind: 'validated_pass' }, + ]); + }); + + it('rejects overlay ctv_ad_experience on video_hosted (matrix only permits screensaver)', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const seeded = await seedCtvProduct(server, 'video_hosted', { + ctv_ad_experience: 'overlay', + }); + const result = await validateCtvProduct(server, 'video_hosted', seeded); + + const results = result.results as Array>; + expect(results[0].result_kind).toBe('validated_fail'); + expect(results[0].violations).toEqual(expect.arrayContaining([ + expect.objectContaining({ rule: 'ctv_experience_matrix', field: 'params.ctv_ad_experience', predicted: 'overlay' }), + ])); + }); + + it('accepts squeezeback ctv_ad_experience on sponsored_placement', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const seeded = await seedCtvProduct(server, 'sponsored_placement', { + ctv_ad_experience: 'squeezeback', + }); + const result = await validateCtvProduct(server, 'sponsored_placement', seeded); + + expect(result.results).toEqual([ + { target: { kind: 'product', id: seeded.productId }, result_kind: 'validated_pass' }, + ]); + }); + + it('rejects full_motion motion_level on image (static canonical)', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const seeded = await seedCtvProduct(server, 'image', { + motion_level: 'full_motion', + }); + const result = await validateCtvProduct(server, 'image', seeded); + + const results = result.results as Array>; + expect(results[0].result_kind).toBe('validated_fail'); + expect(results[0].violations).toEqual(expect.arrayContaining([ + expect.objectContaining({ rule: 'motion_level_static_canonical', field: 'params.motion_level', predicted: 'full_motion' }), + ])); + }); + + it('rejects ctv_ad_experience on a canonical with no matrix entry (e.g. html5)', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const seeded = await seedCtvProduct(server, 'html5', { + ctv_ad_experience: 'pause', + }); + const result = await validateCtvProduct(server, 'html5', seeded); + + const results = result.results as Array>; + expect(results[0].result_kind).toBe('validated_fail'); + expect(results[0].violations).toEqual(expect.arrayContaining([ + expect.objectContaining({ rule: 'ctv_experience_matrix', field: 'params.ctv_ad_experience', predicted: 'pause' }), + ])); + }); + + it('does not hard-fail a missing activation copy slot because the profile requirement is advisory', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const seeded = await seedCtvProduct(server, 'video_vast', { + activation_methods: ['push_notification'], + }); + const result = await validateCtvProduct(server, 'video_vast', seeded); + + const results = result.results as Array>; + expect(results[0].result_kind).toBe('validated_pass'); + expect(results[0].violations).toBeUndefined(); + }); + + it('accepts push_notification activation_methods when the activation_message copy slot is declared', async () => { + const server = createTrainingAgentServer(DEFAULT_CTX); + const seeded = await seedCtvProduct(server, 'video_vast', { + activation_methods: ['push_notification'], + slots: [ + { asset_group_id: 'vast_tag', asset_type: 'vast', required: true }, + { asset_group_id: 'activation_message', asset_type: 'text' }, + ], + }); + const result = await validateCtvProduct(server, 'video_vast', seeded); + + expect(result.results).toEqual([ + { target: { kind: 'product', id: seeded.productId }, result_kind: 'validated_pass' }, + ]); + }); +}); + // ── create_media_buy handler ─────────────────────────────────────── describe('create_media_buy handler', () => { diff --git a/static/compliance/source/universal/ctv-experience-validate-input.yaml b/static/compliance/source/universal/ctv-experience-validate-input.yaml new file mode 100644 index 0000000000..a3b270e039 --- /dev/null +++ b/static/compliance/source/universal/ctv-experience-validate-input.yaml @@ -0,0 +1,1152 @@ +# Grading notes (informational — not read by the runner): +# +# Phase `matrix_positive_pairings` is manifest-driven in the ordinary sense: +# each step submits a manifest satisfying a validly-declared CTV product's +# required slots and expects `validated_pass`. +# +# Phase `matrix_defense_in_depth` seeds products whose format_option +# declarations are THEMSELVES invalid against the canonical schema's +# `x-adcp-validation.verifier_constraints` (menu on video_vast, pause on +# native_in_feed, creative_type:linear on an overlay option, a 5-second +# duration on an overlay option, simid_supported on an in_scene option, +# full_motion on a pause image). A conformant seller never actually +# authors these — the canonical JSON Schema's `allOf` conditionals already +# reject the schema-expressible ones at product-authoring time (see +# static/schemas/source/formats/canonical/*.json and +# tests/canonical-negative-fixtures.test.cjs, owned separately from this +# storyboard). Every check in this phase is evaluated against the SEEDED +# PRODUCT'S OWN declared params, not against anything the buyer's manifest +# contributes — validate_input's `manifest` object has no `ctv_ad_experience`, +# `creative_type`, `duration_ms_range`, `simid_supported`, or `motion_level` +# field of its own (see /docs/creative/ctv-experiences#validating-ctv- +# manifests). These steps test whether the agent's validate_input runtime +# independently re-derives each constraint at request time — defense in +# depth against a malformed product declaration reaching a buyer — rather +# than pure manifest structural validation. Grading them requires the +# semantic validate_input implementation this storyboard was written +# against (a fixed CTV_EXPERIENCE_MATRIX plus per-canonical constraint +# checks against `params`), not a generic JSON Schema compiled at request +# time. +# +# Two steps have NO JSON Schema enforcement at all — they are graded purely on +# the runtime's semantic interpretation of the `x-adcp-validation.verifier_constraints` +# prose rules: +# +# `reject_focus_autoplay_without_video_slot` — native_in_feed.json documents +# `focus_video_pairing` in verifier_constraints prose but has no `allOf` +# conditional checking that `focus_behavior: autoplay_*` requires a `video` +# entry in the format option's effective `slots[]`. +# +# `reject_pause_on_html5` — html5.json carries no CTV `allOf` conditional at +# all; the semantic validator's `ctv_experience_matrix` check is the only layer +# that rejects a `ctv_ad_experience` value on a canonical not listed in the +# matrix (such as `html5`). +# +# Duration floors (`reject_overlay_short_duration`) are also semantic-only: +# the numeric thresholds live in `VIDEO_VAST_DURATION_FLOOR_MS` in the +# reference validator, not in a JSON Schema `allOf` conditional, so a schema- +# only validator would not catch a 5-second overlay product declaration. + +id: ctv_experience_validate_input +version: "1.0.0" +title: "CTV experience profile validate_input" +category: schema_validation +summary: "Validates AdCP 3.2 ctv_ad_experience matrix pairings, duration/interactivity constraint profiles, and the menu focus/video pairing via validate_input against seeded CTV products." +track: creative +introduced_in: "3.2" +required_tools: + - validate_input + - comply_test_controller + +requires: + - controller + +narrative: | + AdCP 3.2 expresses the IAB CTV Ad Portfolio's six on-glass experiences + (`menu`, `pause`, `screensaver`, `overlay`, `squeezeback`, `in_scene`) as a + shared `ctv_ad_experience` value on existing canonical formats rather than + as new canonical kinds. Each canonical's schema restricts which experience + values it accepts, and — where applicable — the duration floor, the + interactivity rule, and the motion-level narrowing. + + This storyboard seeds one product per matrix row via `comply_test_controller` + and calls `validate_input` with `targets: [{ kind: "product", id: ... }]` + so the agent under test evaluates the buyer's manifest against that + product's declared CTV parameters. Six pairings validate; eight seeded + products carry an experience/canonical or constraint pairing that never + should have been authored, testing whether the runtime still catches it. + Two of those eight are purely semantic — no JSON Schema enforcement backs + them: the focus/video pairing (`reject_focus_autoplay_without_video_slot`) + and the unlisted-canonical matrix row (`reject_pause_on_html5`, where + `html5` has no CTV allOf conditional at all). + +agent: + interaction_model: creative + capabilities: + - validates_canonical_formats + examples: + - "CTV sales agents that accept canonical v2 manifests for menu, pause, screensaver, overlay, squeezeback, and in_scene inventory" + - "Sales agents that pre-flight product-local CTV format_options" + +caller: + role: buyer_agent + example: "Compliance test harness" + +prerequisites: + description: | + The runner seeds fourteen fictional CTV products for the StreamHaus + publisher via `comply_test_controller`: six with valid experience/canonical + pairings (one per matrix row) and eight with intentionally invalid + pairings or constraint values used only for the defense-in-depth phase + (see the grading notes above). + test_kit: "test-kits/acme-outdoor.yaml" + controller_seeding: true + +fixtures: + products: + - product_id: "ctv_validate_menu_tile" + name: "CTV Validate — Menu Tile" + description: "Seeded video-capable menu tile used to test the menu/native_in_feed matrix pairing." + publisher_properties: + - publisher_domain: "tv.streamhaus.example" + selection_type: "all" + channels: ["ctv"] + delivery_type: "guaranteed" + format_options: + - format_kind: "native_in_feed" + format_option_id: "ctv_validate_menu_tile_option" + params: + ctv_ad_experience: "menu" + menu_placement: "tile" + focus_behavior: "autoplay_muted" + motion_level: "full_motion" + slots: + - { asset_group_id: "title", asset_type: "text", required: true } + - { asset_group_id: "main_image", asset_type: "image", required: true } + - { asset_group_id: "advertiser_name", asset_type: "text", required: true } + - { asset_group_id: "landing_page_url", asset_type: "url", required: true } + - { asset_group_id: "video", asset_type: "vast", required: true } + pricing_options: + - pricing_option_id: "ctv_validate_menu_tile_cpm" + pricing_model: "cpm" + currency: "USD" + fixed_price: 40 + reporting_capabilities: + available_reporting_frequencies: ["daily"] + expected_delay_minutes: 60 + timezone: "UTC" + supports_webhooks: false + available_metrics: ["impressions", "spend"] + date_range_support: "date_range" + + - product_id: "ctv_validate_menu_banner" + name: "CTV Validate — Menu Headline Banner" + description: "Seeded static headline-banner menu tile used to test the menu/native_in_feed matrix pairing without a video slot." + publisher_properties: + - publisher_domain: "tv.streamhaus.example" + selection_type: "all" + channels: ["ctv"] + delivery_type: "guaranteed" + format_options: + - format_kind: "native_in_feed" + format_option_id: "ctv_validate_menu_banner_option" + params: + ctv_ad_experience: "menu" + menu_placement: "headline_banner" + focus_behavior: "none" + motion_level: "static" + slots: + - { asset_group_id: "title", asset_type: "text", required: true } + - { asset_group_id: "main_image", asset_type: "image", required: true } + - { asset_group_id: "advertiser_name", asset_type: "text", required: true } + - { asset_group_id: "landing_page_url", asset_type: "url", required: true } + pricing_options: + - pricing_option_id: "ctv_validate_menu_banner_cpm" + pricing_model: "cpm" + currency: "USD" + fixed_price: 35 + reporting_capabilities: + available_reporting_frequencies: ["daily"] + expected_delay_minutes: 60 + timezone: "UTC" + supports_webhooks: false + available_metrics: ["impressions", "spend"] + date_range_support: "date_range" + + - product_id: "ctv_validate_pause_image" + name: "CTV Validate — Pause Image" + description: "Seeded raw-image pause frame used to test the pause/image matrix pairing." + publisher_properties: + - publisher_domain: "tv.streamhaus.example" + selection_type: "all" + channels: ["ctv"] + delivery_type: "guaranteed" + format_options: + - format_kind: "image" + format_option_id: "ctv_validate_pause_image_option" + params: + ctv_ad_experience: "pause" + motion_level: "static" + width: 1920 + height: 1080 + slots: + - { asset_group_id: "image_main", asset_type: "image", required: true } + pricing_options: + - pricing_option_id: "ctv_validate_pause_image_cpm" + pricing_model: "cpm" + currency: "USD" + fixed_price: 30 + reporting_capabilities: + available_reporting_frequencies: ["daily"] + expected_delay_minutes: 60 + timezone: "UTC" + supports_webhooks: false + available_metrics: ["impressions", "spend"] + date_range_support: "date_range" + + - product_id: "ctv_validate_screensaver_video" + name: "CTV Validate — Screensaver Hosted Video" + description: "Seeded ambient looping hosted-video screensaver used to test the screensaver/video_hosted matrix pairing." + publisher_properties: + - publisher_domain: "tv.streamhaus.example" + selection_type: "all" + channels: ["ctv"] + delivery_type: "guaranteed" + format_options: + - format_kind: "video_hosted" + format_option_id: "ctv_validate_screensaver_video_option" + params: + ctv_ad_experience: "screensaver" + motion_level: "full_motion" + orientation: "horizontal" + slots: + - { asset_group_id: "video_main", asset_type: "video", required: true } + pricing_options: + - pricing_option_id: "ctv_validate_screensaver_video_cpm" + pricing_model: "cpm" + currency: "USD" + fixed_price: 28 + reporting_capabilities: + available_reporting_frequencies: ["daily"] + expected_delay_minutes: 60 + timezone: "UTC" + supports_webhooks: false + available_metrics: ["impressions", "spend"] + date_range_support: "date_range" + + - product_id: "ctv_validate_overlay_vast" + name: "CTV Validate — Overlay NonLinear VAST" + description: "Seeded 10-30 second NonLinear VAST overlay used to test the overlay/video_vast matrix pairing and its duration floor." + publisher_properties: + - publisher_domain: "tv.streamhaus.example" + selection_type: "all" + channels: ["ctv"] + delivery_type: "non_guaranteed" + format_options: + - format_kind: "video_vast" + format_option_id: "ctv_validate_overlay_vast_option" + params: + ctv_ad_experience: "overlay" + creative_type: "nonlinear" + duration_ms_range: [10000, 30000] + slots: + - { asset_group_id: "vast_tag", asset_type: "vast", required: true } + pricing_options: + - pricing_option_id: "ctv_validate_overlay_vast_cpm" + pricing_model: "cpm" + currency: "USD" + floor_price: 20 + reporting_capabilities: + available_reporting_frequencies: ["daily"] + expected_delay_minutes: 60 + timezone: "UTC" + supports_webhooks: false + available_metrics: ["impressions", "spend"] + date_range_support: "date_range" + + - product_id: "ctv_validate_squeezeback_sponsored" + name: "CTV Validate — Squeezeback Sponsored Placement" + description: "Seeded catalog-driven squeezeback placement used to test the squeezeback/sponsored_placement matrix pairing." + publisher_properties: + - publisher_domain: "tv.streamhaus.example" + selection_type: "all" + channels: ["ctv"] + delivery_type: "guaranteed" + format_options: + - format_kind: "sponsored_placement" + format_option_id: "ctv_validate_squeezeback_option" + params: + ctv_ad_experience: "squeezeback" + slots: + - { asset_group_id: "source_catalog", asset_type: "catalog", required: true } + pricing_options: + - pricing_option_id: "ctv_validate_squeezeback_cpm" + pricing_model: "cpm" + currency: "USD" + fixed_price: 50 + reporting_capabilities: + available_reporting_frequencies: ["daily"] + expected_delay_minutes: 60 + timezone: "UTC" + supports_webhooks: false + available_metrics: ["impressions", "spend"] + date_range_support: "date_range" + + # --- Intentionally invalid seeded products (defense-in-depth phase only) --- + + - product_id: "ctv_invalid_menu_video_vast" + name: "CTV Invalid — Menu on video_vast" + description: "Seeded product whose format_option declares ctv_ad_experience: menu on video_vast, which the matrix never permits — used only to test runtime defense in depth." + publisher_properties: + - publisher_domain: "tv.streamhaus.example" + selection_type: "all" + channels: ["ctv"] + delivery_type: "non_guaranteed" + format_options: + - format_kind: "video_vast" + format_option_id: "ctv_invalid_menu_video_vast_option" + params: + ctv_ad_experience: "menu" + creative_type: "nonlinear" + slots: + - { asset_group_id: "vast_tag", asset_type: "vast", required: true } + pricing_options: + - pricing_option_id: "ctv_invalid_menu_video_vast_cpm" + pricing_model: "cpm" + currency: "USD" + floor_price: 20 + reporting_capabilities: + available_reporting_frequencies: ["daily"] + expected_delay_minutes: 60 + timezone: "UTC" + supports_webhooks: false + available_metrics: ["impressions", "spend"] + date_range_support: "date_range" + + - product_id: "ctv_invalid_pause_native" + name: "CTV Invalid — Pause on native_in_feed" + description: "Seeded product whose format_option declares ctv_ad_experience: pause on native_in_feed, which the matrix restricts to menu only — used only to test runtime defense in depth." + publisher_properties: + - publisher_domain: "tv.streamhaus.example" + selection_type: "all" + channels: ["ctv"] + delivery_type: "guaranteed" + format_options: + - format_kind: "native_in_feed" + format_option_id: "ctv_invalid_pause_native_option" + params: + ctv_ad_experience: "pause" + slots: + - { asset_group_id: "title", asset_type: "text", required: true } + - { asset_group_id: "advertiser_name", asset_type: "text", required: true } + - { asset_group_id: "landing_page_url", asset_type: "url", required: true } + pricing_options: + - pricing_option_id: "ctv_invalid_pause_native_cpm" + pricing_model: "cpm" + currency: "USD" + fixed_price: 30 + reporting_capabilities: + available_reporting_frequencies: ["daily"] + expected_delay_minutes: 60 + timezone: "UTC" + supports_webhooks: false + available_metrics: ["impressions", "spend"] + date_range_support: "date_range" + + - product_id: "ctv_invalid_overlay_linear" + name: "CTV Invalid — Overlay with creative_type linear" + description: "Seeded product whose format_option declares ctv_ad_experience: overlay with creative_type: linear; overlay requires nonlinear — used only to test runtime defense in depth." + publisher_properties: + - publisher_domain: "tv.streamhaus.example" + selection_type: "all" + channels: ["ctv"] + delivery_type: "non_guaranteed" + format_options: + - format_kind: "video_vast" + format_option_id: "ctv_invalid_overlay_linear_option" + params: + ctv_ad_experience: "overlay" + creative_type: "linear" + duration_ms_range: [10000, 30000] + slots: + - { asset_group_id: "vast_tag", asset_type: "vast", required: true } + pricing_options: + - pricing_option_id: "ctv_invalid_overlay_linear_cpm" + pricing_model: "cpm" + currency: "USD" + floor_price: 20 + reporting_capabilities: + available_reporting_frequencies: ["daily"] + expected_delay_minutes: 60 + timezone: "UTC" + supports_webhooks: false + available_metrics: ["impressions", "spend"] + date_range_support: "date_range" + + - product_id: "ctv_invalid_overlay_short_duration" + name: "CTV Invalid — Overlay below the 10-second duration floor" + description: "Seeded product whose format_option declares ctv_ad_experience: overlay with duration_ms_exact: 5000, below the 10-second floor — used only to test runtime defense in depth." + publisher_properties: + - publisher_domain: "tv.streamhaus.example" + selection_type: "all" + channels: ["ctv"] + delivery_type: "non_guaranteed" + format_options: + - format_kind: "video_vast" + format_option_id: "ctv_invalid_overlay_short_duration_option" + params: + ctv_ad_experience: "overlay" + creative_type: "nonlinear" + duration_ms_exact: 5000 + slots: + - { asset_group_id: "vast_tag", asset_type: "vast", required: true } + pricing_options: + - pricing_option_id: "ctv_invalid_overlay_short_duration_cpm" + pricing_model: "cpm" + currency: "USD" + floor_price: 20 + reporting_capabilities: + available_reporting_frequencies: ["daily"] + expected_delay_minutes: 60 + timezone: "UTC" + supports_webhooks: false + available_metrics: ["impressions", "spend"] + date_range_support: "date_range" + + - product_id: "ctv_invalid_in_scene_simid" + name: "CTV Invalid — In-Scene with simid_supported" + description: "Seeded product whose format_option declares ctv_ad_experience: in_scene with simid_supported: true; in_scene forbids interactivity — used only to test runtime defense in depth." + publisher_properties: + - publisher_domain: "tv.streamhaus.example" + selection_type: "all" + channels: ["ctv"] + delivery_type: "non_guaranteed" + format_options: + - format_kind: "video_vast" + format_option_id: "ctv_invalid_in_scene_simid_option" + params: + ctv_ad_experience: "in_scene" + creative_type: "nonlinear" + duration_ms_range: [3000, 15000] + simid_supported: true + slots: + - { asset_group_id: "vast_tag", asset_type: "vast", required: true } + pricing_options: + - pricing_option_id: "ctv_invalid_in_scene_simid_cpm" + pricing_model: "cpm" + currency: "USD" + floor_price: 25 + reporting_capabilities: + available_reporting_frequencies: ["daily"] + expected_delay_minutes: 60 + timezone: "UTC" + supports_webhooks: false + available_metrics: ["impressions", "spend"] + date_range_support: "date_range" + + - product_id: "ctv_invalid_pause_full_motion" + name: "CTV Invalid — Pause image with motion_level full_motion" + description: "Seeded product whose format_option declares ctv_ad_experience: pause with motion_level: full_motion; image narrows motion_level to static/limited_motion unconditionally — used only to test runtime defense in depth." + publisher_properties: + - publisher_domain: "tv.streamhaus.example" + selection_type: "all" + channels: ["ctv"] + delivery_type: "guaranteed" + format_options: + - format_kind: "image" + format_option_id: "ctv_invalid_pause_full_motion_option" + params: + ctv_ad_experience: "pause" + motion_level: "full_motion" + width: 1920 + height: 1080 + slots: + - { asset_group_id: "image_main", asset_type: "image", required: true } + pricing_options: + - pricing_option_id: "ctv_invalid_pause_full_motion_cpm" + pricing_model: "cpm" + currency: "USD" + fixed_price: 30 + reporting_capabilities: + available_reporting_frequencies: ["daily"] + expected_delay_minutes: 60 + timezone: "UTC" + supports_webhooks: false + available_metrics: ["impressions", "spend"] + date_range_support: "date_range" + + - product_id: "ctv_invalid_menu_no_video_slot" + name: "CTV Invalid — Menu tile with autoplay focus but no video slot" + description: "Seeded product whose format_option declares focus_behavior: autoplay_muted but omits the video asset group from its effective slots — used only to test runtime defense in depth against the (semantic-only) focus_video_pairing rule." + publisher_properties: + - publisher_domain: "tv.streamhaus.example" + selection_type: "all" + channels: ["ctv"] + delivery_type: "guaranteed" + format_options: + - format_kind: "native_in_feed" + format_option_id: "ctv_invalid_menu_no_video_slot_option" + params: + ctv_ad_experience: "menu" + menu_placement: "tile" + focus_behavior: "autoplay_muted" + slots: + - { asset_group_id: "title", asset_type: "text", required: true } + - { asset_group_id: "main_image", asset_type: "image", required: true } + - { asset_group_id: "advertiser_name", asset_type: "text", required: true } + - { asset_group_id: "landing_page_url", asset_type: "url", required: true } + pricing_options: + - pricing_option_id: "ctv_invalid_menu_no_video_slot_cpm" + pricing_model: "cpm" + currency: "USD" + fixed_price: 40 + reporting_capabilities: + available_reporting_frequencies: ["daily"] + expected_delay_minutes: 60 + timezone: "UTC" + supports_webhooks: false + available_metrics: ["impressions", "spend"] + date_range_support: "date_range" + + - product_id: "ctv_invalid_pause_html5" + name: "CTV Invalid — Pause on html5 (unlisted canonical)" + description: "Seeded product whose format_option declares ctv_ad_experience: pause on html5, a canonical absent from the experience-to-canonical matrix entirely — used only to test runtime defense in depth against the semantic-layer ctv_experience_matrix check for unlisted canonicals (html5.json has no CTV allOf conditional)." + publisher_properties: + - publisher_domain: "tv.streamhaus.example" + selection_type: "all" + channels: ["ctv"] + delivery_type: "guaranteed" + format_options: + - format_kind: "html5" + format_option_id: "ctv_invalid_pause_html5_option" + params: + ctv_ad_experience: "pause" + width: 1920 + height: 1080 + slots: + - { asset_group_id: "html5_bundle", asset_type: "zip", required: true } + pricing_options: + - pricing_option_id: "ctv_invalid_pause_html5_cpm" + pricing_model: "cpm" + currency: "USD" + fixed_price: 30 + reporting_capabilities: + available_reporting_frequencies: ["daily"] + expected_delay_minutes: 60 + timezone: "UTC" + supports_webhooks: false + available_metrics: ["impressions", "spend"] + date_range_support: "date_range" + +phases: + - id: matrix_positive_pairings + title: "Experience-to-canonical matrix — positive pairings" + narrative: | + Each step submits a manifest satisfying a validly-declared CTV + product's required slots. Every pairing here is one row of the + experience-to-canonical matrix in + /docs/creative/ctv-experiences#experience-to-canonical-matrix. + + steps: + - id: validate_menu_tile + title: "menu + native_in_feed (tile, video-capable)" + task: validate_input + schema_ref: "creative/validate-input-request.json" + response_schema_ref: "creative/validate-input-response.json" + doc_ref: "/docs/creative/ctv-experiences#experience-to-canonical-matrix" + comply_scenario: ctv_experience_validation + stateful: true + sample_request: + adcp_version: "3.2" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + manifest: + format_kind: "native_in_feed" + format_option_ref: + scope: "product" + format_option_id: "ctv_validate_menu_tile_option" + assets: + title: + asset_type: "text" + content: "Gear for every trail" + main_image: + asset_type: "image" + url: "https://cdn.acmeoutdoor.example/ctv/menu-tile-bg.png" + width: 1920 + height: 1080 + advertiser_name: + asset_type: "text" + content: "Acme Outdoor" + landing_page_url: + asset_type: "url" + url: "https://acmeoutdoor.example/trails" + video: + asset_type: "vast" + delivery_type: "url" + url: "https://vast.acmeoutdoor.example/menu-hero.xml" + targets: + - kind: "product" + id: "ctv_validate_menu_tile" + validations: + - check: response_schema + description: "Response matches validate-input-response.json schema" + - check: field_value + path: "results[0].result_kind" + value: "validated_pass" + description: "Video-capable menu tile manifest satisfies the menu/native_in_feed pairing" + + - id: validate_menu_banner + title: "menu + native_in_feed (headline banner, no video)" + task: validate_input + schema_ref: "creative/validate-input-request.json" + response_schema_ref: "creative/validate-input-response.json" + doc_ref: "/docs/creative/ctv-experiences#experience-to-canonical-matrix" + comply_scenario: ctv_experience_validation + stateful: true + sample_request: + adcp_version: "3.2" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + manifest: + format_kind: "native_in_feed" + format_option_ref: + scope: "product" + format_option_id: "ctv_validate_menu_banner_option" + assets: + title: + asset_type: "text" + content: "Weekend packs, ready now" + main_image: + asset_type: "image" + url: "https://cdn.acmeoutdoor.example/ctv/menu-banner-bg.png" + width: 1920 + height: 260 + advertiser_name: + asset_type: "text" + content: "Acme Outdoor" + landing_page_url: + asset_type: "url" + url: "https://acmeoutdoor.example/packs" + targets: + - kind: "product" + id: "ctv_validate_menu_banner" + validations: + - check: field_value + path: "results[0].result_kind" + value: "validated_pass" + description: "Static headline-banner manifest satisfies the menu/native_in_feed pairing without a video slot" + + - id: validate_pause_image + title: "pause + image" + task: validate_input + schema_ref: "creative/validate-input-request.json" + response_schema_ref: "creative/validate-input-response.json" + doc_ref: "/docs/creative/ctv-experiences#experience-to-canonical-matrix" + comply_scenario: ctv_experience_validation + stateful: true + sample_request: + adcp_version: "3.2" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + manifest: + format_kind: "image" + format_option_ref: + scope: "product" + format_option_id: "ctv_validate_pause_image_option" + assets: + image_main: + asset_type: "image" + url: "https://cdn.acmeoutdoor.example/ctv/pause-frame.png" + width: 1920 + height: 1080 + targets: + - kind: "product" + id: "ctv_validate_pause_image" + validations: + - check: field_value + path: "results[0].result_kind" + value: "validated_pass" + description: "Raw 1920x1080 image manifest satisfies the pause/image pairing — no VAST tag required" + + - id: validate_screensaver_video + title: "screensaver + video_hosted" + task: validate_input + schema_ref: "creative/validate-input-request.json" + response_schema_ref: "creative/validate-input-response.json" + doc_ref: "/docs/creative/ctv-experiences#experience-to-canonical-matrix" + comply_scenario: ctv_experience_validation + stateful: true + sample_request: + adcp_version: "3.2" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + manifest: + format_kind: "video_hosted" + format_option_ref: + scope: "product" + format_option_id: "ctv_validate_screensaver_video_option" + assets: + video_main: + asset_type: "video" + url: "https://cdn.acmeoutdoor.example/ctv/screensaver-loop.mp4" + width: 1920 + height: 1080 + duration_ms: 20000 + targets: + - kind: "product" + id: "ctv_validate_screensaver_video" + validations: + - check: field_value + path: "results[0].result_kind" + value: "validated_pass" + description: "Ambient looping video manifest satisfies the screensaver/video_hosted pairing" + + - id: validate_overlay_vast + title: "overlay + video_vast (nonlinear, 20s)" + task: validate_input + schema_ref: "creative/validate-input-request.json" + response_schema_ref: "creative/validate-input-response.json" + doc_ref: "/docs/creative/ctv-experiences#constraint-profiles" + comply_scenario: ctv_experience_validation + stateful: true + sample_request: + adcp_version: "3.2" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + manifest: + format_kind: "video_vast" + format_option_ref: + scope: "product" + format_option_id: "ctv_validate_overlay_vast_option" + assets: + vast_tag: + asset_type: "vast" + delivery_type: "url" + url: "https://vast.acmeoutdoor.example/overlay.xml" + duration_ms: 20000 + targets: + - kind: "product" + id: "ctv_validate_overlay_vast" + validations: + - check: field_value + path: "results[0].result_kind" + value: "validated_pass" + description: "20-second NonLinear VAST manifest satisfies the overlay/video_vast pairing declared on this product (whose own duration_ms_range [10000, 30000] already clears the 10-second floor)" + + - id: validate_squeezeback_sponsored + title: "squeezeback + sponsored_placement" + task: validate_input + schema_ref: "creative/validate-input-request.json" + response_schema_ref: "creative/validate-input-response.json" + doc_ref: "/docs/creative/ctv-experiences#experience-to-canonical-matrix" + comply_scenario: ctv_experience_validation + stateful: true + sample_request: + adcp_version: "3.2" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + manifest: + format_kind: "sponsored_placement" + format_option_ref: + scope: "product" + format_option_id: "ctv_validate_squeezeback_option" + assets: + source_catalog: + asset_type: "catalog" + type: "product" + items: + - sku: "ACME-TRAIL-001" + name: "Trail jacket" + targets: + - kind: "product" + id: "ctv_validate_squeezeback_sponsored" + validations: + - check: field_value + path: "results[0].result_kind" + value: "validated_pass" + description: "Catalog reference satisfies the squeezeback/sponsored_placement pairing" + + - id: matrix_defense_in_depth + title: "Matrix and constraint-profile defense in depth" + narrative: | + Every product seeded here declares an experience/canonical or + constraint pairing that should never have been authored (see the + top-of-file grading notes). A conformant seller never actually + publishes these; the steps below test whether the agent's + validate_input runtime independently re-derives the same rule when + asked to validate a manifest against such a product, rather than + trusting that a seeded product target is well-formed. Every check + here evaluates the seeded product's own declared params — none + depends on anything the manifest itself contributes. + + steps: + - id: reject_menu_on_video_vast + title: "Reject menu declared on a video_vast product" + task: validate_input + schema_ref: "creative/validate-input-request.json" + response_schema_ref: "creative/validate-input-response.json" + doc_ref: "/docs/creative/ctv-experiences#experience-to-canonical-matrix" + comply_scenario: ctv_experience_validation + stateful: true + sample_request: + adcp_version: "3.2" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + manifest: + format_kind: "video_vast" + format_option_ref: + scope: "product" + format_option_id: "ctv_invalid_menu_video_vast_option" + assets: + vast_tag: + asset_type: "vast" + delivery_type: "url" + url: "https://vast.acmeoutdoor.example/menu-invalid.xml" + targets: + - kind: "product" + id: "ctv_invalid_menu_video_vast" + validations: + - check: field_value + path: "results[0].result_kind" + value: "validated_fail" + description: "video_vast never permits ctv_ad_experience: menu" + - check: field_value + path: "results[0].violations[0].rule" + value: "ctv_experience_matrix" + description: "Violation cites the video_vast verifier_constraints matrix rule" + - check: field_value + path: "results[0].violations[0].field" + value: "params.ctv_ad_experience" + description: "Violation points at the seeded product's own ctv_ad_experience declaration, not the manifest" + + - id: reject_pause_on_native_in_feed + title: "Reject pause declared on a native_in_feed product" + task: validate_input + schema_ref: "creative/validate-input-request.json" + response_schema_ref: "creative/validate-input-response.json" + doc_ref: "/docs/creative/ctv-experiences#experience-to-canonical-matrix" + comply_scenario: ctv_experience_validation + stateful: true + sample_request: + adcp_version: "3.2" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + manifest: + format_kind: "native_in_feed" + format_option_ref: + scope: "product" + format_option_id: "ctv_invalid_pause_native_option" + assets: + title: + asset_type: "text" + content: "Invalid pause native" + advertiser_name: + asset_type: "text" + content: "Acme Outdoor" + landing_page_url: + asset_type: "url" + url: "https://acmeoutdoor.example/invalid" + targets: + - kind: "product" + id: "ctv_invalid_pause_native" + validations: + - check: field_value + path: "results[0].result_kind" + value: "validated_fail" + description: "native_in_feed only permits ctv_ad_experience: menu" + - check: field_value + path: "results[0].violations[0].rule" + value: "ctv_experience_matrix" + description: "Violation cites the native_in_feed verifier_constraints matrix rule" + - check: field_value + path: "results[0].violations[0].field" + value: "params.ctv_ad_experience" + description: "Violation points at the seeded product's own ctv_ad_experience declaration, not the manifest" + + - id: reject_overlay_creative_type_linear + title: "Reject overlay declared with creative_type linear" + task: validate_input + schema_ref: "creative/validate-input-request.json" + response_schema_ref: "creative/validate-input-response.json" + doc_ref: "/docs/creative/ctv-experiences#constraint-profiles" + comply_scenario: ctv_experience_validation + stateful: true + sample_request: + adcp_version: "3.2" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + manifest: + format_kind: "video_vast" + format_option_ref: + scope: "product" + format_option_id: "ctv_invalid_overlay_linear_option" + assets: + vast_tag: + asset_type: "vast" + delivery_type: "url" + url: "https://vast.acmeoutdoor.example/overlay-linear-invalid.xml" + duration_ms: 20000 + targets: + - kind: "product" + id: "ctv_invalid_overlay_linear" + validations: + - check: field_value + path: "results[0].result_kind" + value: "validated_fail" + description: "Every non-menu CTV experience on video_vast requires creative_type: nonlinear" + - check: field_value + path: "results[0].violations[0].rule" + value: "ctv_experience_matrix" + description: "Violation cites the video_vast verifier_constraints matrix rule (the nonlinear requirement is bundled with the experience pairing, not a separate rule name)" + - check: field_value + path: "results[0].violations[0].field" + value: "params.creative_type" + description: "Violation points at the seeded product's own creative_type declaration, not the manifest" + + - id: reject_overlay_short_duration + title: "Reject overlay declared below the 10-second duration floor" + task: validate_input + schema_ref: "creative/validate-input-request.json" + response_schema_ref: "creative/validate-input-response.json" + doc_ref: "/docs/creative/ctv-experiences#constraint-profiles" + comply_scenario: ctv_experience_validation + stateful: true + sample_request: + adcp_version: "3.2" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + manifest: + format_kind: "video_vast" + format_option_ref: + scope: "product" + format_option_id: "ctv_invalid_overlay_short_duration_option" + assets: + vast_tag: + asset_type: "vast" + delivery_type: "url" + url: "https://vast.acmeoutdoor.example/overlay-short-invalid.xml" + targets: + - kind: "product" + id: "ctv_invalid_overlay_short_duration" + validations: + - check: field_value + path: "results[0].result_kind" + value: "validated_fail" + description: "overlay requires a 10-second minimum duration; this product declares duration_ms_exact: 5000" + - check: field_value + path: "results[0].violations[0].rule" + value: "ctv_duration_floors" + description: "Violation cites the video_vast verifier_constraints duration-floor rule" + - check: field_value + path: "results[0].violations[0].field" + value: "params.duration_ms_exact" + description: "Violation points at the seeded product's own duration_ms_exact declaration, not the manifest" + + - id: reject_in_scene_simid_supported + title: "Reject in_scene declared with simid_supported: true" + task: validate_input + schema_ref: "creative/validate-input-request.json" + response_schema_ref: "creative/validate-input-response.json" + doc_ref: "/docs/creative/ctv-experiences#constraint-profiles" + comply_scenario: ctv_experience_validation + stateful: true + sample_request: + adcp_version: "3.2" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + manifest: + format_kind: "video_vast" + format_option_ref: + scope: "product" + format_option_id: "ctv_invalid_in_scene_simid_option" + assets: + vast_tag: + asset_type: "vast" + delivery_type: "url" + url: "https://vast.acmeoutdoor.example/in-scene-invalid.xml" + duration_ms: 5000 + targets: + - kind: "product" + id: "ctv_invalid_in_scene_simid" + validations: + - check: field_value + path: "results[0].result_kind" + value: "validated_fail" + description: "in_scene forbids simid_supported: true — no interactivity is permitted" + - check: field_value + path: "results[0].violations[0].rule" + value: "ctv_in_scene_no_interactivity" + description: "Violation cites the video_vast verifier_constraints in_scene interactivity rule" + - check: field_value + path: "results[0].violations[0].field" + value: "params.simid_supported" + description: "Violation points at the seeded product's own simid_supported declaration, not the manifest" + + - id: reject_pause_full_motion_image + title: "Reject pause image declared with motion_level full_motion" + task: validate_input + schema_ref: "creative/validate-input-request.json" + response_schema_ref: "creative/validate-input-response.json" + doc_ref: "/docs/creative/ctv-experiences#motion-level-and-activation-vocabularies" + comply_scenario: ctv_experience_validation + stateful: true + sample_request: + adcp_version: "3.2" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + manifest: + format_kind: "image" + format_option_ref: + scope: "product" + format_option_id: "ctv_invalid_pause_full_motion_option" + assets: + image_main: + asset_type: "image" + url: "https://cdn.acmeoutdoor.example/ctv/pause-invalid.png" + width: 1920 + height: 1080 + targets: + - kind: "product" + id: "ctv_invalid_pause_full_motion" + validations: + - check: field_value + path: "results[0].result_kind" + value: "validated_fail" + description: "image narrows motion_level to static or limited_motion unconditionally — the image canonical never accepts full_motion regardless of whether ctv_ad_experience is declared" + - check: field_value + path: "results[0].violations[0].rule" + value: "motion_level_static_canonical" + description: "Violation cites the image verifier_constraints motion-level rule" + - check: field_value + path: "results[0].violations[0].field" + value: "params.motion_level" + description: "Violation points at the seeded product's own motion_level declaration, not the manifest" + + - id: reject_focus_autoplay_without_video_slot + title: "Reject autoplay_muted focus_behavior on a product with no video slot" + task: validate_input + schema_ref: "creative/validate-input-request.json" + response_schema_ref: "creative/validate-input-response.json" + doc_ref: "/docs/creative/ctv-experiences#menu-profile-on-native_in_feed" + comply_scenario: ctv_experience_validation + stateful: true + sample_request: + adcp_version: "3.2" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + manifest: + format_kind: "native_in_feed" + format_option_ref: + scope: "product" + format_option_id: "ctv_invalid_menu_no_video_slot_option" + assets: + title: + asset_type: "text" + content: "Invalid autoplay tile" + main_image: + asset_type: "image" + url: "https://cdn.acmeoutdoor.example/ctv/menu-invalid.png" + width: 1920 + height: 1080 + advertiser_name: + asset_type: "text" + content: "Acme Outdoor" + landing_page_url: + asset_type: "url" + url: "https://acmeoutdoor.example/invalid" + targets: + - kind: "product" + id: "ctv_invalid_menu_no_video_slot" + validations: + - check: field_value + path: "results[0].result_kind" + value: "validated_fail" + description: "focus_behavior: autoplay_muted requires a video asset group in the product's effective slots — not enforced by JSON Schema, semantic-only (see grading notes)" + - check: field_value + path: "results[0].violations[0].rule" + value: "focus_video_pairing" + description: "Violation cites the native_in_feed verifier_constraints focus/video pairing rule" + - check: field_value + path: "results[0].violations[0].field" + value: "params.focus_behavior" + description: "Violation points at the seeded product's own focus_behavior declaration, not the manifest" + + - id: reject_pause_on_html5 + title: "Reject pause declared on an html5 product (unlisted canonical)" + task: validate_input + schema_ref: "creative/validate-input-request.json" + response_schema_ref: "creative/validate-input-response.json" + doc_ref: "/docs/creative/ctv-experiences#experience-to-canonical-matrix" + comply_scenario: ctv_experience_validation + stateful: true + sample_request: + adcp_version: "3.2" + account: + brand: + domain: "acmeoutdoor.example" + operator: "pinnacle-agency.example" + sandbox: true + manifest: + format_kind: "html5" + format_option_ref: + scope: "product" + format_option_id: "ctv_invalid_pause_html5_option" + assets: + html5_bundle: + asset_type: "zip" + url: "https://cdn.acmeoutdoor.example/ctv/pause-invalid.zip" + targets: + - kind: "product" + id: "ctv_invalid_pause_html5" + validations: + - check: field_value + path: "results[0].result_kind" + value: "validated_fail" + description: "html5 is absent from the experience-to-canonical matrix entirely; the semantic validator's ctv_experience_matrix check is the only layer that rejects this pairing — html5.json has no CTV allOf conditional" + - check: field_value + path: "results[0].violations[0].rule" + value: "ctv_experience_matrix" + description: "Violation cites the semantic ctv_experience_matrix rule (no allOf conditional on html5 — semantic layer only)" + - check: field_value + path: "results[0].violations[0].field" + value: "params.ctv_ad_experience" + description: "Violation points at the seeded product's own ctv_ad_experience declaration, not the manifest" diff --git a/static/examples/products/canonical/streamhaus_ctv_menu_banner.json b/static/examples/products/canonical/streamhaus_ctv_menu_banner.json new file mode 100644 index 0000000000..83e5ab90e5 --- /dev/null +++ b/static/examples/products/canonical/streamhaus_ctv_menu_banner.json @@ -0,0 +1,52 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "streamhaus_ctv_menu_banner", + "name": "StreamHaus CTV Home Screen — Headline Banner", + "description": "Static headline banner across the top of StreamHaus's smart-TV home screen (OpenRTB Native plcmttype 3). Buyer ships a native_in_feed asset bundle with no video slot — the banner is a static image-plus-copy unit with no focus-triggered playback.", + "publisher_properties": [ + { + "publisher_domain": "tv.streamhaus.example", + "selection_type": "all" + } + ], + "channels": ["ctv"], + "delivery_type": "guaranteed", + "pricing_options": [ + { + "pricing_option_id": "streamhaus_menu_banner_cpm", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 38 + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": ["daily"], + "expected_delay_minutes": 120, + "timezone": "America/Los_Angeles", + "supports_webhooks": false, + "available_metrics": ["impressions", "spend"], + "date_range_support": "date_range" + }, + "format_options": [ + { + "format_kind": "native_in_feed", + "format_option_id": "streamhaus_menu_headline_banner", + "display_name": "StreamHaus Headline Banner — static", + "params": { + "ctv_ad_experience": "menu", + "menu_placement": "headline_banner", + "focus_behavior": "none", + "motion_level": "static", + "asset_source": "buyer_uploaded", + "ssl_required": true, + "slots": [ + { "asset_group_id": "title", "asset_type": "text", "required": true, "max_chars": 60 }, + { "asset_group_id": "main_image", "asset_type": "image", "required": true }, + { "asset_group_id": "advertiser_name", "asset_type": "text", "required": true }, + { "asset_group_id": "landing_page_url", "asset_type": "url", "required": true }, + { "asset_group_id": "impression_tracker", "asset_type": "pixel_tracker", "required": false } + ] + } + } + ] +} diff --git a/static/examples/products/canonical/streamhaus_ctv_menu_tile.json b/static/examples/products/canonical/streamhaus_ctv_menu_tile.json new file mode 100644 index 0000000000..17c67bbd11 --- /dev/null +++ b/static/examples/products/canonical/streamhaus_ctv_menu_tile.json @@ -0,0 +1,55 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "streamhaus_ctv_menu_tile", + "name": "StreamHaus CTV Home Screen — Menu Tile", + "description": "Video-capable hero tile on StreamHaus's smart-TV home screen, in the Fire TV Feature Rotator / Samsung 1st Screen Masthead pattern. Buyer ships a native_in_feed asset bundle (background image, icon, title, advertiser name, landing page) plus a required focus-triggered video; the platform autoplays it muted when the viewer's remote focus lands on the tile. QR code and deep-link activations are engagement events layered on top of the one rendered impression, never additional impressions.", + "publisher_properties": [ + { + "publisher_domain": "tv.streamhaus.example", + "selection_type": "all" + } + ], + "channels": ["ctv"], + "delivery_type": "guaranteed", + "pricing_options": [ + { + "pricing_option_id": "streamhaus_menu_tile_cpm", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 45 + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": ["daily"], + "expected_delay_minutes": 120, + "timezone": "America/Los_Angeles", + "supports_webhooks": false, + "available_metrics": ["impressions", "engagements", "spend"], + "date_range_support": "date_range" + }, + "format_options": [ + { + "format_kind": "native_in_feed", + "format_option_id": "streamhaus_menu_tile_hero", + "display_name": "StreamHaus Menu Tile — video-capable hero", + "params": { + "ctv_ad_experience": "menu", + "menu_placement": "tile", + "focus_behavior": "autoplay_muted", + "motion_level": "full_motion", + "activation_methods": ["qr_code", "deep_link"], + "asset_source": "buyer_uploaded", + "ssl_required": true, + "slots": [ + { "asset_group_id": "title", "asset_type": "text", "required": true, "max_chars": 40 }, + { "asset_group_id": "main_image", "asset_type": "image", "required": true }, + { "asset_group_id": "icon", "asset_type": "image", "required": true }, + { "asset_group_id": "advertiser_name", "asset_type": "text", "required": true }, + { "asset_group_id": "landing_page_url", "asset_type": "url", "required": true }, + { "asset_group_id": "video", "asset_type": "vast", "required": true }, + { "asset_group_id": "impression_tracker", "asset_type": "pixel_tracker", "required": false } + ] + } + } + ] +} diff --git a/static/examples/products/canonical/streamhaus_ctv_overlay_vast.json b/static/examples/products/canonical/streamhaus_ctv_overlay_vast.json new file mode 100644 index 0000000000..91d8970031 --- /dev/null +++ b/static/examples/products/canonical/streamhaus_ctv_overlay_vast.json @@ -0,0 +1,51 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "streamhaus_ctv_overlay_vast", + "name": "StreamHaus CTV Overlay — VAST NonLinear", + "description": "Semi-transparent overlay layered over playing content on StreamHaus's CTV app, delivered as a VAST NonLinearAds creative (10-30 second minimum-duration window). SIMID is explicitly supported and declared via simid_supported rather than inferred; the VAST tag MUST still carry a non-interactive StaticResource/MediaFile fallback for players without SIMID support. Push-notification activation ships buyer-supplied copy through the added activation_message slot (max 115 characters) and is an engagement event layered on the one rendered impression, never an additional impression.", + "publisher_properties": [ + { + "publisher_domain": "tv.streamhaus.example", + "selection_type": "all" + } + ], + "channels": ["ctv"], + "delivery_type": "non_guaranteed", + "pricing_options": [ + { + "pricing_option_id": "streamhaus_overlay_vast_cpm", + "pricing_model": "cpm", + "currency": "USD", + "floor_price": 22 + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": ["daily"], + "expected_delay_minutes": 120, + "timezone": "America/Los_Angeles", + "supports_webhooks": false, + "available_metrics": ["impressions", "engagements", "spend", "quartile_data"], + "date_range_support": "date_range" + }, + "format_options": [ + { + "format_kind": "video_vast", + "format_option_id": "streamhaus_overlay_nonlinear", + "display_name": "StreamHaus Overlay — VAST NonLinear, 10-30s", + "params": { + "ctv_ad_experience": "overlay", + "creative_type": "nonlinear", + "duration_ms_range": [10000, 30000], + "simid_supported": true, + "activation_methods": ["push_notification"], + "vast_version": "4.2", + "ssl_required": true, + "slots": [ + { "asset_group_id": "vast_tag", "asset_type": "vast", "required": true }, + { "asset_group_id": "landing_page_url", "asset_type": "url", "required": false }, + { "asset_group_id": "activation_message", "asset_type": "text", "required": true, "max_chars": 115 } + ] + } + } + ] +} diff --git a/static/examples/products/canonical/streamhaus_ctv_pause_image.json b/static/examples/products/canonical/streamhaus_ctv_pause_image.json new file mode 100644 index 0000000000..25a6f89154 --- /dev/null +++ b/static/examples/products/canonical/streamhaus_ctv_pause_image.json @@ -0,0 +1,54 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "streamhaus_ctv_pause_image", + "name": "StreamHaus CTV Pause Ad — Image", + "description": "Full-screen pause-frame unit shown when the viewer pauses content, in the Netflix/Hulu-class pattern: a raw 1920x1080 image plus copy, no VAST tag involved. Ends on viewer or device resume — no duration floor. QR code activation is an engagement event layered on the one rendered impression.", + "publisher_properties": [ + { + "publisher_domain": "tv.streamhaus.example", + "selection_type": "all" + } + ], + "channels": ["ctv"], + "delivery_type": "guaranteed", + "pricing_options": [ + { + "pricing_option_id": "streamhaus_pause_image_cpm", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 32 + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": ["daily"], + "expected_delay_minutes": 120, + "timezone": "America/Los_Angeles", + "supports_webhooks": false, + "available_metrics": ["impressions", "engagements", "spend"], + "date_range_support": "date_range" + }, + "format_options": [ + { + "format_kind": "image", + "format_option_id": "streamhaus_pause_frame", + "display_name": "StreamHaus Pause Frame — 1920x1080", + "params": { + "ctv_ad_experience": "pause", + "motion_level": "static", + "activation_methods": ["qr_code"], + "width": 1920, + "height": 1080, + "image_formats": ["jpg", "png"], + "max_file_size_kb": 800, + "ssl_required": true, + "asset_source": "buyer_uploaded", + "slots": [ + { "asset_group_id": "image_main", "asset_type": "image", "required": true }, + { "asset_group_id": "headline", "asset_type": "text", "required": false }, + { "asset_group_id": "body_text", "asset_type": "text", "required": false }, + { "asset_group_id": "landing_page_url", "asset_type": "url", "required": false } + ] + } + } + ] +} diff --git a/static/schemas/source/enums/activation-method.json b/static/schemas/source/enums/activation-method.json new file mode 100644 index 0000000000..40f8358eff --- /dev/null +++ b/static/schemas/source/enums/activation-method.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/enums/activation-method.json", + "title": "Creative Activation Method", + "description": "Viewer activation mechanism a CTV or high-impact unit offers beyond a rendered impression: `qr_code` (on-glass QR to a buyer URL), `deep_link` (app or content deep link, including app-store detail pages), `push_notification` and `email` (send-to-device follow-ups with buyer-supplied copy), `tune_in` (jump to linear/virtual channel or content), `text_message` (SMS follow-up with buyer-supplied copy). Activations are engagement events, never additional impressions. Formats offering copy-bearing activations declare normative character-limited copy slots via `slots_override`: `push_notification` → `activation_message`; `text_message` → `activation_text_message`; `email` → `activation_email_subject` + `activation_email_body`. The activation URL rides `landing_page_url` unless a dedicated slot is declared.", + "type": "string", + "enum": [ + "qr_code", + "deep_link", + "push_notification", + "email", + "tune_in", + "text_message" + ] +} diff --git a/static/schemas/source/enums/ctv-ad-experience.json b/static/schemas/source/enums/ctv-ad-experience.json new file mode 100644 index 0000000000..102576f3f8 --- /dev/null +++ b/static/schemas/source/enums/ctv-ad-experience.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/enums/ctv-ad-experience.json", + "title": "CTV Ad Experience", + "description": "IAB Tech Lab CTV Ad Portfolio experience for a format option. `menu` covers smart-TV home/menu surfaces (tiles and headline banners, OpenRTB Native plcmttype 1/3); `pause`, `screensaver`, `overlay`, `squeezeback`, and `in_scene` are the on-glass experiences the portfolio signals via OpenRTB Video with VAST NonLinearAds (AdCOM plcmt 5-9). One format option declares at most one experience; sellers offering several publish sibling format options. Each canonical format permits only the experiences in its documented matrix — pairings outside the matrix fail validation. Linear CTV video is ordinary video_vast/video_hosted and declares no experience.", + "$comment": "Vocabulary and names follow the IAB CTV Ad Format Guidelines v2.0 and the final Signaling Implementation Guidelines. The IAB signaling table (Menu -> Native, others -> NonLinear VAST) is an OpenRTB bridge mapping documented in docs/creative/ctv-experiences.mdx; AdCP creative supply follows the per-canonical experience matrix, which also admits the asset-bundle and image contracts CTV sellers ingest in practice.", + "type": "string", + "enum": [ + "menu", + "pause", + "screensaver", + "overlay", + "squeezeback", + "in_scene" + ] +} diff --git a/static/schemas/source/enums/motion-level.json b/static/schemas/source/enums/motion-level.json new file mode 100644 index 0000000000..4a1b5aa96c --- /dev/null +++ b/static/schemas/source/enums/motion-level.json @@ -0,0 +1,12 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/enums/motion-level.json", + "title": "Creative Motion Level", + "description": "Motion class of a rendered creative, mirroring AdCOM creative attributes 21 (Static Visual), 22 (Limited Motion / Cinemagraph), and 23 (Full-Motion Video). Sellers constrain the accepted level on a format option; buyers declare the level their creative carries. OpenRTB bridges map the value to `attr` on the bid and `battr` on the request.", + "type": "string", + "enum": [ + "static", + "limited_motion", + "full_motion" + ] +} diff --git a/static/schemas/source/formats/canonical/image.json b/static/schemas/source/formats/canonical/image.json index 9a4c66fad4..318914c231 100644 --- a/static/schemas/source/formats/canonical/image.json +++ b/static/schemas/source/formats/canonical/image.json @@ -67,6 +67,19 @@ } } ] + }, + { + "if": { "required": ["ctv_ad_experience"] }, + "then": { + "properties": { + "ctv_ad_experience": { "enum": ["pause", "screensaver"] } + } + } + }, + { + "properties": { + "motion_level": { "enum": ["static", "limited_motion"] } + } } ], "properties": { @@ -178,7 +191,29 @@ "enum": ["accepted", "rejected"], "default": "accepted", "description": "Whether the product accepts buyer-uploaded assets. When `rejected`, the buyer cannot ship pre-rendered bytes directly — they must use build_creative (or sync_creatives with brief inputs or reference assets) so the seller produces or resolves the asset. Combined with `asset_source`, lets a product declare 'I produce assets from briefs and refuse buyer uploads' (asset_source=`seller_pre_rendered_from_brief`, buyer_asset_acceptance=`rejected`) or 'I accept existing post references, not uploaded bytes' (asset_source=`publisher_owned_reference`, buyer_asset_acceptance=`rejected`)." + }, + "ctv_ad_experience": { + "$ref": "/schemas/enums/ctv-ad-experience.json", + "description": "CTV experience this option serves. On `image` only `pause` and `screensaver` are valid — the image-plus-copy contract the major pause-ad sellers ingest (seller composites the frame; typical canvas 1920×1080 or a transparent-region overlay). Other experiences route per the matrix in docs/creative/ctv-experiences.mdx." + }, + "motion_level": { + "$ref": "/schemas/enums/motion-level.json", + "description": "Accepted motion class (AdCOM attrs 21-22; `full_motion` is invalid on a static image canonical)." + }, + "activation_methods": { + "type": "array", + "uniqueItems": true, + "items": { "$ref": "/schemas/enums/activation-method.json" }, + "description": "Viewer activation mechanisms this option offers (e.g. `qr_code` on a pause frame). Activations are engagement events, not impressions." } }, + "x-adcp-validation": { + "verifier_constraints": { + "ctv_experience_matrix": "ctv_ad_experience on image must be pause or screensaver", + "motion_level_static_canonical": "motion_level full_motion is rejected on image", + "ctv_activation_copy_slots": "push_notification activation_methods requires an activation_message slot; text_message requires an activation_text_message slot; email requires activation_email_subject and activation_email_body slots (SHOULD — downgraded to warning once warnings[] channel lands)" + }, + "spec": "docs/creative/ctv-experiences.mdx" + }, "additionalProperties": true } diff --git a/static/schemas/source/formats/canonical/native_in_feed.json b/static/schemas/source/formats/canonical/native_in_feed.json index bfc04ab710..e8896403e7 100644 --- a/static/schemas/source/formats/canonical/native_in_feed.json +++ b/static/schemas/source/formats/canonical/native_in_feed.json @@ -2,8 +2,32 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/formats/canonical/native_in_feed.json", "title": "Canonical Format: Native In-Feed", - "description": "IAB-shaped native creative for in-feed and content-recommendation surfaces. Default slots cover the primary IAB OpenRTB Native 1.2 asset types — `title` (Title Asset), `body_text` (Data Asset type 2), `main_image` (Image Asset main), `icon` (Image Asset icon), `cta` (Data Asset type 12), `advertiser_name` (Data Asset type 1), `sponsored_label` (Title-adjacent), `landing_page_url` (Link Asset), `display_url` (Data Asset type 11 — visible URL/domain, distinct from clickthrough), `rating` (Data Asset type 3 — app/product rating), `price` (Data Asset type 6 — product price), plus renderer-fired `impression_tracker` / `viewability_tracker` / `click_tracker` (`pixel_tracker`). Products MAY use `slots_override` to add other IAB Native data asset types (likes — type 4, downloads — type 5, saleprice — type 7, phone_number — type 8, address — type 9, desc2 — type 10, etc.) or to remove slots the surface doesn't render. The publisher's renderer assembles these into its own look-and-feel — feed card, content-recommendation slot, in-stream native unit. Buyer ships a single asset bundle; the surface chooses presentation.\n\n**Scope (normative — buyer-agent routing).** This canonical is the home for:\n- IAB OpenRTB Native 1.2 in-feed native ads (publisher feeds, app feeds)\n- Content-recommendation widgets (Taboola, Outbrain, Yahoo Recommendations)\n- AdMob Native / Yahoo Native publisher slots\n- In-feed sponsored placements without catalog dependency\n\n**Not this canonical:**\n- Catalog-driven retail-media (Amazon SP, Criteo SP, CitrusAd SP) — use `sponsored_placement` (requires `source_catalog`).\n- Algorithmic surface that picks from a buyer-supplied asset pool (Google PMax, Meta Advantage+) — use `responsive_creative`.\n- Multi-card carousel — use `image_carousel`.\n- Video-first native units where the asset is a hosted video file — use `video_hosted` with `applies_to_channels: [\"native\"]`.\n\nDistinct from `sponsored_placement` along the catalog axis: native_in_feed is asset-bundle composition; sponsored_placement is catalog-row composition. A buyer agent reading `format_kind: native_in_feed` knows to assemble title + image + body + CTA; reading `format_kind: sponsored_placement` knows to attach a catalog feed.", - "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], + "description": "IAB-shaped native creative for in-feed and content-recommendation surfaces. Default slots cover the primary IAB OpenRTB Native 1.2 asset types — `title` (Title Asset), `body_text` (Data Asset type 2), `main_image` (Image Asset main), `icon` (Image Asset icon), `cta` (Data Asset type 12), `advertiser_name` (Data Asset type 1), `sponsored_label` (Title-adjacent), `landing_page_url` (Link Asset), `display_url` (Data Asset type 11 — visible URL/domain, distinct from clickthrough), `rating` (Data Asset type 3 — app/product rating), `price` (Data Asset type 6 — product price), plus renderer-fired `impression_tracker` / `viewability_tracker` / `click_tracker` (`pixel_tracker`). Products MAY use `slots_override` to add other IAB Native data asset types (likes — type 4, downloads — type 5, saleprice — type 7, phone_number — type 8, address — type 9, desc2 — type 10, etc.) or to remove slots the surface doesn't render. The publisher's renderer assembles these into its own look-and-feel — feed card, content-recommendation slot, in-stream native unit. Buyer ships a single asset bundle; the surface chooses presentation.\n\n**Scope (normative — buyer-agent routing).** This canonical is the home for:\n- IAB OpenRTB Native 1.2 in-feed native ads (publisher feeds, app feeds)\n- Content-recommendation widgets (Taboola, Outbrain, Yahoo Recommendations)\n- AdMob Native / Yahoo Native publisher slots\n- In-feed sponsored placements without catalog dependency\n\n**Not this canonical:**\n- Catalog-driven retail-media (Amazon SP, Criteo SP, CitrusAd SP) — use `sponsored_placement` (requires `source_catalog`).\n- Algorithmic surface that picks from a buyer-supplied asset pool (Google PMax, Meta Advantage+) — use `responsive_creative`.\n- Multi-card carousel — use `image_carousel`.\n- Video-first native units where the asset is a hosted video file — use `video_hosted` with `applies_to_channels: [\"native\"]`. (Distinct from the CTV menu profile: a menu hero remains this canonical because the platform assembles the full asset bundle and the video rides the Native 1.2 `vasttag` video asset, playing on focus rather than being the unit itself.)\n\nDistinct from `sponsored_placement` along the catalog axis: native_in_feed is asset-bundle composition; sponsored_placement is catalog-row composition. A buyer agent reading `format_kind: native_in_feed` knows to assemble title + image + body + CTA; reading `format_kind: sponsored_placement` knows to attach a catalog feed.", + "allOf": [ + { "$ref": "/schemas/formats/canonical/_base.json" }, + { + "if": { "required": ["ctv_ad_experience"] }, + "then": { "properties": { "ctv_ad_experience": { "enum": ["menu", "overlay"] } } } + }, + { + "if": { "anyOf": [{ "required": ["menu_placement"] }, { "required": ["focus_behavior"] }] }, + "then": { + "properties": { + "ctv_ad_experience": { "const": "menu" } + }, + "required": ["ctv_ad_experience"] + } + } + ], + "x-adcp-validation": { + "verifier_constraints": { + "ctv_experience_matrix": "ctv_ad_experience on native_in_feed must be menu or overlay", + "menu_profile_fields": "menu_placement and focus_behavior require ctv_ad_experience menu", + "focus_video_pairing": "focus_behavior autoplay_muted or autoplay_sound requires the effective slots to include the video asset group", + "ctv_activation_copy_slots": "push_notification activation_methods requires an activation_message slot; text_message requires an activation_text_message slot; email requires activation_email_subject and activation_email_body slots (SHOULD — downgraded to warning once warnings[] channel lands)" + }, + "spec": "docs/creative/ctv-experiences.mdx" + }, "properties": { "experimental": { "default": false, @@ -26,11 +50,36 @@ { "asset_group_id": "display_url", "asset_type": "text", "required": false }, { "asset_group_id": "rating", "asset_type": "text", "required": false }, { "asset_group_id": "price", "asset_type": "text", "required": false }, + { "asset_group_id": "video", "asset_type": "vast", "required": false }, { "asset_group_id": "impression_tracker", "asset_type": "pixel_tracker", "required": false }, { "asset_group_id": "viewability_tracker", "asset_type": "pixel_tracker", "required": false }, { "asset_group_id": "click_tracker", "asset_type": "pixel_tracker", "required": false } ], - "description": "Default slot shape for native_in_feed. Mirrors IAB OpenRTB Native 1.2 asset types. Products MAY override (`slots_override` on the projection ref) to narrow per-slot limits (`max_chars` on title/body) or remove unused slots (a content-recommendation slot that doesn't display an icon)." + "description": "Default slot shape for native_in_feed. Mirrors IAB OpenRTB Native 1.2 asset types, including the Native video asset: `video` carries a VAST document (the Native 1.2 `vasttag` field) for video-bearing native units such as CTV menu heroes with focus-triggered playback. Products MAY override (`slots_override` on the projection ref) to narrow per-slot limits (`max_chars` on title/body) or remove unused slots (a content-recommendation slot that doesn't display an icon)." + }, + "ctv_ad_experience": { + "$ref": "/schemas/enums/ctv-ad-experience.json", + "description": "CTV experience this option serves. On native_in_feed `menu` and `overlay` are valid. `menu`: smart-TV home/menu surfaces where the platform assembles buyer assets (background/main image, logo/icon, copy, optional focus-triggered video); `menu_placement` selects the tile vs headline-banner variant, and catalog-derived sponsored tiles route to `sponsored_placement` instead. `overlay`: seller-composited in-stream overlays supplied as an asset bundle (video, logo, imagery, copy, activation copy) — the contract overlay sellers that do not ingest VAST tags use; VAST-ingesting sellers publish a `video_vast` sibling option instead. The wire name stays `native_in_feed` for 3.x even though neither surface is literally in-feed." + }, + "menu_placement": { + "type": "string", + "enum": ["tile", "headline_banner"], + "description": "Menu surface variant, mapping to OpenRTB Native `plcmttype` 1 (tile/feed) and 3 (headline banner). Valid only with `ctv_ad_experience: \"menu\"`." + }, + "focus_behavior": { + "type": "string", + "enum": ["none", "autoplay_muted", "autoplay_sound"], + "description": "What happens when the viewer's remote focus lands on the unit. `autoplay_*` requires a `video` asset; playback method maps to AdCOM playbackmethod on OpenRTB bridges. Valid only with `ctv_ad_experience: \"menu\"`." + }, + "motion_level": { + "$ref": "/schemas/enums/motion-level.json", + "description": "Accepted motion class for the rendered unit (AdCOM attrs 21-23)." + }, + "activation_methods": { + "type": "array", + "uniqueItems": true, + "items": { "$ref": "/schemas/enums/activation-method.json" }, + "description": "Viewer activation mechanisms this option offers (QR, deep link, send-to-device). Activations are engagement events, not impressions." }, "title_max_chars": { "type": "integer", diff --git a/static/schemas/source/formats/canonical/sponsored_placement.json b/static/schemas/source/formats/canonical/sponsored_placement.json index e3c1547f74..33ac608ac9 100644 --- a/static/schemas/source/formats/canonical/sponsored_placement.json +++ b/static/schemas/source/formats/canonical/sponsored_placement.json @@ -3,7 +3,13 @@ "$id": "/schemas/formats/canonical/sponsored_placement.json", "title": "Canonical Format: Sponsored Placement (retail-media catalog-driven)", "description": "Catalog-driven retail-media format. Slot: `source_catalog` (catalog asset — product/SKU/ASIN/GTIN catalog reference, REQUIRED), optional `hero_asset`, optional `landing_page_url`. Buyer supplies the catalog reference; surface composes per-item or multi-item rendering using its native placement template. **Composition is deterministic** — buyer can predict per-slot rendering from the catalog item structure. Tracking model: per-item impression + click + conversion (catalog-keyed via offering_id/sku/gtin macros). Covers Amazon Sponsored Products, Criteo Sponsored Products, CitrusAd Sponsored Products, Walmart Connect Sponsored Products, Pinterest Collection (catalog-driven mode).\n\n**Scope (normative — buyer-agent routing).** This canonical is the home for catalog-driven retail-media placements ONLY. The defining feature is the `source_catalog` slot — products under this canonical compose their creative *per catalog item* using the buyer-supplied catalog feed. Without a catalog feed there is nothing to render against. Buyer agents reading `format_kind: sponsored_placement` MUST attach a catalog reference; sellers MUST require `source_catalog` in the manifest.\n\n**Not this canonical (route elsewhere):**\n- IAB in-feed native ads, content-recommendation widgets (Taboola, Outbrain, Yahoo Native, AdMob Native, in-feed sponsored cards) — use `native_in_feed` (asset-bundle composition; no catalog).\n- Algorithmic surface that picks from a buyer-supplied asset pool (Google PMax, Meta Advantage+) — use `responsive_creative`.\n- Single-image or single-video creative — use `image` or `video_hosted`.\n\nThe earlier broader framing ('any sponsored placement') was too loose for buyer-agent routing — a buyer reading `sponsored_placement` couldn't disambiguate a catalog-driven Amazon SP from an in-feed Taboola widget. As of 3.1, the canonical is narrowed to catalog-keyed retail-media; native moves to `native_in_feed`. Distinct from `responsive_creative` (algorithmic combinator from buyer pool) and `agent_placement` (text/audio AI-surface composition).", - "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], + "allOf": [ + { "$ref": "/schemas/formats/canonical/_base.json" }, + { + "if": { "required": ["ctv_ad_experience"] }, + "then": { "properties": { "ctv_ad_experience": { "enum": ["menu", "squeezeback", "in_scene"] } } } + } + ], "properties": { "experimental": { "default": true, @@ -60,7 +66,17 @@ "enum": ["buyer_uploaded", "seller_pre_rendered_from_brief", "seller_human_designed", "agent_synthesized"], "default": "buyer_uploaded", "description": "How each per-item creative is produced. Covers the same production-source axis as `asset_source` on `image` / `video_hosted` / `audio_hosted` but with a 4-value subset — drops `publisher_host_recorded` because it's audio-specific and doesn't apply to retail-media catalog placements. SDK codegen MAY share a base enum and narrow per-canonical, or emit two distinct enums; either way the wire values overlap exactly for the 4 retained values. `buyer_uploaded` (default, current Amazon/Criteo/CitrusAd pattern): the buyer's catalog already contains rendered assets per item; the seller composes the placement using those assets. (\"Uploaded\" reads slightly off for catalog-keyed items where the buyer didn't actively upload bytes — the catalog ingestion already supplied them — but the semantic is the same: rendered bytes are buyer-supplied, not seller-produced.) `seller_pre_rendered_from_brief`: the buyer ships a brief plus the catalog reference; the seller renders one creative per catalog item from the brief at sync_creatives time. `seller_human_designed`: seller's design team produces per-item renders manually. `agent_synthesized`: AI synthesis pipeline produces per-item renders; pair with `synthesis_nondeterministic: true` for Veo/Sora-class generative video applied per item. Captures the multi-output generative pattern (1 brief × N catalog items → N rendered creatives) under the existing canonical without requiring a separate canonical. Distinct from `fanout_mode`, which describes how items map to delivery slots after rendering." + }, + "ctv_ad_experience": { + "$ref": "/schemas/enums/ctv-ad-experience.json", + "description": "CTV experience this option serves. On `sponsored_placement`: `menu` (sponsored app/content tiles and rows whose assets derive from a catalog listing — Fire-TV-tile pattern), `squeezeback`, and `in_scene` (seller-composited brand integrations produced from the catalog/brief rather than a buyer wire creative). Asset-bundle menu heroes route to `native_in_feed`." } }, + "x-adcp-validation": { + "verifier_constraints": { + "ctv_experience_matrix": "ctv_ad_experience on sponsored_placement must be menu, squeezeback, or in_scene" + }, + "spec": "docs/creative/ctv-experiences.mdx" + }, "additionalProperties": true } diff --git a/static/schemas/source/formats/canonical/video_hosted.json b/static/schemas/source/formats/canonical/video_hosted.json index bbf65e787c..e725a5e722 100644 --- a/static/schemas/source/formats/canonical/video_hosted.json +++ b/static/schemas/source/formats/canonical/video_hosted.json @@ -3,7 +3,13 @@ "$id": "/schemas/formats/canonical/video_hosted.json", "title": "Canonical Format: Hosted Video", "description": "Direct video file (mp4/webm/mov) hosted by the buyer. Slot: `video_main` (video asset, file or hosted URL), optional `headline`, `brand_name`, `cta`, `companion_banner`, `landing_page_url`. Tracking model: IAB Open Measurement SDK + external impression/click/quartile pixels via universal_macros. Orientation is a parameter (vertical 9:16 / horizontal 16:9 / square 1:1); slot shape includes optional `brand_name` (typical for vertical short-form) and optional `companion_banner` (typical for horizontal instream). Distinct from `video_vast` (VAST tag, inherent VAST event tracking) — receivers fire impression and click pixels at delivery time.", - "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], + "allOf": [ + { "$ref": "/schemas/formats/canonical/_base.json" }, + { + "if": { "required": ["ctv_ad_experience"] }, + "then": { "properties": { "ctv_ad_experience": { "const": "screensaver" } } } + } + ], "properties": { "slots": { "default": [ @@ -102,7 +108,17 @@ "enum": ["accepted", "rejected"], "default": "accepted", "description": "Whether the product accepts buyer-uploaded video. When `rejected`, the buyer cannot ship a video asset directly — they must use build_creative, sync_creatives with brief inputs, or sync_creatives with an accepted reference asset so the seller produces or resolves the video." + }, + "ctv_ad_experience": { + "$ref": "/schemas/enums/ctv-ad-experience.json", + "description": "CTV experience this option serves. On `video_hosted` only `screensaver` is valid (ambient looping video the platform plays on idle). Other experiences route per the matrix in docs/creative/ctv-experiences.mdx; linear CTV video declares no experience." } }, + "x-adcp-validation": { + "verifier_constraints": { + "ctv_experience_matrix": "ctv_ad_experience on video_hosted must be screensaver" + }, + "spec": "docs/creative/ctv-experiences.mdx" + }, "additionalProperties": true } diff --git a/static/schemas/source/formats/canonical/video_vast.json b/static/schemas/source/formats/canonical/video_vast.json index 71c673a467..c5e03aeff7 100644 --- a/static/schemas/source/formats/canonical/video_vast.json +++ b/static/schemas/source/formats/canonical/video_vast.json @@ -3,7 +3,6 @@ "$id": "/schemas/formats/canonical/video_vast.json", "title": "Canonical Format: VAST Video", "description": "VAST-tag-delivered video creative. Slot: `vast_tag` (vast asset, URL or inline XML, VAST 2.x-4.x). Tracking model: VAST events inherent to the spec — `impression`, `firstQuartile`, `midpoint`, `thirdQuartile`, `complete`, `start`, `pause`, `resume`, `mute`, `unmute`, `expand`, `collapse`, `fullscreen`, `creativeView`, `clickTracking`, `error`. VPAID interactivity via `vpaid_enabled: true` flag. SIMID extensions for interactive video supported as VAST extensions. Orientation is a parameter (vertical / horizontal / square). Distinct from `video_hosted` (direct file with external tracking).", - "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { "slots": { "default": [ @@ -52,9 +51,28 @@ "max_width": { "type": "integer", "minimum": 1 }, "min_height": { "type": "integer", "minimum": 1 }, "max_height": { "type": "integer", "minimum": 1 }, + "creative_type": { + "type": "string", + "enum": ["linear", "nonlinear", "either"], + "description": "Required VAST creative class: `linear` (in-stream Linear), `nonlinear` (NonLinearAds overlay-class), or `either`. Supersedes `linear_required`; when both are present `creative_type` wins, and validators treat `linear_required: true` with no `creative_type` as `linear`." + }, + "ctv_ad_experience": { + "$ref": "/schemas/enums/ctv-ad-experience.json", + "description": "CTV experience this option serves. On video_vast only `pause`, `screensaver`, `overlay`, `squeezeback`, and `in_scene` are valid (`menu` routes to native_in_feed or sponsored_placement), and `creative_type` MUST be `nonlinear`. Per-experience floors: `overlay` and `squeezeback` require a 10s minimum duration; `in_scene` requires a 3s minimum brand-exposure duration and forbids interactivity (`vpaid_enabled`/`simid_supported` MUST NOT be true); `pause` has no duration floor and ends on viewer or device action. SIMID support is declared explicitly, never inferred, and NonLinear creatives MUST carry a non-interactive StaticResource or MediaFile fallback." + }, + "motion_level": { + "$ref": "/schemas/enums/motion-level.json", + "description": "Accepted motion class for the rendered creative (AdCOM attrs 21-23)." + }, + "activation_methods": { + "type": "array", + "uniqueItems": true, + "items": { "$ref": "/schemas/enums/activation-method.json" }, + "description": "Viewer activation mechanisms this option offers. Activations are engagement events, not impressions." + }, "linear_required": { "type": "boolean", - "description": "Whether the VAST creative must be linear (non-skippable in-stream)." + "description": "Whether the VAST creative must be linear (non-skippable in-stream). Superseded by `creative_type`; retained for pre-3.2 declarations." }, "skippable_after_ms": { "type": "integer", @@ -70,5 +88,39 @@ "type": "boolean" } }, + "allOf": [ + { "$ref": "/schemas/formats/canonical/_base.json" }, + { + "if": { "required": ["ctv_ad_experience"] }, + "then": { + "properties": { + "ctv_ad_experience": { "not": { "const": "menu" } }, + "creative_type": { "const": "nonlinear" } + }, + "required": ["creative_type"] + } + }, + { + "if": { + "properties": { "ctv_ad_experience": { "const": "in_scene" } }, + "required": ["ctv_ad_experience"] + }, + "then": { + "properties": { + "vpaid_enabled": { "const": false }, + "simid_supported": { "const": false } + } + } + } + ], + "x-adcp-validation": { + "verifier_constraints": { + "ctv_experience_matrix": "ctv_ad_experience on video_vast must be one of pause, screensaver, overlay, squeezeback, in_scene, with creative_type nonlinear", + "ctv_duration_floors": "overlay and squeezeback require duration_ms_exact or duration_ms_range minimum of at least 10000; in_scene at least 3000; pause has no floor", + "ctv_in_scene_no_interactivity": "in_scene forbids vpaid_enabled and simid_supported", + "creative_type_precedence": "creative_type wins over linear_required when both are declared; linear_required true with creative_type nonlinear or either is a contradiction and is rejected" + }, + "spec": "docs/creative/ctv-experiences.mdx" + }, "additionalProperties": true }