From 7b8c004a0e21ff2b6a222ca1082a3003ba13139b Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 26 Apr 2026 17:39:00 -0400 Subject: [PATCH 01/41] =?UTF-8?q?feat(creative):=20v2=20Phase=201=20?= =?UTF-8?q?=E2=80=94=20asset=5Fgroup=20vocabulary,=20scenes,=20delivery=5F?= =?UTF-8?q?type,=20video.mdx=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First PR implementing the v2 creative formats RFC (#3305). Backwards-compatible additions only. - Add static/schemas/source/core/asset-group-vocabulary.json (canonical asset_group_id registry — 7 existing catalog vocab entries + 12 audit-driven additions, with landing_page_url canonicalizing 6 v1 alias names) - Add static/schemas/source/creative/scenes.json (typed scene-by-scene structure for build_creative input; renamed from "storyboard" to avoid collision with the testing-harness storyboard concept) - Add optional delivery_type discriminator to html-asset.json and javascript-asset.json via oneOf (inline branch matches v1 producers without delivery_type; url branch lets zip URLs and 3P tag URLs round-trip cleanly) - Fix docs/creative/channels/video.mdx VAST/VPAID format examples (asset_type "url"+asset_role "vast_url" → asset_type "vast"; VPAID uses asset_type "vast" with vpaid_enabled: true) Tracks #3305 (v2 RFC). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../v2-phase1-vocab-scenes-delivery-type.md | 21 ++++ docs/creative/channels/video.mdx | 15 ++- .../source/core/asset-group-vocabulary.json | 119 ++++++++++++++++++ .../source/core/assets/html-asset.json | 49 ++++++-- .../source/core/assets/javascript-asset.json | 49 ++++++-- static/schemas/source/creative/scenes.json | 71 +++++++++++ 6 files changed, 302 insertions(+), 22 deletions(-) create mode 100644 .changeset/v2-phase1-vocab-scenes-delivery-type.md create mode 100644 static/schemas/source/core/asset-group-vocabulary.json create mode 100644 static/schemas/source/creative/scenes.json diff --git a/.changeset/v2-phase1-vocab-scenes-delivery-type.md b/.changeset/v2-phase1-vocab-scenes-delivery-type.md new file mode 100644 index 0000000000..e4bd2ae855 --- /dev/null +++ b/.changeset/v2-phase1-vocab-scenes-delivery-type.md @@ -0,0 +1,21 @@ +--- +"adcontextprotocol": patch +--- + +feat(creative): v2 Phase 1 — asset_group_id vocabulary registry, `scenes` schema, `delivery_type` on html/javascript assets, video.mdx asset_type fix + +First PR implementing the v2 creative formats RFC (#3305). Backwards-compatible additions only — no v1 producers are affected. + +**New schemas:** +- `static/schemas/source/core/asset-group-vocabulary.json` — canonical registry of `asset_group_id` values (the seven existing catalog vocab entries plus 12 audit-driven additions: `video_vertical`, `video_horizontal`, `audio`, `companion_image`, `companion_banner`, `brand_name`, `body_text`, `cards`, `landing_page_url`, `privacy_policy_url`, `youtube_video_id`, `pin_id`). Includes the `landing_page_url` aliases canonicalizing six different field names today (`click_url`, `link`, `final_url`, `link_url`, `click_through_url`, `landing_url`). Non-canonical IDs remain valid for platform-specific extensions; validators MAY soft-warn on non-canonical usage. +- `static/schemas/source/creative/scenes.json` — typed scene-by-scene structure used as input to `build_creative` for generative video platforms. Each scene has `order`, `duration_ms`, `description`, optional `vo` and `caption`. Renamed from "storyboard" to avoid collision with the testing-harness storyboard concept. + +**Schema additions (backwards-compatible):** +- `html-asset.json` and `javascript-asset.json` — added optional `delivery_type` discriminator with `oneOf` for inline (existing) or url (new). v1 producers that don't emit `delivery_type` continue to validate via the inline branch (`content` required). New url branch lets HTML5 zip URLs and 3P display tag URLs round-trip cleanly without iframe-wrapping at runtime. Mirrors the VAST/DAAST naming convention; uses oneOf without the formal `discriminator` keyword to preserve v1 producer compatibility (the registry's `nested_discriminator_pattern` doc scopes the strict pattern to "future asset types with internal variants"). + +**Doc fix:** +- `docs/creative/channels/video.mdx:421-450,762-775` — corrected three format-definition examples that used `asset_type: "url"` + `asset_role: "vast_url"` / `"vpaid_url"`, contradicting the schema-correct `asset_type: "vast"` used elsewhere in the same file. Updated VPAID examples to use `asset_type: "vast"` with `vpaid_enabled: true` in requirements. + +**Why patch:** schema additions and a doc bugfix; no breaking changes to the published spec. + +Tracks #3305 (v2 RFC). Subsequent phases: canonical format catalog + `ProductFormatDeclaration` schema (Phase 2), `validate_input` and `preview_creative` updates (Phase 3), SDK codegen (Phase 4). diff --git a/docs/creative/channels/video.mdx b/docs/creative/channels/video.mdx index d9af5b392d..ec106856fe 100644 --- a/docs/creative/channels/video.mdx +++ b/docs/creative/channels/video.mdx @@ -419,8 +419,7 @@ For third-party ad servers: "assets": [ { "asset_id": "vast_tag", - "asset_type": "url", - "asset_role": "vast_url", + "asset_type": "vast", "item_type": "individual", "required": true, "requirements": { @@ -445,13 +444,13 @@ For third-party ad servers: "assets": [ { "asset_id": "vpaid_tag", - "asset_type": "url", - "asset_role": "vpaid_url", + "asset_type": "vast", "item_type": "individual", "required": true, "requirements": { "vpaid_version": ["2.0"], - "api_framework": "VPAID" + "api_framework": "VPAID", + "vpaid_enabled": true } } ] @@ -762,13 +761,13 @@ VPAID (Video Player Ad-Serving Interface Definition) enables interactive video a "assets": [ { "asset_id": "vpaid_tag", - "asset_type": "url", - "asset_role": "vpaid_url", + "asset_type": "vast", "item_type": "individual", "required": true, "requirements": { "vpaid_version": ["2.0"], - "api_framework": "VPAID" + "api_framework": "VPAID", + "vpaid_enabled": true } } ] diff --git a/static/schemas/source/core/asset-group-vocabulary.json b/static/schemas/source/core/asset-group-vocabulary.json new file mode 100644 index 0000000000..71160d8757 --- /dev/null +++ b/static/schemas/source/core/asset-group-vocabulary.json @@ -0,0 +1,119 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/asset-group-vocabulary.json", + "title": "AdCP Asset Group Vocabulary Registry", + "description": "Canonical registry of asset_group_id values used in offering asset groups (OfferingAssetGroup) and in v2 product format declarations. Non-canonical IDs remain valid for platform-specific extensions; this registry codifies the recommended canonical set so that buyers and sellers share a vocabulary for the most common slot roles. Validators may emit soft warnings on non-canonical IDs to encourage convergence.", + "version": "1.0.0", + "lastUpdated": "2026-04-26", + "vocabulary": { + "headlines": { + "description": "Pool of headline text variants for the surface to choose from.", + "asset_type": "text", + "typical_use": "Multiple short headline copy variants (Google PMax/RDA, Meta promoted_offerings, etc.)" + }, + "descriptions": { + "description": "Pool of body description text variants.", + "asset_type": "text", + "typical_use": "Body-copy variants for surfaces that pick the best combination." + }, + "images_landscape": { + "description": "Pool of landscape-orientation images (1.91:1 or 16:9 typical).", + "asset_type": "image", + "typical_use": "Hero images for landscape-format placements (Meta feed, LinkedIn feed, Google display)." + }, + "images_vertical": { + "description": "Pool of vertical-orientation images (9:16 typical).", + "asset_type": "image", + "typical_use": "Hero images for stories, reels, and other vertical placements (Snap, TikTok, Meta Stories, Pinterest)." + }, + "images_square": { + "description": "Pool of square-orientation images (1:1).", + "asset_type": "image", + "typical_use": "Feed-context images, profile-style placements, square carousel cards." + }, + "logo": { + "description": "Brand logo asset (typically 1:1 or 2:1).", + "asset_type": "image", + "typical_use": "Brand attribution overlay (Google PMax/RDA, Snap Story Ad, Amazon SB)." + }, + "video": { + "description": "Pool of video assets.", + "asset_type": "video", + "typical_use": "Video creative for video placements; orientation determined by platform constraints." + }, + "video_vertical": { + "description": "Pool of vertical-orientation video (9:16).", + "asset_type": "video", + "typical_use": "Reels, Stories, TikTok In-Feed, Snap Spotlight, vertical short-form video." + }, + "video_horizontal": { + "description": "Pool of horizontal-orientation video (16:9).", + "asset_type": "video", + "typical_use": "Pre-roll, mid-roll, instream, CTV, in-feed horizontal video." + }, + "audio": { + "description": "Audio asset.", + "asset_type": "audio", + "typical_use": "Audio ads for streaming, podcasts, broadcast radio." + }, + "companion_image": { + "description": "Image displayed alongside an audio asset.", + "asset_type": "image", + "typical_use": "Brand-attribution image paired with audio (Spotify standard 640x640, Amazon DSP audio 300x300)." + }, + "companion_banner": { + "description": "Banner image displayed alongside a video asset.", + "asset_type": "image", + "typical_use": "Companion banner for instream video (Google video 300x60, Amazon DSP 1.91:1)." + }, + "brand_name": { + "description": "Short brand attribution text (distinct from headline).", + "asset_type": "text", + "typical_use": "Brand name overlay on Snap, TikTok, Spotify, Pinterest creative." + }, + "body_text": { + "description": "Longer free-text body content.", + "asset_type": "text", + "typical_use": "Body copy on Reddit, Amazon DSP, LinkedIn formats — distinct from short headlines." + }, + "cards": { + "description": "Per-item carousel card array.", + "asset_type": "object", + "typical_use": "Carousel slides on Meta, TikTok, Pinterest, LinkedIn, Reddit. Each card carries its own image/video, headline, and link." + }, + "landing_page_url": { + "description": "Click-through destination URL for the ad.", + "asset_type": "url", + "typical_use": "Primary destination for ad click-through across all canonical formats.", + "aliases": [ + "click_url", + "link", + "final_url", + "link_url", + "click_through_url", + "landing_url" + ], + "note": "Canonical name for the destination URL slot. Six different field names exist across platform format definitions today; adopters should standardize on `landing_page_url` for v2." + }, + "privacy_policy_url": { + "description": "Privacy policy URL required for lead-form variants.", + "asset_type": "url", + "typical_use": "LinkedIn lead gen, Snap lead gen — required for any format that collects user data." + }, + "youtube_video_id": { + "description": "Externally-hosted YouTube video reference.", + "asset_type": "text", + "typical_use": "Reference an existing YouTube video for Google instream/bumper/non-skippable, PMax." + }, + "pin_id": { + "description": "Externally-hosted Pinterest creative reference.", + "asset_type": "text", + "typical_use": "Reference an existing Pin (parallel to youtube_video_id)." + } + }, + "governance": { + "extension_policy": "Non-canonical asset_group_id values remain valid for platform-specific extensions. Validators may emit soft warnings to encourage adoption of canonical values where applicable. New canonical entries are added via PR with rationale, at least one reference adopter, and AAO maintainer review.", + "alias_policy": "Aliases listed for canonical entries (e.g., `landing_page_url` aliases) are recognized as v1-era variants. v2 adopters should standardize on the canonical name; v1 aliases continue to work for backwards compatibility.", + "versioning": "This registry is versioned independently of the AdCP spec. Bump `version` on any addition or alias change; preserve existing canonical entries to avoid breaking adopters that have standardized on them." + } +} diff --git a/static/schemas/source/core/assets/html-asset.json b/static/schemas/source/core/assets/html-asset.json index e28a47d581..c78d96a4bc 100644 --- a/static/schemas/source/core/assets/html-asset.json +++ b/static/schemas/source/core/assets/html-asset.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/assets/html-asset.json", "title": "HTML Asset", - "description": "HTML content asset", + "description": "HTML content asset, deliverable inline (HTML markup as a string) or by URL (zip bundle URL, third-party display tag URL). The optional `delivery_type` follows the VAST/DAAST naming convention; absence implies inline for backwards compatibility with v1 producers.", "type": "object", "properties": { "asset_type": { @@ -10,10 +10,6 @@ "const": "html", "description": "Discriminator identifying this as an HTML asset. See /schemas/creative/asset-types for the registry." }, - "content": { - "type": "string", - "description": "HTML content" - }, "version": { "type": "string", "description": "HTML version (e.g., 'HTML5')" @@ -47,8 +43,47 @@ } }, "required": [ - "asset_type", - "content" + "asset_type" + ], + "oneOf": [ + { + "title": "Inline HTML content", + "description": "HTML markup delivered as a string. Default when `delivery_type` is absent (v1-compatible).", + "properties": { + "delivery_type": { + "type": "string", + "enum": ["inline"], + "description": "Optional discriminator. Absence is treated as inline." + }, + "content": { + "type": "string", + "description": "Inline HTML content" + } + }, + "required": [ + "content" + ] + }, + { + "title": "URL-delivered HTML", + "description": "HTML content served at a URL endpoint (e.g., HTML5 banner zip, third-party display tag).", + "properties": { + "delivery_type": { + "type": "string", + "const": "url", + "description": "Discriminator indicating HTML is delivered via URL endpoint." + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL endpoint that serves the HTML content (zip bundle, third-party tag URL, etc.)." + } + }, + "required": [ + "delivery_type", + "url" + ] + } ], "additionalProperties": true } diff --git a/static/schemas/source/core/assets/javascript-asset.json b/static/schemas/source/core/assets/javascript-asset.json index 0af6b3adb7..b3eb000cbb 100644 --- a/static/schemas/source/core/assets/javascript-asset.json +++ b/static/schemas/source/core/assets/javascript-asset.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/assets/javascript-asset.json", "title": "JavaScript Asset", - "description": "JavaScript code asset", + "description": "JavaScript code asset, deliverable inline (code as a string) or by URL (third-party tag URL). The optional `delivery_type` follows the VAST/DAAST naming convention; absence implies inline for backwards compatibility with v1 producers.", "type": "object", "properties": { "asset_type": { @@ -10,10 +10,6 @@ "const": "javascript", "description": "Discriminator identifying this as a JavaScript asset. See /schemas/creative/asset-types for the registry." }, - "content": { - "type": "string", - "description": "JavaScript content" - }, "module_type": { "$ref": "/schemas/enums/javascript-module-type.json", "description": "JavaScript module type" @@ -47,8 +43,47 @@ } }, "required": [ - "asset_type", - "content" + "asset_type" + ], + "oneOf": [ + { + "title": "Inline JavaScript content", + "description": "JavaScript code delivered as a string. Default when `delivery_type` is absent (v1-compatible).", + "properties": { + "delivery_type": { + "type": "string", + "enum": ["inline"], + "description": "Optional discriminator. Absence is treated as inline." + }, + "content": { + "type": "string", + "description": "Inline JavaScript content" + } + }, + "required": [ + "content" + ] + }, + { + "title": "URL-delivered JavaScript", + "description": "JavaScript served at a URL endpoint (e.g., third-party display tag, externally-hosted JS bundle).", + "properties": { + "delivery_type": { + "type": "string", + "const": "url", + "description": "Discriminator indicating JavaScript is delivered via URL endpoint." + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL endpoint that serves the JavaScript content (third-party tag URL, hosted bundle, etc.)." + } + }, + "required": [ + "delivery_type", + "url" + ] + } ], "additionalProperties": true } diff --git a/static/schemas/source/creative/scenes.json b/static/schemas/source/creative/scenes.json new file mode 100644 index 0000000000..2965a932e8 --- /dev/null +++ b/static/schemas/source/creative/scenes.json @@ -0,0 +1,71 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/creative/scenes.json", + "title": "Scenes", + "description": "Typed scene-by-scene structure used as input to build_creative for generative video platforms. Renamed from 'storyboard' to avoid collision with the testing-harness storyboard concept used elsewhere in AdCP.", + "type": "object", + "required": ["scenes"], + "properties": { + "scenes": { + "type": "array", + "minItems": 1, + "description": "Ordered list of scenes that compose the generated video. Sum of `duration_ms` across scenes should match the target video duration.", + "items": { + "type": "object", + "required": ["order", "duration_ms", "description"], + "properties": { + "order": { + "type": "integer", + "minimum": 1, + "description": "1-indexed sequence position of this scene in the final video." + }, + "duration_ms": { + "type": "integer", + "minimum": 1, + "description": "Duration of this scene in milliseconds." + }, + "description": { + "type": "string", + "description": "Visual description of the scene for synthesis (what should appear, action, mood, framing)." + }, + "vo": { + "type": "string", + "description": "Voiceover script for this scene (optional)." + }, + "caption": { + "type": "string", + "description": "On-screen caption text for this scene (optional)." + } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true, + "examples": [ + { + "scenes": [ + { + "order": 1, + "duration_ms": 3000, + "description": "Open on a sunset beach with the brand logo overlay", + "vo": "Welcome to summer with Acme", + "caption": "Summer Sale Now" + }, + { + "order": 2, + "duration_ms": 4000, + "description": "Product close-up — limited-edition sneakers, slow zoom", + "vo": "Limited edition. Hand-crafted.", + "caption": "Up to 50% Off" + }, + { + "order": 3, + "duration_ms": 3000, + "description": "Logo end-card with CTA button overlay", + "caption": "Shop Now" + } + ] + } + ] +} From 8d090c0857750ca2852a1515cff9e3ee1175ceb0 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 26 Apr 2026 18:12:55 -0400 Subject: [PATCH 02/41] =?UTF-8?q?fix(creative):=20address=20Phase=201=20re?= =?UTF-8?q?view=20catches=20=E2=80=94=20audio.mdx=20VAST=20bug=20+=20regis?= =?UTF-8?q?ter=20vocabulary=20in=20source=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from independent expert review of #3307: - audio.mdx:200 had the same bug pattern as video.mdx (caught by code-reviewer): the audio_30s_vast manifest example used asset_type "url" + url_type "tracker" for what should be a VAST audio tag. Corrected to asset_type "vast" with delivery_type "url"; renamed slot key from "vast_url" to "vast_tag" for clarity. - Register asset-group-vocabulary.json under core schemas and scenes.json under creative.build_inputs in static/schemas/source/index.json so the new schemas are discoverable via the public registry. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/creative/channels/audio.mdx | 6 +++--- static/schemas/source/index.json | 10 ++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/creative/channels/audio.mdx b/docs/creative/channels/audio.mdx index 4bb3f34eda..ee6a30a7c8 100644 --- a/docs/creative/channels/audio.mdx +++ b/docs/creative/channels/audio.mdx @@ -197,9 +197,9 @@ Multi-segment audio assembled dynamically: "id": "audio_30s_vast" }, "assets": { - "vast_url": { - "asset_type": "url", - "url_type": "tracker", + "vast_tag": { + "asset_type": "vast", + "delivery_type": "url", "url": "https://ad-server.brand.com/audio-vast?campaign={MEDIA_BUY_ID}&cb={CACHEBUSTER}" } } diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index 646acbb773..1de279f641 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -194,6 +194,10 @@ "$ref": "/schemas/core/offering-asset-group.json", "description": "A structured group of creative assets within an offering, identified by group ID and asset type" }, + "asset-group-vocabulary": { + "$ref": "/schemas/core/asset-group-vocabulary.json", + "description": "Canonical registry of asset_group_id values with descriptions and v1 alias mapping (e.g., landing_page_url replaces 6 v1 alias names)" + }, "store-item": { "$ref": "/schemas/core/store-item.json", "description": "A physical store or location with coordinates, address, and catchment areas for proximity targeting" @@ -1106,6 +1110,12 @@ "asset_types": { "$ref": "/schemas/creative/asset-types/index.json", "description": "Asset type definitions for creative manifests" + }, + "build_inputs": { + "scenes": { + "$ref": "/schemas/creative/scenes.json", + "description": "Typed scene-by-scene structure for build_creative input on generative video platforms" + } } }, "signals": { From 8fdc4072980deff27760349c61790d2624f8b759 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 26 Apr 2026 18:25:44 -0400 Subject: [PATCH 03/41] fix(docs): vast_version must be a single string, not an array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught: when I changed asset_type from "url" to "vast" in video.mdx:410, the vast-asset-requirements.json schema started applying — it requires vast_version as a single string from the enum, not an array. Original doc had vast_version: ["3.0", "4.0", "4.1", "4.2"] which the looser "url" asset_type tolerated. Fix: vast_version: "4.2" (matches the manifest example at line 511). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/creative/channels/video.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/creative/channels/video.mdx b/docs/creative/channels/video.mdx index ec106856fe..a39730fb59 100644 --- a/docs/creative/channels/video.mdx +++ b/docs/creative/channels/video.mdx @@ -423,7 +423,7 @@ For third-party ad servers: "item_type": "individual", "required": true, "requirements": { - "vast_version": ["3.0", "4.0", "4.1", "4.2"] + "vast_version": "4.2" } } ] From 6324fbace2170b6b96797718c39eb03baa55b601 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 27 Apr 2026 05:56:50 -0400 Subject: [PATCH 04/41] chore(changeset): correct phase 1 bump from patch to minor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New schemas (asset-group-vocabulary.json, scenes.json) and a new optional field (delivery_type on html/javascript) are additive features. Patch is reserved for bug fixes only. Aligns with the RFC's "3.1 preview track" framing — Phase 1 is the first PR toward 3.1.0. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/v2-phase1-vocab-scenes-delivery-type.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/v2-phase1-vocab-scenes-delivery-type.md b/.changeset/v2-phase1-vocab-scenes-delivery-type.md index e4bd2ae855..b3b1556441 100644 --- a/.changeset/v2-phase1-vocab-scenes-delivery-type.md +++ b/.changeset/v2-phase1-vocab-scenes-delivery-type.md @@ -1,10 +1,10 @@ --- -"adcontextprotocol": patch +"adcontextprotocol": minor --- feat(creative): v2 Phase 1 — asset_group_id vocabulary registry, `scenes` schema, `delivery_type` on html/javascript assets, video.mdx asset_type fix -First PR implementing the v2 creative formats RFC (#3305). Backwards-compatible additions only — no v1 producers are affected. +First PR implementing the v2 creative formats RFC (#3305). Backwards-compatible additions only — no v1 producers are affected. Minor bump because this introduces new schemas (`asset-group-vocabulary.json`, `scenes.json`) and a new optional field (`delivery_type` on html/javascript assets), which are additive features rather than bug fixes. **New schemas:** - `static/schemas/source/core/asset-group-vocabulary.json` — canonical registry of `asset_group_id` values (the seven existing catalog vocab entries plus 12 audit-driven additions: `video_vertical`, `video_horizontal`, `audio`, `companion_image`, `companion_banner`, `brand_name`, `body_text`, `cards`, `landing_page_url`, `privacy_policy_url`, `youtube_video_id`, `pin_id`). Includes the `landing_page_url` aliases canonicalizing six different field names today (`click_url`, `link`, `final_url`, `link_url`, `click_through_url`, `landing_url`). Non-canonical IDs remain valid for platform-specific extensions; validators MAY soft-warn on non-canonical usage. @@ -16,6 +16,6 @@ First PR implementing the v2 creative formats RFC (#3305). Backwards-compatible **Doc fix:** - `docs/creative/channels/video.mdx:421-450,762-775` — corrected three format-definition examples that used `asset_type: "url"` + `asset_role: "vast_url"` / `"vpaid_url"`, contradicting the schema-correct `asset_type: "vast"` used elsewhere in the same file. Updated VPAID examples to use `asset_type: "vast"` with `vpaid_enabled: true` in requirements. -**Why patch:** schema additions and a doc bugfix; no breaking changes to the published spec. +**Why minor (not patch):** new schemas and a new optional field are additive features — patch is reserved for bug fixes only. **Why not major:** no breaking changes; v1 producers and consumers continue to work unchanged. Tracks #3305 (v2 RFC). Subsequent phases: canonical format catalog + `ProductFormatDeclaration` schema (Phase 2), `validate_input` and `preview_creative` updates (Phase 3), SDK codegen (Phase 4). From 4df03c2f2fddff53fe5f3e1e6ffe8511e9b17a19 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 27 Apr 2026 08:02:01 -0400 Subject: [PATCH 05/41] refactor(creative): replace delivery_type addition with zip asset type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 design scrub. The delivery_type oneOf addition on html/javascript schemas wasn't earning its keep — URL-delivered HTML/JS already routes through url-asset.json with appropriate url_type. The real gap was a zip asset type for HTML5 banner bundles, which is genuinely missing from the registry today. - Revert delivery_type from html-asset.json and javascript-asset.json (back to inline-only, matching v1 behavior) - Add static/schemas/source/core/assets/zip-asset.json — new asset type for bundled HTML5 banners (url + max_file_size_kb + entry_point + allowed_inner_extensions + backup_image_url + sha256 digest) - Register zip in creative/asset-types/index.json - Add IndividualZipAsset / GroupZipAsset branches to format.json - Add zip-asset.json $ref to creative-manifest.json, creative-asset.json, creative/list-creatives-response.json, offering-asset-group.json - Clarify scenes.json description re: reference-asset.json purpose: "storyboard" (related but different concept — structured plan vs visual reference asset) - Rename changeset accordingly Co-Authored-By: Claude Opus 4.7 (1M context) --- .../v2-phase1-vocab-scenes-delivery-type.md | 21 ----- .changeset/v2-phase1-vocab-scenes-zip.md | 30 +++++++ .../source/core/assets/html-asset.json | 49 ++-------- .../source/core/assets/javascript-asset.json | 49 ++-------- .../schemas/source/core/assets/zip-asset.json | 89 +++++++++++++++++++ .../schemas/source/core/creative-asset.json | 1 + .../source/core/creative-manifest.json | 1 + static/schemas/source/core/format.json | 17 ++++ .../source/core/offering-asset-group.json | 1 + .../source/creative/asset-types/index.json | 5 ++ .../creative/list-creatives-response.json | 1 + static/schemas/source/creative/scenes.json | 2 +- 12 files changed, 160 insertions(+), 106 deletions(-) delete mode 100644 .changeset/v2-phase1-vocab-scenes-delivery-type.md create mode 100644 .changeset/v2-phase1-vocab-scenes-zip.md create mode 100644 static/schemas/source/core/assets/zip-asset.json diff --git a/.changeset/v2-phase1-vocab-scenes-delivery-type.md b/.changeset/v2-phase1-vocab-scenes-delivery-type.md deleted file mode 100644 index b3b1556441..0000000000 --- a/.changeset/v2-phase1-vocab-scenes-delivery-type.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"adcontextprotocol": minor ---- - -feat(creative): v2 Phase 1 — asset_group_id vocabulary registry, `scenes` schema, `delivery_type` on html/javascript assets, video.mdx asset_type fix - -First PR implementing the v2 creative formats RFC (#3305). Backwards-compatible additions only — no v1 producers are affected. Minor bump because this introduces new schemas (`asset-group-vocabulary.json`, `scenes.json`) and a new optional field (`delivery_type` on html/javascript assets), which are additive features rather than bug fixes. - -**New schemas:** -- `static/schemas/source/core/asset-group-vocabulary.json` — canonical registry of `asset_group_id` values (the seven existing catalog vocab entries plus 12 audit-driven additions: `video_vertical`, `video_horizontal`, `audio`, `companion_image`, `companion_banner`, `brand_name`, `body_text`, `cards`, `landing_page_url`, `privacy_policy_url`, `youtube_video_id`, `pin_id`). Includes the `landing_page_url` aliases canonicalizing six different field names today (`click_url`, `link`, `final_url`, `link_url`, `click_through_url`, `landing_url`). Non-canonical IDs remain valid for platform-specific extensions; validators MAY soft-warn on non-canonical usage. -- `static/schemas/source/creative/scenes.json` — typed scene-by-scene structure used as input to `build_creative` for generative video platforms. Each scene has `order`, `duration_ms`, `description`, optional `vo` and `caption`. Renamed from "storyboard" to avoid collision with the testing-harness storyboard concept. - -**Schema additions (backwards-compatible):** -- `html-asset.json` and `javascript-asset.json` — added optional `delivery_type` discriminator with `oneOf` for inline (existing) or url (new). v1 producers that don't emit `delivery_type` continue to validate via the inline branch (`content` required). New url branch lets HTML5 zip URLs and 3P display tag URLs round-trip cleanly without iframe-wrapping at runtime. Mirrors the VAST/DAAST naming convention; uses oneOf without the formal `discriminator` keyword to preserve v1 producer compatibility (the registry's `nested_discriminator_pattern` doc scopes the strict pattern to "future asset types with internal variants"). - -**Doc fix:** -- `docs/creative/channels/video.mdx:421-450,762-775` — corrected three format-definition examples that used `asset_type: "url"` + `asset_role: "vast_url"` / `"vpaid_url"`, contradicting the schema-correct `asset_type: "vast"` used elsewhere in the same file. Updated VPAID examples to use `asset_type: "vast"` with `vpaid_enabled: true` in requirements. - -**Why minor (not patch):** new schemas and a new optional field are additive features — patch is reserved for bug fixes only. **Why not major:** no breaking changes; v1 producers and consumers continue to work unchanged. - -Tracks #3305 (v2 RFC). Subsequent phases: canonical format catalog + `ProductFormatDeclaration` schema (Phase 2), `validate_input` and `preview_creative` updates (Phase 3), SDK codegen (Phase 4). diff --git a/.changeset/v2-phase1-vocab-scenes-zip.md b/.changeset/v2-phase1-vocab-scenes-zip.md new file mode 100644 index 0000000000..675cdd892d --- /dev/null +++ b/.changeset/v2-phase1-vocab-scenes-zip.md @@ -0,0 +1,30 @@ +--- +"adcontextprotocol": minor +--- + +feat(creative): v2 Phase 1 — asset_group_id vocabulary registry, `scenes` schema, `zip` asset type, video/audio mdx asset_type fixes + +First PR implementing the v2 creative formats RFC (#3305). Backwards-compatible additions only — no v1 producers are affected. Minor bump because this introduces new schemas (`asset-group-vocabulary.json`, `scenes.json`, `zip-asset.json`), which are additive features rather than bug fixes. + +**New schemas:** + +- `static/schemas/source/core/asset-group-vocabulary.json` — canonical registry of `asset_group_id` values (the seven existing catalog vocab entries plus 12 audit-driven additions: `video_vertical`, `video_horizontal`, `audio`, `companion_image`, `companion_banner`, `brand_name`, `body_text`, `cards`, `landing_page_url`, `privacy_policy_url`, `youtube_video_id`, `pin_id`). Includes the `landing_page_url` aliases canonicalizing six different field names today (`click_url`, `link`, `final_url`, `link_url`, `click_through_url`, `landing_url`). Non-canonical IDs remain valid for platform-specific extensions; validators MAY soft-warn on non-canonical usage. + +- `static/schemas/source/creative/scenes.json` — typed scene-by-scene structure used as input to `build_creative` for generative video platforms. Each scene has `order`, `duration_ms`, `description`, optional `vo` and `caption`. Renamed from "storyboard" to avoid collision with the testing-harness storyboard concept; description disambiguates from `reference-asset.json` `purpose: "storyboard"` (which describes a reference asset, not a structured plan). + +- `static/schemas/source/core/assets/zip-asset.json` — new asset type for bundled creatives delivered as zip archives (HTML5 banners with index.html + CSS + JS + images, MRAID-compatible interactive ads). Carries `url`, optional `max_file_size_kb`, `entry_point`, `allowed_inner_extensions`, `backup_image_url`, and SHA-256 `digest` for integrity. Distinct from inline HTML (`html` asset) and from third-party tag URLs (`url` asset with appropriate `url_type`). + +**Registry updates:** + +- `static/schemas/source/creative/asset-types/index.json` — added `zip` entry pointing at the new schema +- `static/schemas/source/core/format.json` — added `IndividualZipAsset` and `GroupZipAsset` branches to the format declaration oneOf +- `static/schemas/source/core/offering-asset-group.json`, `creative-manifest.json`, `creative-asset.json`, `creative/list-creatives-response.json` — added `zip-asset.json` to manifest/asset-group oneOf branches so manifests can carry zip assets + +**Doc fixes:** + +- `docs/creative/channels/video.mdx` — corrected three format-definition examples that used `asset_type: "url"` + `asset_role: "vast_url"` / `"vpaid_url"`, contradicting the schema-correct `asset_type: "vast"` used elsewhere in the same file. Updated VPAID examples to use `asset_type: "vast"` with `vpaid_enabled: true` in requirements. +- `docs/creative/channels/audio.mdx:200` — same bug pattern: `asset_type: "url"` for what should be a VAST audio tag. Corrected to `asset_type: "vast"` with `delivery_type: "url"`; renamed slot key from `vast_url` to `vast_tag` for clarity. + +**Why minor (not patch):** new schemas and a new asset type are additive features — patch is reserved for bug fixes only. **Why not major:** no breaking changes; v1 producers and consumers continue to work unchanged. The new `zip` asset type is purely additive — receivers that don't recognize it ignore it via standard discriminator-mismatch handling. + +Tracks #3305 (v2 RFC). Phase 1 lays foundational primitives; subsequent phases build the canonical format catalog, `ProductFormatDeclaration` schema, and tools on top of these primitives. diff --git a/static/schemas/source/core/assets/html-asset.json b/static/schemas/source/core/assets/html-asset.json index c78d96a4bc..76b89e6f4a 100644 --- a/static/schemas/source/core/assets/html-asset.json +++ b/static/schemas/source/core/assets/html-asset.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/assets/html-asset.json", "title": "HTML Asset", - "description": "HTML content asset, deliverable inline (HTML markup as a string) or by URL (zip bundle URL, third-party display tag URL). The optional `delivery_type` follows the VAST/DAAST naming convention; absence implies inline for backwards compatibility with v1 producers.", + "description": "Inline HTML content asset. For URL-delivered HTML5 banner bundles, use the zip asset type instead. For single-URL iframe-rendered tag references, use the url asset type with an appropriate url_type.", "type": "object", "properties": { "asset_type": { @@ -10,6 +10,10 @@ "const": "html", "description": "Discriminator identifying this as an HTML asset. See /schemas/creative/asset-types for the registry." }, + "content": { + "type": "string", + "description": "HTML content" + }, "version": { "type": "string", "description": "HTML version (e.g., 'HTML5')" @@ -43,47 +47,8 @@ } }, "required": [ - "asset_type" - ], - "oneOf": [ - { - "title": "Inline HTML content", - "description": "HTML markup delivered as a string. Default when `delivery_type` is absent (v1-compatible).", - "properties": { - "delivery_type": { - "type": "string", - "enum": ["inline"], - "description": "Optional discriminator. Absence is treated as inline." - }, - "content": { - "type": "string", - "description": "Inline HTML content" - } - }, - "required": [ - "content" - ] - }, - { - "title": "URL-delivered HTML", - "description": "HTML content served at a URL endpoint (e.g., HTML5 banner zip, third-party display tag).", - "properties": { - "delivery_type": { - "type": "string", - "const": "url", - "description": "Discriminator indicating HTML is delivered via URL endpoint." - }, - "url": { - "type": "string", - "format": "uri", - "description": "URL endpoint that serves the HTML content (zip bundle, third-party tag URL, etc.)." - } - }, - "required": [ - "delivery_type", - "url" - ] - } + "asset_type", + "content" ], "additionalProperties": true } diff --git a/static/schemas/source/core/assets/javascript-asset.json b/static/schemas/source/core/assets/javascript-asset.json index b3eb000cbb..9b1522c7ad 100644 --- a/static/schemas/source/core/assets/javascript-asset.json +++ b/static/schemas/source/core/assets/javascript-asset.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/assets/javascript-asset.json", "title": "JavaScript Asset", - "description": "JavaScript code asset, deliverable inline (code as a string) or by URL (third-party tag URL). The optional `delivery_type` follows the VAST/DAAST naming convention; absence implies inline for backwards compatibility with v1 producers.", + "description": "Inline JavaScript content asset. For URL-delivered third-party tag scripts, use the url asset type with url_type 'tracker_script'. For HTML5 banner bundles that include JavaScript, use the zip asset type.", "type": "object", "properties": { "asset_type": { @@ -10,6 +10,10 @@ "const": "javascript", "description": "Discriminator identifying this as a JavaScript asset. See /schemas/creative/asset-types for the registry." }, + "content": { + "type": "string", + "description": "JavaScript content" + }, "module_type": { "$ref": "/schemas/enums/javascript-module-type.json", "description": "JavaScript module type" @@ -43,47 +47,8 @@ } }, "required": [ - "asset_type" - ], - "oneOf": [ - { - "title": "Inline JavaScript content", - "description": "JavaScript code delivered as a string. Default when `delivery_type` is absent (v1-compatible).", - "properties": { - "delivery_type": { - "type": "string", - "enum": ["inline"], - "description": "Optional discriminator. Absence is treated as inline." - }, - "content": { - "type": "string", - "description": "Inline JavaScript content" - } - }, - "required": [ - "content" - ] - }, - { - "title": "URL-delivered JavaScript", - "description": "JavaScript served at a URL endpoint (e.g., third-party display tag, externally-hosted JS bundle).", - "properties": { - "delivery_type": { - "type": "string", - "const": "url", - "description": "Discriminator indicating JavaScript is delivered via URL endpoint." - }, - "url": { - "type": "string", - "format": "uri", - "description": "URL endpoint that serves the JavaScript content (third-party tag URL, hosted bundle, etc.)." - } - }, - "required": [ - "delivery_type", - "url" - ] - } + "asset_type", + "content" ], "additionalProperties": true } diff --git a/static/schemas/source/core/assets/zip-asset.json b/static/schemas/source/core/assets/zip-asset.json new file mode 100644 index 0000000000..9a7f1ca2dc --- /dev/null +++ b/static/schemas/source/core/assets/zip-asset.json @@ -0,0 +1,89 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/assets/zip-asset.json", + "title": "Zip Asset", + "description": "Bundled creative asset delivered as a zip archive — typically an HTML5 banner with index.html plus supporting CSS, JS, images, and fonts. Receivers unpack the zip, validate internal structure, and serve contents from CDN. Distinct from inline HTML (html asset) and from third-party tag URLs (url asset with url_type tracker_script).", + "type": "object", + "properties": { + "asset_type": { + "type": "string", + "const": "zip", + "description": "Discriminator identifying this as a zip-bundled asset. See /schemas/creative/asset-types for the registry." + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL where the zip archive is hosted. Must be HTTPS." + }, + "max_file_size_kb": { + "type": "integer", + "minimum": 0, + "description": "Maximum file size in kilobytes. Receivers should reject zips exceeding this." + }, + "entry_point": { + "type": "string", + "description": "Relative path to the entry file within the zip (typically 'index.html'). Receivers default to 'index.html' if absent." + }, + "allowed_inner_extensions": { + "type": "array", + "items": { "type": "string" }, + "description": "File extensions permitted inside the zip (e.g., ['html', 'css', 'js', 'png', 'jpg', 'svg', 'webp', 'json', 'woff2']). Receivers may reject zips containing other extensions." + }, + "backup_image_url": { + "type": "string", + "format": "uri", + "description": "Fallback image URL for environments that cannot render the bundled creative (e.g., non-HTML5 endpoints, ad blockers). Recommended for HTML5 banners." + }, + "digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$", + "description": "Optional SHA-256 content digest of the zip archive (sha256:) for integrity verification. Lets receivers detect tampered or stale archives." + }, + "accessibility": { + "type": "object", + "description": "Self-declared accessibility properties for this opaque creative", + "x-accessibility": true, + "properties": { + "alt_text": { + "type": "string", + "description": "Text alternative describing the creative content" + }, + "keyboard_navigable": { + "type": "boolean", + "description": "Whether the creative can be fully operated via keyboard" + }, + "motion_control": { + "type": "boolean", + "description": "Whether the creative respects prefers-reduced-motion or provides pause/stop controls" + }, + "screen_reader_tested": { + "type": "boolean", + "description": "Whether the creative has been tested with screen readers" + } + } + }, + "provenance": { + "$ref": "/schemas/core/provenance.json", + "description": "Provenance metadata for this asset, overrides manifest-level provenance" + } + }, + "required": [ + "asset_type", + "url" + ], + "additionalProperties": true, + "examples": [ + { + "description": "HTML5 banner bundle with backup image", + "data": { + "asset_type": "zip", + "url": "https://cdn.acme.example/creatives/spring-300x250-html5.zip", + "max_file_size_kb": 200, + "entry_point": "index.html", + "allowed_inner_extensions": ["html", "css", "js", "png", "jpg", "svg", "json"], + "backup_image_url": "https://cdn.acme.example/creatives/spring-300x250-backup.jpg", + "digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + } + ] +} diff --git a/static/schemas/source/core/creative-asset.json b/static/schemas/source/core/creative-asset.json index 3f7d692c7a..3ac6d533cc 100644 --- a/static/schemas/source/core/creative-asset.json +++ b/static/schemas/source/core/creative-asset.json @@ -32,6 +32,7 @@ { "$ref": "/schemas/core/assets/url-asset.json" }, { "$ref": "/schemas/core/assets/html-asset.json" }, { "$ref": "/schemas/core/assets/javascript-asset.json" }, + { "$ref": "/schemas/core/assets/zip-asset.json" }, { "$ref": "/schemas/core/assets/webhook-asset.json" }, { "$ref": "/schemas/core/assets/css-asset.json" }, { "$ref": "/schemas/core/assets/daast-asset.json" }, diff --git a/static/schemas/source/core/creative-manifest.json b/static/schemas/source/core/creative-manifest.json index 0130bdc1b4..6fd3470782 100644 --- a/static/schemas/source/core/creative-manifest.json +++ b/static/schemas/source/core/creative-manifest.json @@ -23,6 +23,7 @@ { "$ref": "/schemas/core/assets/url-asset.json" }, { "$ref": "/schemas/core/assets/html-asset.json" }, { "$ref": "/schemas/core/assets/javascript-asset.json" }, + { "$ref": "/schemas/core/assets/zip-asset.json" }, { "$ref": "/schemas/core/assets/webhook-asset.json" }, { "$ref": "/schemas/core/assets/css-asset.json" }, { "$ref": "/schemas/core/assets/daast-asset.json" }, diff --git a/static/schemas/source/core/format.json b/static/schemas/source/core/format.json index 90813c7348..fa4eb92540 100644 --- a/static/schemas/source/core/format.json +++ b/static/schemas/source/core/format.json @@ -278,6 +278,15 @@ "requirements": { "$ref": "/schemas/core/requirements/javascript-asset-requirements.json" } } }, + { + "title": "IndividualZipAsset", + "description": "Zip-bundled asset (HTML5 banner bundles, etc.)", + "allOf": [{ "$ref": "#/$defs/baseIndividualAsset" }], + "properties": { + "item_type": { "const": "individual" }, + "asset_type": { "const": "zip" } + } + }, { "title": "IndividualVastAsset", "description": "VAST asset", @@ -451,6 +460,14 @@ "requirements": { "$ref": "/schemas/core/requirements/javascript-asset-requirements.json" } } }, + { + "title": "GroupZipAsset", + "description": "Zip-bundled asset in group (HTML5 banner bundles, etc.)", + "allOf": [{ "$ref": "#/$defs/baseGroupAsset" }], + "properties": { + "asset_type": { "const": "zip" } + } + }, { "title": "GroupVastAsset", "description": "VAST asset in group", diff --git a/static/schemas/source/core/offering-asset-group.json b/static/schemas/source/core/offering-asset-group.json index d977776d00..d82f73be04 100644 --- a/static/schemas/source/core/offering-asset-group.json +++ b/static/schemas/source/core/offering-asset-group.json @@ -29,6 +29,7 @@ { "$ref": "/schemas/core/assets/daast-asset.json" }, { "$ref": "/schemas/core/assets/css-asset.json" }, { "$ref": "/schemas/core/assets/javascript-asset.json" }, + { "$ref": "/schemas/core/assets/zip-asset.json" }, { "$ref": "/schemas/core/assets/webhook-asset.json" } ], "discriminator": { diff --git a/static/schemas/source/creative/asset-types/index.json b/static/schemas/source/creative/asset-types/index.json index 3f0abcb2a5..d4cd88bd3c 100644 --- a/static/schemas/source/creative/asset-types/index.json +++ b/static/schemas/source/creative/asset-types/index.json @@ -66,6 +66,11 @@ "schema": "/schemas/core/assets/javascript-asset.json", "typical_use": "Third-party tags, custom interaction logic, analytics" }, + "zip": { + "description": "Bundled creative archive (zip) containing index.html and supporting assets", + "schema": "/schemas/core/assets/zip-asset.json", + "typical_use": "HTML5 banner bundles with index.html + CSS + JS + images, MRAID-compatible interactive ads" + }, "brief": { "description": "Campaign-level creative context (creative brief)", "schema": "/schemas/core/assets/brief-asset.json", diff --git a/static/schemas/source/creative/list-creatives-response.json b/static/schemas/source/creative/list-creatives-response.json index a368a7b587..2c26afe88a 100644 --- a/static/schemas/source/creative/list-creatives-response.json +++ b/static/schemas/source/creative/list-creatives-response.json @@ -96,6 +96,7 @@ { "$ref": "/schemas/core/assets/url-asset.json" }, { "$ref": "/schemas/core/assets/html-asset.json" }, { "$ref": "/schemas/core/assets/javascript-asset.json" }, + { "$ref": "/schemas/core/assets/zip-asset.json" }, { "$ref": "/schemas/core/assets/webhook-asset.json" }, { "$ref": "/schemas/core/assets/css-asset.json" }, { "$ref": "/schemas/core/assets/daast-asset.json" }, diff --git a/static/schemas/source/creative/scenes.json b/static/schemas/source/creative/scenes.json index 2965a932e8..5fc2570c1b 100644 --- a/static/schemas/source/creative/scenes.json +++ b/static/schemas/source/creative/scenes.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/creative/scenes.json", "title": "Scenes", - "description": "Typed scene-by-scene structure used as input to build_creative for generative video platforms. Renamed from 'storyboard' to avoid collision with the testing-harness storyboard concept used elsewhere in AdCP.", + "description": "Typed scene-by-scene structure used as input to build_creative for generative video platforms. Renamed from 'storyboard' to avoid collision with the testing-harness storyboard concept used elsewhere in AdCP. Distinct from `reference-asset.json` `purpose: 'storyboard'`, which describes a reference asset (image, video, document) serving as visual direction — Scenes is the structured plan; a storyboard reference asset is the visual inspiration that may inform such a plan.", "type": "object", "required": ["scenes"], "properties": { From 77398b3a96393ad99c3d39fe1856b90d3469cfa9 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 27 Apr 2026 08:58:52 -0400 Subject: [PATCH 06/41] =?UTF-8?q?feat(creative):=20v2=20Phase=202=20?= =?UTF-8?q?=E2=80=94=20canonical=20format=20catalog,=20ProductFormatDeclar?= =?UTF-8?q?ation,=20validate=5Finput,=20build=20capabilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the v2 RFC architecture (#3305). Backwards-compatible additions only — v1 named formats and v1 producers continue to work unchanged. **11 canonical format definitions** at static/schemas/source/formats/canonical/: - image (static), html5 (interactive bundle), display_tag (3P-served) - image_carousel (multi-card, polymorphic items) - video_hosted (direct file, OM-SDK + external trackers) - video_vast (VAST tag, inherent VAST event tracking) - audio_hosted (direct file) - audio_daast (DAAST tag) - sponsored_placement (retail-media catalog-driven, deterministic composition) - asset_pool_composed (Google PMax family, algorithmic composition) - brand_mention (text/audio AI-surface composition) - _base.json with shared fields (composition_model, provenance_required, platform_extensions, tracking_extensions) Each canonical bakes in its tracking model and references the asset_group_id vocabulary shipped in Phase 1. Format-keyed-by-name structure (no canonical discriminator field). **ProductFormatDeclaration** (static/schemas/source/core/product-format-declaration.json): keyed by canonical format name with minProperties: 1, maxProperties: 1, no additionalProperties — exactly one canonical key per declaration. Includes worked examples for Meta Reels, IAB MREC, podcast host-read. **Product/manifest additive fields:** - product.json: optional `format` field (v2 inline declaration), optional `build_capability_ref` (for products requiring agent-produced creative). v1 format_ids path remains supported. - creative-manifest.json: optional `brand` field (resolves brand.json) and `brand_kit_override` (explicit override for missing/stale brand.json). **Build capabilities:** - build-capability.json: schema for creative agents declaring what canonical formats they can build, with parameter narrowing and typed inputs. - build-capability-ref.json: reference type used on products. - get_adcp_capabilities response: added `creative.creative_build_capabilities` array. Replaces v1 list_creative_formats discovery surface for creative agents. **Platform extension references:** - platform-extension-ref.json: URI + content-digest reference to platform extension definitions. Bundled in get_products responses to avoid extra fetches; SDK caches by URI@digest. **validate_input tool:** - validate-input-request.json, validate-input-response.json, validate-input-result.json: cheap dry-run primitive for validating a manifest against canonical formats and/or specific products. predicted field carries pre-flight estimates; no protocol state for orphaned out-of-spec artifacts (nondeterministic platforms run their own QA loop). Tracks #3305 (v2 RFC). Phase 1 (#3307) primitives unblock this Phase 2 surface; together they let buyers and adopters point at one preview branch and build end-to-end against the v2 spec. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../source/core/build-capability-ref.json | 32 ++++++ .../schemas/source/core/build-capability.json | 105 ++++++++++++++++++ .../source/core/creative-manifest.json | 41 +++++++ .../source/core/platform-extension-ref.json | 27 +++++ .../core/product-format-declaration.json | 77 +++++++++++++ static/schemas/source/core/product.json | 10 +- .../creative/validate-input-request.json | 47 ++++++++ .../creative/validate-input-response.json | 16 +++ .../creative/validate-input-result.json | 78 +++++++++++++ .../source/formats/canonical/_base.json | 29 +++++ .../canonical/asset_pool_composed.json | 42 +++++++ .../source/formats/canonical/audio_daast.json | 37 ++++++ .../formats/canonical/audio_hosted.json | 68 ++++++++++++ .../formats/canonical/brand_mention.json | 42 +++++++ .../source/formats/canonical/display_tag.json | 54 +++++++++ .../source/formats/canonical/html5.json | 75 +++++++++++++ .../source/formats/canonical/image.json | 52 +++++++++ .../formats/canonical/image_carousel.json | 52 +++++++++ .../canonical/sponsored_placement.json | 46 ++++++++ .../formats/canonical/video_hosted.json | 78 +++++++++++++ .../source/formats/canonical/video_vast.json | 66 +++++++++++ static/schemas/source/index.json | 26 +++++ .../get-adcp-capabilities-response.json | 7 ++ 23 files changed, 1106 insertions(+), 1 deletion(-) create mode 100644 static/schemas/source/core/build-capability-ref.json create mode 100644 static/schemas/source/core/build-capability.json create mode 100644 static/schemas/source/core/platform-extension-ref.json create mode 100644 static/schemas/source/core/product-format-declaration.json create mode 100644 static/schemas/source/creative/validate-input-request.json create mode 100644 static/schemas/source/creative/validate-input-response.json create mode 100644 static/schemas/source/creative/validate-input-result.json create mode 100644 static/schemas/source/formats/canonical/_base.json create mode 100644 static/schemas/source/formats/canonical/asset_pool_composed.json create mode 100644 static/schemas/source/formats/canonical/audio_daast.json create mode 100644 static/schemas/source/formats/canonical/audio_hosted.json create mode 100644 static/schemas/source/formats/canonical/brand_mention.json create mode 100644 static/schemas/source/formats/canonical/display_tag.json create mode 100644 static/schemas/source/formats/canonical/html5.json create mode 100644 static/schemas/source/formats/canonical/image.json create mode 100644 static/schemas/source/formats/canonical/image_carousel.json create mode 100644 static/schemas/source/formats/canonical/sponsored_placement.json create mode 100644 static/schemas/source/formats/canonical/video_hosted.json create mode 100644 static/schemas/source/formats/canonical/video_vast.json diff --git a/static/schemas/source/core/build-capability-ref.json b/static/schemas/source/core/build-capability-ref.json new file mode 100644 index 0000000000..34757da76c --- /dev/null +++ b/static/schemas/source/core/build-capability-ref.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/build-capability-ref.json", + "title": "Build Capability Reference", + "description": "Reference to a creative agent's build_creative capability. Used on products where the creative must be produced via a specific creative agent (e.g., podcast host-read products where the publisher's host records the audio from a script). Buyers resolve the capability by calling `get_adcp_capabilities` on the agent and reading its `creative_build_capabilities` array, then call `build_creative` against the matching capability.", + "type": "object", + "required": ["agent_url", "capability_id"], + "properties": { + "agent_url": { + "type": "string", + "format": "uri", + "description": "URL of the creative agent that exposes the capability." + }, + "capability_id": { + "type": "string", + "description": "Identifier of the specific build capability within the agent (e.g., 'the_daily_host_read_production')." + }, + "digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$", + "description": "Optional SHA-256 content digest of the capability definition. Lets buyers detect when the capability has changed." + } + }, + "additionalProperties": true, + "examples": [ + { + "agent_url": "https://creative.thedailypod.example/adcp", + "capability_id": "the_daily_host_read_production", + "digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + ] +} diff --git a/static/schemas/source/core/build-capability.json b/static/schemas/source/core/build-capability.json new file mode 100644 index 0000000000..3c11aab6cb --- /dev/null +++ b/static/schemas/source/core/build-capability.json @@ -0,0 +1,105 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/build-capability.json", + "title": "Build Capability", + "description": "Declaration by a creative agent that it can produce manifests conforming to a canonical format with specific parameter narrowing and required inputs. Buyers discover capabilities via the `creative_build_capabilities` field on `get_adcp_capabilities` responses, then call `build_creative` against the matching capability_id with the declared inputs. Structurally a ProductFormatDeclaration narrowing minus the sales-agent-product wrapping — the schemas converge.", + "type": "object", + "required": ["capability_id", "target_format", "inputs"], + "properties": { + "capability_id": { + "type": "string", + "description": "Identifier for this specific build capability within the creative agent (e.g., 'audiostack_audio_30s', 'the_daily_host_read_production', 'midjourney_image_landscape')." + }, + "name": { + "type": "string", + "description": "Human-readable capability name." + }, + "description": { + "type": "string", + "description": "Detailed description of what this capability produces and how." + }, + "target_format": { + "type": "string", + "description": "Canonical format the capability produces (e.g., 'audio_hosted', 'image', 'video_vertical')." + }, + "narrowing": { + "$ref": "/schemas/core/product-format-declaration.json", + "description": "Parameter narrowing of the target canonical (dimensions, duration, codec ranges). Use the same shape as ProductFormatDeclaration; the canonical key MUST match `target_format`." + }, + "inputs": { + "type": "object", + "description": "Typed input vocabulary the capability consumes. Each key is a build_creative input name (creative_brief, scenes, voice_id, style_reference, script, starter_assets, product_feed, brand, offering_ref). Values describe whether each input is required and any constraints (max_chars, allowed_voices, etc.).", + "additionalProperties": { + "type": "object", + "properties": { + "required": { "type": "boolean" }, + "description": { "type": "string" }, + "max_chars": { "type": "integer", "minimum": 1 }, + "allowed_values": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": true + } + }, + "production_window_business_days": { + "type": "integer", + "minimum": 0, + "description": "Typical production turnaround in business days. 0 for synchronous (e.g., generative AI); >0 for human-produced (e.g., podcast host-read)." + }, + "digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$", + "description": "SHA-256 content digest of this capability definition. Drives BuildCapabilityRef caching." + } + }, + "additionalProperties": true, + "examples": [ + { + "capability_id": "audiostack_audio_30s", + "name": "AudioStack 30s Audio Synthesis", + "target_format": "audio_hosted", + "narrowing": { + "audio_hosted": { + "duration_ms_exact": 30000, + "audio_codecs": ["mp3"], + "audio_channels": ["stereo"], + "loudness_lufs": -16 + } + }, + "inputs": { + "creative_brief": { + "required": true, + "max_chars": 1000, + "description": "Talking points and brand context for the synthesized audio." + }, + "voice_id": { + "required": false, + "description": "Voice selection from AudioStack's voice library. Defaults to brand voice if absent." + }, + "brand": { "required": true } + }, + "production_window_business_days": 0 + }, + { + "capability_id": "the_daily_host_read_production", + "name": "The Daily — 30s Host-Read Production", + "target_format": "audio_hosted", + "narrowing": { + "audio_hosted": { + "duration_ms_exact": 30000, + "loudness_lufs": -16, + "audio_source": "publisher_host_recorded" + } + }, + "inputs": { + "script": { + "required": true, + "max_chars": 800, + "description": "Verbatim script the host will read. Exact wording — no improvisation; legal pre-cleared." + }, + "brand": { "required": true }, + "offering_ref": { "required": false } + }, + "production_window_business_days": 7 + } + ] +} diff --git a/static/schemas/source/core/creative-manifest.json b/static/schemas/source/core/creative-manifest.json index 6fd3470782..9e205c191b 100644 --- a/static/schemas/source/core/creative-manifest.json +++ b/static/schemas/source/core/creative-manifest.json @@ -38,6 +38,47 @@ }, "additionalProperties": true }, + "brand": { + "type": "object", + "description": "Brand identity reference. When present, the seller pulls brand context (logos, colors, voice, taglines) from the brand's brand.json automatically. v2 formats no longer redeclare brand_logo / brand_colors / brand_voice as explicit slots — brand identity is implicit context.", + "properties": { + "domain": { + "type": "string", + "format": "hostname", + "description": "Brand domain. Seller fetches https:///.well-known/brand.json for brand identity context." + } + }, + "required": ["domain"], + "additionalProperties": true + }, + "brand_kit_override": { + "type": "object", + "description": "Explicit brand-kit override for the case where brand.json is missing, stale, or inappropriate for this specific creative. When present, takes precedence over brand.json lookups for the supplied fields. Sellers use brand.json as the default and the override as the per-creative authoritative source.", + "properties": { + "logo": { + "$ref": "/schemas/core/assets/image-asset.json", + "description": "Override logo asset for this creative." + }, + "colors": { + "type": "object", + "description": "Override brand colors (hex strings).", + "properties": { + "primary": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" }, + "secondary": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" }, + "accent": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" } + }, + "additionalProperties": true + }, + "voice": { + "type": "string", + "description": "Override brand-voice description for surface-composed text/audio output." + }, + "tagline": { + "type": "string" + } + }, + "additionalProperties": true + }, "rights": { "type": "array", "description": "Rights constraints attached to this creative. Each entry represents constraints from a single rights holder. A creative may combine multiple rights constraints (e.g., talent likeness + music license). For v1, rights constraints are informational metadata — the buyer/orchestrator manages creative lifecycle against these terms.", diff --git a/static/schemas/source/core/platform-extension-ref.json b/static/schemas/source/core/platform-extension-ref.json new file mode 100644 index 0000000000..70b06376c1 --- /dev/null +++ b/static/schemas/source/core/platform-extension-ref.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/platform-extension-ref.json", + "title": "Platform Extension Reference", + "description": "Reference to a platform extension definition. The agent that owns the URI is authoritative for the extension's schema. Buyers fetch the definition once per content digest and cache it. Platform extensions are typically bundled in `get_products` responses under an `extensions` map keyed by `uri@digest`, eliminating the need for a separate fetch.", + "type": "object", + "required": ["uri", "digest"], + "properties": { + "uri": { + "type": "string", + "format": "uri", + "description": "URL identifying the extension. The URI base is the owning agent's URL; the path identifies the extension within that agent. Example: 'https://meta.adcp/extensions/meta_pixel'." + }, + "digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$", + "description": "SHA-256 content digest of the extension definition (sha256:). Used to detect drift — if the agent revises the extension, the digest changes and cached definitions become invalid." + } + }, + "additionalProperties": true, + "examples": [ + { + "uri": "https://meta.adcp/extensions/meta_pixel", + "digest": "sha256:a3f5b7c9d8e2f1a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2" + } + ] +} diff --git a/static/schemas/source/core/product-format-declaration.json b/static/schemas/source/core/product-format-declaration.json new file mode 100644 index 0000000000..d4adf519cc --- /dev/null +++ b/static/schemas/source/core/product-format-declaration.json @@ -0,0 +1,77 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/product-format-declaration.json", + "title": "Product Format Declaration", + "description": "Inline format declaration on a product. Keyed by canonical format name; a product narrows exactly one canonical with platform-specific parameters and extensions. Replaces the v1 named-format pattern (where products referenced a separately-defined format by `format_id`). v1 named formats remain supported through the deprecation cycle; v2 product-bound declarations are opt-in.", + "type": "object", + "minProperties": 1, + "maxProperties": 1, + "properties": { + "image": { "$ref": "/schemas/formats/canonical/image.json" }, + "html5": { "$ref": "/schemas/formats/canonical/html5.json" }, + "display_tag": { "$ref": "/schemas/formats/canonical/display_tag.json" }, + "image_carousel": { "$ref": "/schemas/formats/canonical/image_carousel.json" }, + "video_hosted": { "$ref": "/schemas/formats/canonical/video_hosted.json" }, + "video_vast": { "$ref": "/schemas/formats/canonical/video_vast.json" }, + "audio_hosted": { "$ref": "/schemas/formats/canonical/audio_hosted.json" }, + "audio_daast": { "$ref": "/schemas/formats/canonical/audio_daast.json" }, + "sponsored_placement": { "$ref": "/schemas/formats/canonical/sponsored_placement.json" }, + "asset_pool_composed": { "$ref": "/schemas/formats/canonical/asset_pool_composed.json" }, + "brand_mention": { "$ref": "/schemas/formats/canonical/brand_mention.json" } + }, + "additionalProperties": false, + "examples": [ + { + "description": "Meta Reels — narrows video_hosted (vertical orientation)", + "data": { + "video_hosted": { + "orientation": "vertical", + "aspect_ratio": "9:16", + "duration_ms_range": [3000, 90000], + "min_width": 1080, + "min_height": 1920, + "max_file_size_mb": 200, + "video_codecs": ["h264"], + "audio_codecs": ["aac"], + "headline_max_chars": 25, + "primary_text_max_chars": 72, + "captions": "recommended", + "cta_values": ["LEARN_MORE", "SHOP_NOW", "DOWNLOAD", "SIGN_UP"], + "composition_model": "deterministic", + "platform_extensions": [ + { "uri": "https://meta.adcp/extensions/meta_pixel", "digest": "sha256:a3f5b7c9d8e2f1a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2" } + ] + } + } + }, + { + "description": "IAB Medium Rectangle (300x250) — narrows image", + "data": { + "image": { + "width": 300, + "height": 250, + "max_file_size_kb": 200, + "image_formats": ["jpg", "png", "gif"], + "ssl_required": true, + "composition_model": "deterministic", + "cta_values": ["LEARN_MORE", "SHOP_NOW", "GET_OFFER"] + } + } + }, + { + "description": "Podcast 30s host-read — narrows audio_hosted, requires build_capability_ref on parent product", + "data": { + "audio_hosted": { + "duration_ms_exact": 30000, + "audio_codecs": ["mp3", "aac"], + "audio_sample_rates": [44100, 48000], + "audio_channels": ["stereo"], + "loudness_lufs": -16, + "audio_source": "publisher_host_recorded", + "buyer_audio_acceptance": "rejected", + "composition_model": "deterministic" + } + } + } + ] +} diff --git a/static/schemas/source/core/product.json b/static/schemas/source/core/product.json index 29bc6aff5b..70c6fe6e66 100644 --- a/static/schemas/source/core/product.json +++ b/static/schemas/source/core/product.json @@ -36,11 +36,19 @@ }, "format_ids": { "type": "array", - "description": "Array of supported creative format IDs - structured format_id objects with agent_url and id", + "description": "v1 path: array of supported creative format IDs (structured format_id objects with agent_url and id). Products MAY use either `format_ids` (v1) or `format` (v2 inline declaration) — not both. v1 named formats remain supported through the deprecation cycle.", "items": { "$ref": "/schemas/core/format-id.json" } }, + "format": { + "$ref": "/schemas/core/product-format-declaration.json", + "description": "v2 path: inline format declaration keyed by canonical format name. Products narrow exactly one canonical format with platform-specific parameters and extensions. Mutually exclusive with `format_ids` — a product is either v1 (references named formats) or v2 (carries inline declaration), not both." + }, + "build_capability_ref": { + "$ref": "/schemas/core/build-capability-ref.json", + "description": "Reference to a creative agent's build_creative capability. Used when this product requires creative produced by a specific creative agent (e.g., podcast host-read products where the publisher's host records audio from a buyer-supplied script). Buyers resolve via get_adcp_capabilities on the agent." + }, "placements": { "type": "array", "description": "Optional array of specific placements within this product. When provided, buyers can target specific placements when assigning creatives.", diff --git a/static/schemas/source/creative/validate-input-request.json b/static/schemas/source/creative/validate-input-request.json new file mode 100644 index 0000000000..35bb22da80 --- /dev/null +++ b/static/schemas/source/creative/validate-input-request.json @@ -0,0 +1,47 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/creative/validate-input-request.json", + "title": "Validate Input Request", + "description": "Request payload for the validate_input task. Lets buyers dry-run a creative manifest against canonical formats and/or specific products before committing to a render. Cheaper than preview_creative (no synthesis cost). Used by build_creative internally to validate inputs before producing output. For genuinely nondeterministic generative platforms (Veo/Sora/Runway-class) where predictive validation is impossible, the platform's own post-synthesis QA loop applies — validate_input is the predictable-case primitive.", + "type": "object", + "required": ["manifest"], + "properties": { + "manifest": { + "$ref": "/schemas/core/creative-manifest.json", + "description": "Creative manifest to validate." + }, + "format_ids": { + "type": "array", + "items": { "type": "string" }, + "description": "Canonical format names to validate against (e.g., ['video_hosted', 'video_vast']). Each is a canonical format identifier or a third-party URI form. Multi-format support enables universal-creative scenarios where one manifest targets multiple sellers' format declarations." + }, + "product_ids": { + "type": "array", + "items": { "type": "string" }, + "description": "Specific product IDs to validate against. The seller validates the manifest against each product's inline ProductFormatDeclaration narrowing of the canonical." + } + }, + "additionalProperties": true, + "examples": [ + { + "description": "Dry-run a video manifest against canonical video_vertical and a specific Meta product", + "data": { + "manifest": { + "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_30s" }, + "assets": { + "video_main": { + "asset_type": "video", + "url": "https://cdn.acme.example/spring-30s.mp4", + "duration_ms": 30000, + "width": 1080, + "height": 1920 + } + }, + "brand": { "domain": "acme.example" } + }, + "format_ids": ["video_hosted"], + "product_ids": ["meta_reels_us"] + } + } + ] +} diff --git a/static/schemas/source/creative/validate-input-response.json b/static/schemas/source/creative/validate-input-response.json new file mode 100644 index 0000000000..3ce966b549 --- /dev/null +++ b/static/schemas/source/creative/validate-input-response.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/creative/validate-input-response.json", + "title": "Validate Input Response", + "description": "Response payload for the validate_input task. Returns per-target validation results — one entry per format_id or product_id requested. `predicted` carries the platform's pre-flight estimate (e.g., predicted audio duration from text-length analysis), NOT the actual output — there is no protocol state for orphaned out-of-spec artifacts. For nondeterministic generative platforms, the QA-loop obligation means out-of-spec output never reaches this surface; instead, build_creative returns task_failed with synthesis_failed reason.", + "type": "object", + "required": ["results"], + "properties": { + "results": { + "type": "array", + "items": { "$ref": "/schemas/creative/validate-input-result.json" }, + "description": "Per-target validation results." + } + }, + "additionalProperties": true +} diff --git a/static/schemas/source/creative/validate-input-result.json b/static/schemas/source/creative/validate-input-result.json new file mode 100644 index 0000000000..17f144a5bb --- /dev/null +++ b/static/schemas/source/creative/validate-input-result.json @@ -0,0 +1,78 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/creative/validate-input-result.json", + "title": "Validate Input Result", + "description": "Per-target result of a validate_input call.", + "type": "object", + "required": ["target", "ok"], + "properties": { + "target": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { + "type": "string", + "enum": ["canonical", "product", "third_party_format"] + }, + "id": { + "type": "string", + "description": "Canonical format name (e.g., 'image'), product_id, or URI-form format_id." + } + }, + "additionalProperties": true + }, + "ok": { + "type": "boolean", + "description": "True when the manifest validates against the target." + }, + "violations": { + "type": "array", + "description": "When ok is false, the specific constraints the manifest fails to meet.", + "items": { + "type": "object", + "required": ["rule", "field"], + "properties": { + "rule": { + "type": "string", + "description": "Rule name (e.g., 'duration_ms_range', 'aspect_ratio', 'max_file_size_kb')." + }, + "expected": { + "description": "Expected value or range (e.g., '28000-32000', '9:16', 200)." + }, + "predicted": { + "description": "Platform's pre-flight estimate for this field (NOT the actual output — there is no protocol state for orphaned out-of-spec artifacts). For TTS, this might be the predicted audio duration from text-length analysis. Helps the buyer fix the input before committing to a build." + }, + "field": { + "type": "string", + "description": "Path to the violating field (e.g., 'assets.video_main.duration_ms')." + }, + "retry_with": { + "type": "object", + "description": "Optional advisory adjustment hint. Platforms MAY suggest a corrected input shape; buyers MUST treat this as advisory, not authoritative.", + "additionalProperties": true + } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true, + "examples": [ + { + "description": "Manifest fails canonical video_vertical because its duration is too long", + "data": { + "target": { "kind": "canonical", "id": "video_vertical" }, + "ok": false, + "violations": [ + { + "rule": "duration_ms_range", + "expected": "3000-90000", + "predicted": 91500, + "field": "assets.video_main.duration_ms", + "retry_with": { "trim_ms_from_end": 1500 } + } + ] + } + } + ] +} diff --git a/static/schemas/source/formats/canonical/_base.json b/static/schemas/source/formats/canonical/_base.json new file mode 100644 index 0000000000..37579c0391 --- /dev/null +++ b/static/schemas/source/formats/canonical/_base.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/formats/canonical/_base.json", + "title": "Canonical Format Base", + "description": "Shared parameter fields that apply across canonical formats. Each canonical format extends this base with format-specific parameters (dimensions, durations, codecs, slot constraints).", + "type": "object", + "properties": { + "composition_model": { + "type": "string", + "enum": ["deterministic", "algorithmic"], + "description": "Whether the surface composes deterministically (buyer can predict per-slot rendering — sponsored_placement, image, video) or algorithmically (surface chooses combinations or phrasing — asset_pool_composed, brand_mention)." + }, + "provenance_required": { + "type": "boolean", + "description": "When true, the product rejects unsigned synthesized assets. Builders calling build_creative MUST attach a C2PA-compatible provenance manifest attributing synthesis to the creative agent." + }, + "platform_extensions": { + "type": "array", + "description": "Platform-specific extensions narrowing the canonical (pixel ID shapes, conversion event taxonomies, platform-specific CTAs/destinations). Each extension is a URI+digest reference resolved against the bundled `extensions` map in get_products responses or fetched directly.", + "items": { "$ref": "/schemas/core/platform-extension-ref.json" } + }, + "tracking_extensions": { + "type": "array", + "description": "Platform-specific tracking extensions (e.g., Meta pixel, TikTok pixel) referenced when the canonical's baseline tracking is insufficient. Each entry references the tracking-extension definition.", + "items": { "$ref": "/schemas/core/platform-extension-ref.json" } + } + }, + "additionalProperties": true +} diff --git a/static/schemas/source/formats/canonical/asset_pool_composed.json b/static/schemas/source/formats/canonical/asset_pool_composed.json new file mode 100644 index 0000000000..8f2303be04 --- /dev/null +++ b/static/schemas/source/formats/canonical/asset_pool_composed.json @@ -0,0 +1,42 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/formats/canonical/asset_pool_composed.json", + "title": "Canonical Format: Asset-Pool Composed", + "description": "Buyer supplies a pool of typed assets (multiple headlines, descriptions, images, videos, logos); the surface algorithmically composes combinations per placement. Slots: `headlines[]`, `descriptions[]`, `images_landscape[]`, `images_square[]`, `images_vertical[]`, `videos[]`, `logo[]`, `landing_page_url`. **Composition is algorithmic** — surface picks combinations and reports per-asset performance breakdowns. Covers Google Performance Max, Responsive Display Ads, Demand Gen, and Meta Advantage+ creative. Distinct from `sponsored_placement` (catalog-driven, deterministic) and `brand_mention` (text/audio AI composition).", + "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], + "properties": { + "headlines_min": { "type": "integer", "minimum": 0 }, + "headlines_max": { "type": "integer", "minimum": 0 }, + "headline_max_chars": { "type": "integer", "minimum": 1 }, + "long_headlines_min": { "type": "integer", "minimum": 0 }, + "long_headlines_max": { "type": "integer", "minimum": 0 }, + "long_headline_max_chars": { "type": "integer", "minimum": 1 }, + "descriptions_min": { "type": "integer", "minimum": 0 }, + "descriptions_max": { "type": "integer", "minimum": 0 }, + "description_max_chars": { "type": "integer", "minimum": 1 }, + "images_landscape_min": { "type": "integer", "minimum": 0 }, + "images_landscape_max": { "type": "integer", "minimum": 0 }, + "images_landscape_aspect_ratio": { "type": "string" }, + "images_square_min": { "type": "integer", "minimum": 0 }, + "images_square_max": { "type": "integer", "minimum": 0 }, + "images_vertical_min": { "type": "integer", "minimum": 0 }, + "images_vertical_max": { "type": "integer", "minimum": 0 }, + "videos_min": { "type": "integer", "minimum": 0 }, + "videos_max": { "type": "integer", "minimum": 0 }, + "video_min_duration_ms": { "type": "integer", "minimum": 1 }, + "video_max_duration_ms": { "type": "integer", "minimum": 1 }, + "logo_min": { "type": "integer", "minimum": 0 }, + "logo_max": { "type": "integer", "minimum": 0 }, + "logo_aspect_ratios": { + "type": "array", + "items": { "type": "string" } + }, + "business_name_max_chars": { "type": "integer", "minimum": 1 }, + "asset_image_max_file_size_kb": { "type": "integer", "minimum": 1 }, + "supports_catalog_input": { + "type": "boolean", + "description": "Whether the product can additionally consume a catalog reference (e.g., PMax with product feed)." + } + }, + "additionalProperties": true +} diff --git a/static/schemas/source/formats/canonical/audio_daast.json b/static/schemas/source/formats/canonical/audio_daast.json new file mode 100644 index 0000000000..1f091e36aa --- /dev/null +++ b/static/schemas/source/formats/canonical/audio_daast.json @@ -0,0 +1,37 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/formats/canonical/audio_daast.json", + "title": "Canonical Format: DAAST Audio", + "description": "DAAST-tag-delivered audio creative (audio analog of VAST). Slot: `daast_tag` (daast asset, URL or inline XML). Tracking model: DAAST events inherent to the spec — `impression`, `firstQuartile`, `midpoint`, `thirdQuartile`, `complete`, `start`, `pause`, `resume`, `mute`, `unmute`, `clickTracking`, `error`. Distinct from `audio_hosted` (direct file with external tracking).", + "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], + "properties": { + "daast_version": { + "type": "string", + "enum": ["1.0", "1.1"] + }, + "duration_ms_range": { + "type": "array", + "items": { "type": "integer", "minimum": 0 }, + "minItems": 2, + "maxItems": 2 + }, + "duration_ms_exact": { + "type": "integer", + "minimum": 1 + }, + "linear_required": { + "type": "boolean" + }, + "max_wrapper_depth": { + "type": "integer", + "minimum": 0 + }, + "ssl_required": { + "type": "boolean" + }, + "companion_image_required": { + "type": "boolean" + } + }, + "additionalProperties": true +} diff --git a/static/schemas/source/formats/canonical/audio_hosted.json b/static/schemas/source/formats/canonical/audio_hosted.json new file mode 100644 index 0000000000..608a471658 --- /dev/null +++ b/static/schemas/source/formats/canonical/audio_hosted.json @@ -0,0 +1,68 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/formats/canonical/audio_hosted.json", + "title": "Canonical Format: Hosted Audio", + "description": "Direct audio file (mp3/aac/wav) hosted by the buyer (or produced by a creative agent — see build_capability_ref for podcast host-read flows). Slot: `audio` (audio asset), optional `companion_image` (image), `brand_name`, `landing_page_url`. Tracking model: standard impression + completion + companion-image-click pixels via universal_macros. Distinct from `audio_daast` (DAAST tag, inherent DAAST event tracking). For podcast host-reads, the audio is typically produced via a creative agent's build_creative capability with a `script` or `creative_brief` input; the product declares `audio_source: 'publisher_host_recorded'` and references the production capability via `build_capability_ref`.", + "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], + "properties": { + "duration_ms_range": { + "type": "array", + "items": { "type": "integer", "minimum": 0 }, + "minItems": 2, + "maxItems": 2 + }, + "duration_ms_exact": { + "type": "integer", + "minimum": 1 + }, + "audio_codecs": { + "type": "array", + "items": { "type": "string", "enum": ["mp3", "aac", "wav", "opus", "flac"] } + }, + "audio_sample_rates": { + "type": "array", + "items": { "type": "integer", "minimum": 1 } + }, + "audio_channels": { + "type": "array", + "items": { "type": "string", "enum": ["mono", "stereo"] } + }, + "min_bitrate_kbps": { "type": "integer", "minimum": 1 }, + "max_bitrate_kbps": { "type": "integer", "minimum": 1 }, + "loudness_lufs": { + "type": "number", + "description": "Required integrated loudness in LUFS (typical: -16 for streaming/podcast, -23 for broadcast). Negative values." + }, + "loudness_tolerance_db": { + "type": "number", + "minimum": 0, + "description": "Permitted deviation from loudness_lufs in dB." + }, + "true_peak_dbfs": { + "type": "number", + "description": "Maximum true-peak level in dBFS (typical: -2)." + }, + "audio_source": { + "type": "string", + "enum": ["buyer_uploaded", "publisher_host_recorded", "agent_synthesized"], + "description": "Where the audio comes from. `publisher_host_recorded` indicates the publisher's host records the audio (typical for podcast host-reads); buyer must use the publisher's build_creative capability." + }, + "buyer_audio_acceptance": { + "type": "string", + "enum": ["accepted", "rejected"], + "description": "Whether the product accepts buyer-uploaded audio. When `rejected`, audio must come from build_creative." + }, + "companion_image_required": { + "type": "boolean" + }, + "companion_image_aspect_ratio": { + "type": "string" + }, + "companion_image_max_file_size_kb": { + "type": "integer", + "minimum": 1 + }, + "brand_name_max_chars": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": true +} diff --git a/static/schemas/source/formats/canonical/brand_mention.json b/static/schemas/source/formats/canonical/brand_mention.json new file mode 100644 index 0000000000..a12411db3e --- /dev/null +++ b/static/schemas/source/formats/canonical/brand_mention.json @@ -0,0 +1,42 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/formats/canonical/brand_mention.json", + "title": "Canonical Format: Brand Mention (AI-surface composition)", + "description": "Text/audio AI-surface mention. Buyer supplies brand context (via `brand: { domain }` resolving brand.json) and optional offering reference; surface composes natural-language mention or sponsored snippet. **Composition is algorithmic** — LLM/search-ranker chooses phrasing and presentation. Covers ChatGPT-style brand mentions, Perplexity sponsored answers, voice-assistant sponsored mentions, sponsored search snippets. Output asset_type varies by surface: `text` for chat UIs and search snippets; `audio` (synthesized) for voice assistants. Tracking model: mention-level impression + attribution events; per-mention id keys back to brand and offering.", + "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], + "properties": { + "output_modality": { + "type": "string", + "enum": ["text", "audio", "card"], + "description": "How the surface presents the mention. `text` = inline text (chat, search snippet). `audio` = TTS-synthesized voice. `card` = structured card with optional image + text." + }, + "max_mention_length_chars": { + "type": "integer", + "minimum": 1, + "description": "For text output: maximum length of the surface-composed mention text." + }, + "max_mention_duration_ms": { + "type": "integer", + "minimum": 1, + "description": "For audio output: maximum duration of the spoken mention in milliseconds." + }, + "supports_offering_reference": { + "type": "boolean", + "description": "Whether the product accepts an offering reference (specific product/service to promote within the mention) in addition to brand context." + }, + "supports_landing_page_url": { + "type": "boolean", + "description": "Whether the surface attaches a landing page URL to the mention (citation, learn-more link)." + }, + "tone_constraints": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional brand-voice constraints the surface must honor (e.g., ['formal', 'no_superlatives'])." + }, + "disclosure_required": { + "type": "boolean", + "description": "Whether the surface must include an explicit sponsorship disclosure label." + } + }, + "additionalProperties": true +} diff --git a/static/schemas/source/formats/canonical/display_tag.json b/static/schemas/source/formats/canonical/display_tag.json new file mode 100644 index 0000000000..3e34f55388 --- /dev/null +++ b/static/schemas/source/formats/canonical/display_tag.json @@ -0,0 +1,54 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/formats/canonical/display_tag.json", + "title": "Canonical Format: Display Tag", + "description": "Third-party-served display tag (JS, iframe, or 1×1 redirect). The buyer's adserver hosts the creative; the seller calls the tag URL at impression time. Slot: `tag_url` (url asset with appropriate `url_type`). Tracking model: opaque to seller — third party serves and measures. Click tracking via redirect URL substitution using universal_macros. Distinct from `image` (static asset hosted by seller) and `html5` (zip bundle hosted by seller).", + "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], + "properties": { + "width": { + "type": "integer", + "minimum": 1, + "description": "Required tag rendering width in pixels." + }, + "height": { + "type": "integer", + "minimum": 1, + "description": "Required tag rendering height in pixels." + }, + "supported_tag_types": { + "type": "array", + "items": { + "type": "string", + "enum": ["iframe", "javascript", "1x1_redirect"] + }, + "description": "Tag delivery mechanisms accepted." + }, + "ssl_required": { + "type": "boolean", + "description": "Whether the tag URL must be HTTPS." + }, + "max_redirect_depth": { + "type": "integer", + "minimum": 0, + "description": "Maximum redirect chain depth permitted." + }, + "max_response_time_ms": { + "type": "integer", + "minimum": 1, + "description": "Maximum tag-server response time in milliseconds." + }, + "backup_image_required": { + "type": "boolean", + "description": "Whether a backup image must accompany the tag for environments that cannot render the third-party tag." + }, + "backup_image_max_size_kb": { + "type": "integer", + "minimum": 1 + }, + "om_sdk_required": { + "type": "boolean", + "description": "Whether the buyer's tag must integrate IAB Open Measurement SDK for viewability." + } + }, + "additionalProperties": true +} diff --git a/static/schemas/source/formats/canonical/html5.json b/static/schemas/source/formats/canonical/html5.json new file mode 100644 index 0000000000..4d9bc4e8ee --- /dev/null +++ b/static/schemas/source/formats/canonical/html5.json @@ -0,0 +1,75 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/formats/canonical/html5.json", + "title": "Canonical Format: HTML5 Banner", + "description": "Interactive HTML5 banner delivered as a zip archive. Slot: `html5_bundle` (zip asset). Tracking model: MRAID + IAB Open Measurement (OM-SDK) + click-tag macro substitution + backup image fallback. Receivers unpack the zip, validate internal structure, and serve from CDN. Distinct from `image` (static, non-interactive) and `display_tag` (third-party served). The zip's entry point is typically `index.html`; click handling uses `clickTag` (or `clickTAG`) macro substitution.", + "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], + "properties": { + "width": { + "type": "integer", + "minimum": 1, + "description": "Required banner width in pixels." + }, + "height": { + "type": "integer", + "minimum": 1, + "description": "Required banner height in pixels." + }, + "max_initial_load_kb": { + "type": "integer", + "minimum": 1, + "description": "Maximum initial-load file size (zip + above-the-fold assets) in kilobytes. IAB display standards: 200 KB for fixed sizes, 100 KB for mobile." + }, + "max_polite_load_kb": { + "type": "integer", + "minimum": 1, + "description": "Maximum polite-load file size after host-initiated subload, in kilobytes. IAB display standards: 500 KB for fixed sizes." + }, + "host_initiated_subload": { + "type": "boolean", + "description": "Whether the host page must initiate the polite-load phase. IAB-compliant banners require true." + }, + "max_animation_duration_ms": { + "type": "integer", + "minimum": 0, + "description": "Maximum total animation duration in milliseconds. IAB standard: 30000 (30 seconds)." + }, + "max_cpu_load_percent": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Maximum CPU load percentage during render." + }, + "mraid_required": { + "type": "boolean", + "description": "Whether MRAID compatibility is required (mobile in-app)." + }, + "mraid_version": { + "type": "string", + "enum": ["2.0", "3.0"], + "description": "Required MRAID version when mraid_required is true." + }, + "om_sdk_required": { + "type": "boolean", + "description": "Whether IAB Open Measurement SDK integration is required." + }, + "clicktag_macro": { + "type": "string", + "enum": ["clickTag", "clickTAG"], + "description": "Name of the click-tag macro the bundle must use." + }, + "backup_image_required": { + "type": "boolean", + "description": "Whether a backup image must accompany the zip for non-HTML5 environments." + }, + "backup_image_max_size_kb": { + "type": "integer", + "minimum": 1, + "description": "Maximum backup image file size in kilobytes." + }, + "ssl_required": { + "type": "boolean" + } + }, + "additionalProperties": true +} diff --git a/static/schemas/source/formats/canonical/image.json b/static/schemas/source/formats/canonical/image.json new file mode 100644 index 0000000000..f2211f0f26 --- /dev/null +++ b/static/schemas/source/formats/canonical/image.json @@ -0,0 +1,52 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/formats/canonical/image.json", + "title": "Canonical Format: Image", + "description": "Static image creative format. Slots: `image_main` (image asset, file or hosted URL), optional `headline` (text), `body_text` (text), `cta` (text/enum), `landing_page_url` (url). Tracking model: impression pixel + click URL via universal_macros, with optional viewability pixel. Distinct from `html5` (interactive bundles) and `display_tag` (third-party served). AR/dimensions narrow to specific sizes via product parameters — covers IAB display sizes (300x250, 728x90, 970x250, etc.) without a separate iab_size enum.", + "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], + "properties": { + "width": { + "type": "integer", + "minimum": 1, + "description": "Required image width in pixels." + }, + "height": { + "type": "integer", + "minimum": 1, + "description": "Required image height in pixels." + }, + "aspect_ratio": { + "type": "string", + "pattern": "^[0-9]+(\\.[0-9]+)?:[0-9]+(\\.[0-9]+)?$", + "description": "Optional aspect ratio constraint (e.g., '1.91:1', '1:1'). When provided alongside width/height, must agree." + }, + "max_file_size_kb": { + "type": "integer", + "minimum": 1, + "description": "Maximum file size in kilobytes." + }, + "image_formats": { + "type": "array", + "items": { "type": "string", "enum": ["jpg", "jpeg", "png", "gif", "webp", "svg"] }, + "description": "Permitted image file formats." + }, + "ssl_required": { + "type": "boolean", + "description": "Whether the image and its trackers must be served over HTTPS." + }, + "headline_max_chars": { + "type": "integer", + "minimum": 1 + }, + "body_text_max_chars": { + "type": "integer", + "minimum": 1 + }, + "cta_values": { + "type": "array", + "items": { "type": "string" }, + "description": "Permitted CTA values for this product (e.g., ['LEARN_MORE', 'SHOP_NOW'])." + } + }, + "additionalProperties": true +} diff --git a/static/schemas/source/formats/canonical/image_carousel.json b/static/schemas/source/formats/canonical/image_carousel.json new file mode 100644 index 0000000000..911ecbdd45 --- /dev/null +++ b/static/schemas/source/formats/canonical/image_carousel.json @@ -0,0 +1,52 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/formats/canonical/image_carousel.json", + "title": "Canonical Format: Image Carousel", + "description": "Multi-card swipeable carousel. Slot: `cards[]` (per-card polymorphic — image OR video asset, plus per-card headline and link). Tracking model: per-card impression and engagement pixels + carousel-level engagement (swipe, view-time). Card aspect ratio is uniform across the carousel; mixed orientations not allowed within a single carousel. Allowed asset types per card: `image` and `video` (Meta-style mixed-media); platforms can narrow to image-only or video-only via parameters.", + "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], + "properties": { + "card_aspect_ratio": { + "type": "string", + "pattern": "^[0-9]+(\\.[0-9]+)?:[0-9]+(\\.[0-9]+)?$", + "description": "Aspect ratio shared across all cards (e.g., '1:1', '1.91:1', '4:5')." + }, + "min_cards": { + "type": "integer", + "minimum": 2, + "description": "Minimum card count (typical: 2 or 3)." + }, + "max_cards": { + "type": "integer", + "description": "Maximum card count (typical: 6, 10, or 35 depending on platform)." + }, + "allowed_card_asset_types": { + "type": "array", + "items": { + "type": "string", + "enum": ["image", "video"] + }, + "description": "Asset types each card may carry. Default: ['image']. Polymorphic carousels (Meta) allow ['image', 'video']." + }, + "card_image_max_file_size_kb": { + "type": "integer", + "minimum": 1 + }, + "card_video_max_duration_ms": { + "type": "integer", + "minimum": 1 + }, + "primary_text_max_chars": { + "type": "integer", + "minimum": 1, + "description": "Maximum length of the carousel-level primary text." + }, + "card_headline_max_chars": { + "type": "integer", + "minimum": 1 + }, + "ssl_required": { + "type": "boolean" + } + }, + "additionalProperties": true +} diff --git a/static/schemas/source/formats/canonical/sponsored_placement.json b/static/schemas/source/formats/canonical/sponsored_placement.json new file mode 100644 index 0000000000..1de5933bcf --- /dev/null +++ b/static/schemas/source/formats/canonical/sponsored_placement.json @@ -0,0 +1,46 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$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), 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. Distinct from `asset_pool_composed` (algorithmic combinator from buyer pool) and `brand_mention` (text/audio AI-surface composition).", + "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], + "properties": { + "supported_catalog_types": { + "type": "array", + "items": { + "type": "string", + "enum": ["product", "store", "offering", "hotel", "flight", "vehicle", "real_estate", "education", "destination", "app", "job", "inventory"] + }, + "description": "Catalog types this product accepts." + }, + "min_items": { + "type": "integer", + "minimum": 1, + "description": "Minimum catalog item count buyer must supply." + }, + "max_items": { + "type": "integer", + "description": "Maximum items considered for placement." + }, + "fanout_mode": { + "type": "string", + "enum": ["per_item", "multi_item_in_creative", "single_item"], + "description": "How items map to delivery: per_item = one ad per catalog item; multi_item_in_creative = composed multi-item ad (Pinterest Collection, Snap Collection); single_item = one ad showing one item." + }, + "required_catalog_fields": { + "type": "array", + "items": { "type": "string" }, + "description": "Catalog item fields the seller requires (e.g., ['title', 'image_url', 'price'])." + }, + "supported_id_types": { + "type": "array", + "items": { "type": "string", "enum": ["asin", "sku", "gtin", "offering_id", "store_id", "hotel_id", "flight_id", "vehicle_id", "listing_id", "program_id", "destination_id", "app_id", "job_id"] }, + "description": "Catalog identifier types the placement renders against." + }, + "hero_asset_supported": { + "type": "boolean", + "description": "Whether the buyer can supply a hero/banner asset alongside the catalog (Pinterest Collection pattern)." + } + }, + "additionalProperties": true +} diff --git a/static/schemas/source/formats/canonical/video_hosted.json b/static/schemas/source/formats/canonical/video_hosted.json new file mode 100644 index 0000000000..c7336adc95 --- /dev/null +++ b/static/schemas/source/formats/canonical/video_hosted.json @@ -0,0 +1,78 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$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" }], + "properties": { + "orientation": { + "type": "string", + "enum": ["vertical", "horizontal", "square"], + "description": "Video orientation. Vertical = 9:16 (Reels, Stories, Shorts). Horizontal = 16:9 (instream, CTV). Square = 1:1 (in-feed)." + }, + "aspect_ratio": { + "type": "string", + "pattern": "^[0-9]+(\\.[0-9]+)?:[0-9]+(\\.[0-9]+)?$", + "description": "Aspect ratio. Inferred from orientation if omitted." + }, + "min_width": { "type": "integer", "minimum": 1 }, + "min_height": { "type": "integer", "minimum": 1 }, + "max_width": { "type": "integer", "minimum": 1 }, + "max_height": { "type": "integer", "minimum": 1 }, + "duration_ms_range": { + "type": "array", + "items": { "type": "integer", "minimum": 0 }, + "minItems": 2, + "maxItems": 2, + "description": "[min, max] duration in milliseconds." + }, + "duration_ms_exact": { + "type": "integer", + "minimum": 1, + "description": "When set, duration must equal exactly this value." + }, + "video_codecs": { + "type": "array", + "items": { "type": "string", "enum": ["h264", "h265", "vp8", "vp9", "av1", "prores"] } + }, + "audio_codecs": { + "type": "array", + "items": { "type": "string", "enum": ["aac", "mp3", "opus", "pcm"] } + }, + "containers": { + "type": "array", + "items": { "type": "string", "enum": ["mp4", "webm", "mov"] } + }, + "min_bitrate_kbps": { "type": "integer", "minimum": 1 }, + "max_bitrate_kbps": { "type": "integer", "minimum": 1 }, + "max_file_size_mb": { "type": "integer", "minimum": 1 }, + "frame_rates": { + "type": "array", + "items": { "type": "number" } + }, + "captions": { + "type": "string", + "enum": ["required", "recommended", "not_required"] + }, + "om_sdk_required": { + "type": "boolean" + }, + "headline_max_chars": { "type": "integer", "minimum": 1 }, + "primary_text_max_chars": { "type": "integer", "minimum": 1 }, + "brand_name_max_chars": { "type": "integer", "minimum": 1 }, + "cta_values": { + "type": "array", + "items": { "type": "string" } + }, + "companion_banner_widths": { + "type": "array", + "items": { "type": "integer", "minimum": 1 }, + "description": "Permitted companion banner widths (instream video)." + }, + "companion_banner_heights": { + "type": "array", + "items": { "type": "integer", "minimum": 1 } + } + }, + "additionalProperties": true +} diff --git a/static/schemas/source/formats/canonical/video_vast.json b/static/schemas/source/formats/canonical/video_vast.json new file mode 100644 index 0000000000..fa24c10380 --- /dev/null +++ b/static/schemas/source/formats/canonical/video_vast.json @@ -0,0 +1,66 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$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": { + "orientation": { + "type": "string", + "enum": ["vertical", "horizontal", "square"] + }, + "aspect_ratio": { + "type": "string", + "pattern": "^[0-9]+(\\.[0-9]+)?:[0-9]+(\\.[0-9]+)?$" + }, + "vast_version": { + "type": "string", + "enum": ["2.0", "3.0", "4.0", "4.1", "4.2"], + "description": "Required VAST version." + }, + "vpaid_enabled": { + "type": "boolean", + "description": "Whether VPAID interactivity is supported. When true, the VAST tag may carry VPAID JS/Flash payloads." + }, + "vpaid_version": { + "type": "string", + "enum": ["1.0", "2.0"] + }, + "simid_supported": { + "type": "boolean", + "description": "Whether IAB SIMID interactive video extensions are supported." + }, + "duration_ms_range": { + "type": "array", + "items": { "type": "integer", "minimum": 0 }, + "minItems": 2, + "maxItems": 2 + }, + "duration_ms_exact": { + "type": "integer", + "minimum": 1 + }, + "min_width": { "type": "integer", "minimum": 1 }, + "max_width": { "type": "integer", "minimum": 1 }, + "min_height": { "type": "integer", "minimum": 1 }, + "max_height": { "type": "integer", "minimum": 1 }, + "linear_required": { + "type": "boolean", + "description": "Whether the VAST creative must be linear (non-skippable in-stream)." + }, + "skippable_after_ms": { + "type": "integer", + "minimum": 0, + "description": "When skippable, the buyer-side skip threshold in milliseconds (e.g., 5000 for 5-second skippable pre-roll)." + }, + "max_wrapper_depth": { + "type": "integer", + "minimum": 0, + "description": "Maximum VAST wrapper redirect depth permitted." + }, + "ssl_required": { + "type": "boolean" + } + }, + "additionalProperties": true +} diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index 1de279f641..6871518ac1 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -198,6 +198,22 @@ "$ref": "/schemas/core/asset-group-vocabulary.json", "description": "Canonical registry of asset_group_id values with descriptions and v1 alias mapping (e.g., landing_page_url replaces 6 v1 alias names)" }, + "product-format-declaration": { + "$ref": "/schemas/core/product-format-declaration.json", + "description": "v2 inline format declaration on products. Keyed by canonical format name; product narrows exactly one canonical with platform-specific parameters." + }, + "platform-extension-ref": { + "$ref": "/schemas/core/platform-extension-ref.json", + "description": "Reference to a platform extension definition (URI + content digest)." + }, + "build-capability-ref": { + "$ref": "/schemas/core/build-capability-ref.json", + "description": "Reference to a creative agent's build_creative capability. Used on products that require agent-produced creative." + }, + "build-capability": { + "$ref": "/schemas/core/build-capability.json", + "description": "Declaration by a creative agent of which canonical format it can build with what parameter narrowing and required inputs." + }, "store-item": { "$ref": "/schemas/core/store-item.json", "description": "A physical store or location with coordinates, address, and catchment areas for proximity targeting" @@ -1105,6 +1121,16 @@ "$ref": "/schemas/creative/sync-creatives-response.json", "description": "Response payload for sync_creatives task" } + }, + "validate-input": { + "request": { + "$ref": "/schemas/creative/validate-input-request.json", + "description": "Request parameters for validating a creative manifest against canonical formats and/or specific products without committing to a render" + }, + "response": { + "$ref": "/schemas/creative/validate-input-response.json", + "description": "Response payload for validate_input task with per-target validation results" + } } }, "asset_types": { diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index 621f9f3870..b7c1f02d7c 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -822,6 +822,13 @@ "type": "boolean", "description": "When true, this agent can transform or resize existing manifests via build_creative. The buyer provides a creative_manifest and a target_format_id, and the agent adapts the creative to the new format.", "default": false + }, + "creative_build_capabilities": { + "type": "array", + "description": "v2 path: declarations of which canonical formats this creative agent can build via build_creative, with what parameter narrowing and required inputs. Replaces the v1 list_creative_formats discovery surface for creative agents. Buyers locate a capability by canonical format and inputs, then call build_creative against the matching capability_id. Products on sales agents reference these capabilities via build_capability_ref when their creative must be agent-produced (e.g., podcast host-read products).", + "items": { + "$ref": "/schemas/core/build-capability.json" + } } }, "additionalProperties": true From 4b0231a04ee10a062913abece6c5dfbd48b7a359 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 27 Apr 2026 12:46:21 -0400 Subject: [PATCH 07/41] docs(creative): add v2 overview with worked examples (Meta Reels, IAB MREC, podcast host-read) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopter-facing documentation for the v2 RFC architecture (#3305) shipping in the #3307 preview branch. Walks through the canonical-formats-narrowed- by-products model with three concrete worked examples that illustrate the full surface: - Meta Reels narrowing video_hosted with platform extensions - IAB Medium Rectangle (300x250) narrowing image, plus a sibling NYTimes HTML5 banner narrowing html5 (different canonical for different tracking model) - The Daily 30s host-read narrowing audio_hosted with build_capability_ref pointing at the publisher's creative agent Includes worked validate_input flow, brand_kit_override usage, platform extension distribution explanation (URI+digest bundled in get_products), and explicit notes on what's NOT in v2 (brand safety frameworks, universal macros schema, destination_kinds schema, cta_vocabulary, list_build_ capabilities tool — all dropped per design scrub). Examples are marked test=false because they intentionally show only the v2-specific surface, not the full Product schema (which would clutter the illustrations with unrelated required fields like reporting_capabilities, delivery_type, etc.). Adopters can refer to actual reference fixtures for fully-valid product examples. Tracks #3305 (v2 RFC). Doc lives at /docs/creative/v2-overview alongside existing creative docs. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/creative/v2-overview.mdx | 380 ++++++++++++++++++++++++++++++++++ 1 file changed, 380 insertions(+) create mode 100644 docs/creative/v2-overview.mdx diff --git a/docs/creative/v2-overview.mdx b/docs/creative/v2-overview.mdx new file mode 100644 index 0000000000..74551f3f67 --- /dev/null +++ b/docs/creative/v2-overview.mdx @@ -0,0 +1,380 @@ +--- +title: Creative Formats v2 (preview) +description: "Canonical formats live on AdCP; sellers' products narrow them inline. Phase 1 + Phase 2 preview against the v2 RFC #3305." +"og:title": "AdCP — Creative Formats v2" +testable: true +--- + +# Creative Formats v2 (preview) + +> **Status:** Preview track. The v2 surface is being designed in flight against [RFC #3305](https://github.com/adcontextprotocol/adcp/issues/3305) and the [#3307 implementation branch](https://github.com/adcontextprotocol/adcp/pull/3307). v1 named formats (`format_id` as `{ agent_url, id }`) remain a first-class path through 4.x with a 5.0 sunset; v2 is opt-in and additive. + +v2 collapses today's separate format registry into product-bound declarations. AdCP defines a small set of **canonical formats** (universal building blocks); sellers' products carry inline `ProductFormatDeclaration` that narrows exactly one canonical with platform-specific parameters. Creative agents become transformation services declaring `build_creative` capabilities targeting canonical formats. Most existing concepts (CTAs, destinations, tracking, brand identity) are reused or stay in their current homes — v2 doesn't create a new vocabulary layer for those. + +## Architectural shift + +| Concept | v1 | v2 | +|---|---|---| +| Format identity | Compound `{ agent_url, id }` referencing a separately-defined format file | Canonical name (e.g., `image`) keyed under `format` on the product, narrowed inline | +| Format authoring | Each platform authors its own named format files | Platforms narrow AdCP-defined canonicals; canonical IS the contract buyers validate against | +| Generative formats | Each platform publishes `*_generated_*` formats | `build_creative` capability targeting a canonical; ~30 duplicate format files dissolve | +| Discovery (sales) | `list_creative_formats` | `get_products` (each product carries its `format` declaration) | +| Discovery (creative agents) | `list_creative_formats` (overloaded) | `creative_build_capabilities` field on `get_adcp_capabilities` | +| Tracking | Mixed across asset types and format definitions | Baked into each canonical format (VAST events for `video_vast`, MRAID+OM-SDK for `html5`, impression pixel for `image`) | +| Brand identity | Sometimes redeclared as format slots | Implicit via `brand: { domain }` resolving brand.json; explicit override via `brand_kit_override` on the manifest | + +## The 11 canonical formats + +Each canonical lives at `/schemas/formats/canonical/.json`. Tracking model is **format-specific** (split by tracking model is why we have 11 instead of, say, 5). + +| Canonical | What it is | Tracking | +|---|---|---| +| `image` | Static image, file or hosted URL redirect | Impression pixel + click URL via `universal_macros` | +| `html5` | Interactive HTML5 banner (zip asset) | MRAID + OM-SDK + click-tag macro + backup image | +| `display_tag` | Third-party JS/iframe tag URL | Opaque to seller | +| `image_carousel` | Multi-card swipe (polymorphic image/video items) | Per-card pixels + carousel engagement | +| `video_hosted` | Direct video file, orientation parameter | OM-SDK + external impression/click/quartile trackers | +| `video_vast` | VAST tag (URL or inline XML), VAST 2-4.x | Inherent VAST events | +| `audio_hosted` | Direct audio file (or host-read produced via build_creative) | Standard audio impression/completion | +| `audio_daast` | DAAST tag | Inherent DAAST events | +| `sponsored_placement` | Retail-media catalog-driven (Amazon SP, Criteo SP, CitrusAd SP) | Per-item catalog-keyed events | +| `asset_pool_composed` | Buyer asset pool, surface composes (Google PMax, RDA, Demand Gen, Meta Advantage+) | Per-asset performance breakdown | +| `brand_mention` | Text/audio AI-surface composition (ChatGPT, voice assistants, sponsored search snippets) | Mention-level impression + attribution | + +## Worked example — Meta Reels + +Meta Reels narrows `video_hosted` (vertical orientation) with Meta-specific parameters and platform extensions: + +```json test=false +{ + "product_id": "meta_reels_us", + "name": "Meta Reels — United States", + "publisher_properties": [ + { "type": "publisher", "publisher_domain": "meta.com" } + ], + "channels": ["social"], + "format": { + "video_hosted": { + "orientation": "vertical", + "aspect_ratio": "9:16", + "duration_ms_range": [3000, 90000], + "min_width": 1080, + "min_height": 1920, + "max_file_size_mb": 200, + "video_codecs": ["h264"], + "audio_codecs": ["aac"], + "headline_max_chars": 25, + "primary_text_max_chars": 72, + "captions": "recommended", + "cta_values": ["LEARN_MORE", "SHOP_NOW", "DOWNLOAD", "SIGN_UP"], + "composition_model": "deterministic", + "platform_extensions": [ + { + "uri": "https://meta.adcp/extensions/meta_pixel", + "digest": "sha256:a3f5b7c9d8e2f1a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2" + }, + { + "uri": "https://meta.adcp/extensions/meta_placements_reels", + "digest": "sha256:b8e1d4f6a9c2b5e8d1f4a7c0b3e6d9f2a5c8b1e4d7f0a3c6b9e2d5f8a1c4b7e0" + } + ] + } + }, + "pricing_options": [ + { "pricing_option_id": "cpm_floor", "pricing_model": "cpm", "currency": "USD", "fixed_price": 5.50 } + ] +} +``` + +Buyer's manifest validates against canonical `video_hosted` first (does it satisfy the contract any seller speaking that canonical accepts?), then narrows against this product's specific parameters. The Meta-specific extensions (pixel, placements) are bundled in the `get_products` response under an `extensions` map keyed by `uri@digest` — buyers fetch them once and cache by digest. + +## Worked example — IAB MREC (300×250) + +The canonical-as-contract value is clearest for IAB-standard formats. NYTimes and any other publisher selling MREC narrow `image` with the same IAB-standard parameters; buyers validate against canonical `image` *before* knowing which publisher wins. + +```json test=false +{ + "product_id": "nytimes_homepage_mrec", + "name": "NYTimes.com Homepage MREC (300×250)", + "publisher_properties": [ + { "type": "publisher", "publisher_domain": "nytimes.com" } + ], + "channels": ["web"], + "format": { + "image": { + "width": 300, + "height": 250, + "max_file_size_kb": 200, + "image_formats": ["jpg", "png", "gif"], + "ssl_required": true, + "cta_values": ["LEARN_MORE", "SHOP_NOW", "GET_OFFER"], + "composition_model": "deterministic", + "platform_extensions": [ + { + "uri": "https://nytimes.adcp/extensions/nytimes_om_strict", + "digest": "sha256:c9d2f5b8e1a4c7b0e3d6f9a2c5b8e1d4f7a0c3b6e9d2f5a8c1b4e7d0f3a6c9b2" + } + ] + } + }, + "pricing_options": [ + { "pricing_option_id": "cpm_homepage", "pricing_model": "cpm", "currency": "USD", "fixed_price": 22.00 } + ] +} +``` + +For HTML5 banners on the same placement, NYTimes publishes a *separate* product narrowing `html5`: + +```json test=false +{ + "product_id": "nytimes_homepage_html5", + "name": "NYTimes.com Homepage HTML5 Banner (300×250)", + "publisher_properties": [ + { "type": "publisher", "publisher_domain": "nytimes.com" } + ], + "channels": ["web"], + "format": { + "html5": { + "width": 300, + "height": 250, + "max_initial_load_kb": 200, + "max_polite_load_kb": 500, + "host_initiated_subload": true, + "max_animation_duration_ms": 30000, + "max_cpu_load_percent": 30, + "om_sdk_required": true, + "clicktag_macro": "clickTag", + "backup_image_required": true, + "backup_image_max_size_kb": 50, + "ssl_required": true, + "composition_model": "deterministic" + } + }, + "pricing_options": [ + { "pricing_option_id": "cpm_homepage_html5", "pricing_model": "cpm", "currency": "USD", "fixed_price": 28.00 } + ] +} +``` + +Different canonical (`html5`, not `image`) because the tracking model is fundamentally different — MRAID + OM-SDK + click-tag rather than impression pixel + click URL. Adopters that want to express "this placement accepts either image or HTML5" publish two products. + +## Worked example — Podcast 30s host-read + +Host-reads are the host-recorded-from-buyer-script pattern. The product declares `audio_hosted` narrowed to publisher-host-recorded mode AND a `build_capability_ref` pointing to the publisher's creative agent that produces the audio: + +```json test=false +{ + "product_id": "the_daily_30s_host_read_us", + "name": "The Daily — 30s Host-Read Pre-roll (US)", + "publisher_properties": [ + { "type": "publisher", "publisher_domain": "thedailypod.example" } + ], + "channels": ["podcast"], + "format": { + "audio_hosted": { + "duration_ms_exact": 30000, + "audio_codecs": ["mp3", "aac"], + "audio_sample_rates": [44100, 48000], + "audio_channels": ["stereo"], + "loudness_lufs": -16, + "audio_source": "publisher_host_recorded", + "buyer_audio_acceptance": "rejected", + "composition_model": "deterministic" + } + }, + "build_capability_ref": { + "agent_url": "https://creative.thedailypod.example/adcp", + "capability_id": "the_daily_host_read_production", + "digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "pricing_options": [ + { "pricing_option_id": "cpm_host_read", "pricing_model": "cpm", "currency": "USD", "fixed_price": 35.00 } + ] +} +``` + +The buyer's flow: + +1. `get_products` on The Daily's sales agent → finds the host-read product. +2. Reads `build_capability_ref` → calls `get_adcp_capabilities` on the creative agent at `https://creative.thedailypod.example/adcp`. +3. The capability's response declares its inputs: + + ```json test=false + { + "creative": { + "creative_build_capabilities": [ + { + "capability_id": "the_daily_host_read_production", + "target_format": "audio_hosted", + "narrowing": { + "audio_hosted": { + "duration_ms_exact": 30000, + "loudness_lufs": -16, + "audio_source": "publisher_host_recorded" + } + }, + "inputs": { + "script": { + "required": true, + "max_chars": 800, + "description": "Verbatim script the host will read. Exact wording — no improvisation; legal pre-cleared." + }, + "brand": { "required": true }, + "offering_ref": { "required": false } + }, + "production_window_business_days": 7 + } + ] + } + } + ``` + +4. Buyer calls `build_creative({ format_id: "audio_hosted", inputs: { script, brand, offering_ref } })` → host records → returns manifest with audio asset. +5. Buyer submits the resulting creative for the media buy. + +For brief-driven (talking-points-style) host-reads, the same product would point at a different capability that takes `creative_brief` instead of `script`. Same target format (`audio_hosted`); different input contract. + +## Validation flow — `validate_input` + +Buyers can dry-run a manifest against canonicals and/or specific products without committing to a render: + +```json test=false +{ + "manifest": { + "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_30s" }, + "assets": { + "video_main": { + "asset_type": "video", + "url": "https://cdn.acme.example/spring-30s.mp4", + "duration_ms": 30000, + "width": 1080, + "height": 1920 + } + }, + "brand": { "domain": "acme.example" } + }, + "format_ids": ["video_hosted"], + "product_ids": ["meta_reels_us"] +} +``` + +Response carries per-target results: + +```json test=false +{ + "results": [ + { + "target": { "kind": "canonical", "id": "video_hosted" }, + "ok": true + }, + { + "target": { "kind": "product", "id": "meta_reels_us" }, + "ok": false, + "violations": [ + { + "rule": "duration_ms_range", + "expected": "3000-90000", + "predicted": 30000, + "field": "assets.video_main.duration_ms" + } + ] + } + ] +} +``` + +`validate_input` is the predictable-case primitive. For genuinely nondeterministic generative platforms (Veo / Sora / Runway-class), predictive validation is impossible and the platform's own post-synthesis QA loop applies — `build_creative` returns `task_failed` with a `synthesis_failed` reason if the QA loop exhausts without producing a valid artifact. There is **no protocol state for orphaned out-of-spec artifacts**. + +## Brand identity via brand.json (with override) + +v2 formats no longer redeclare `brand_logo`, `brand_colors`, `brand_voice`, `brand_tagline` as explicit slots. When a manifest carries `brand: { domain: "acme.example" }`, the seller fetches `https://acme.example/.well-known/brand.json` for brand context. + +For the case where brand.json is missing or stale, the manifest includes `brand_kit_override`: + +```json test=false +{ + "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "image_300x250" }, + "assets": { + "image_main": { "asset_type": "image", "url": "https://cdn.acme.example/banner.jpg", "width": 300, "height": 250 } + }, + "brand": { "domain": "acme.example" }, + "brand_kit_override": { + "logo": { "asset_type": "image", "url": "https://cdn.acme.example/logo-2026.png", "width": 200, "height": 100 }, + "colors": { "primary": "#0066CC", "accent": "#FF6600" }, + "tagline": "Spring savings, all season" + } +} +``` + +Override fields take precedence over `brand.json` for that creative. + +## Platform extensions — distribution + +Platform extensions are narrow, truly platform-specific additions (pixel ID shapes, conversion event taxonomies, platform-specific CTAs/destinations). They live at well-known paths on the owning agent: + +``` +https://meta.adcp/extensions/meta_pixel +https://tiktok.adcp/extensions/tiktok_pixel +https://nytimes.adcp/extensions/nytimes_om_strict +``` + +Each extension's response carries the schema, the canonical pattern or slot it extends, a version, and a content digest. + +**Distribution path: bundled in `get_products`.** The sales agent's response includes definitions for every extension referenced by any product in the response, keyed by `uri@digest`: + +```json test=false +{ + "products": [ { "...": "..." } ], + "extensions": { + "https://meta.adcp/extensions/meta_pixel@sha256:a3f5...": { + "extends": "tracking", + "fields": { + "pixel_id": { "type": "string", "required": true }, + "conversion_event": { "type": "string", "enum": ["PURCHASE", "LEAD"] } + }, + "version": "2.1.0" + } + } +} +``` + +Buyer's SDK caches by URI@digest. Subsequent `get_products` responses can reference by digest alone if the buyer has the extension cached. Direct URI fetch is supported for tooling but the primary path is bundled-in-`get_products`. + +## What's NOT in v2 + +By design, v2 doesn't introduce new vocabulary for things AdCP already handles or that belong elsewhere: + +- **Brand safety vocabularies** — that's media-buy/campaign-level (`creative-policy.json` and broader campaign settings), not creative-format-level. Format declarations don't redeclare brand safety. +- **Universal macros as a new schema** — already documented at [`/docs/creative/universal-macros`](/docs/creative/universal-macros). Canonical formats reference them by name. +- **`destination_kinds` as a new schema** — `url-asset.json` already has `url_type` covering URL kind disambiguation. Platform-specific destinations (Meta `messenger_thread`, etc.) are platform extensions. +- **`cta_vocabulary` as a canonical pattern** — CTAs vary meaningfully across surfaces; we let products declare `cta_values` arrays inline until cross-platform demand emerges. +- **`list_build_capabilities` as a separate tool** — folded into `get_adcp_capabilities` under `creative.creative_build_capabilities`. + +## Migration + +| Adopter | Cost | Realistic timeline | +|---|---|---| +| DSP buyer agents | Low | 3.1-3.2 | +| SSP/sales agents | Medium-high | 3.3-4.0 | +| Walled gardens (Meta, Google, Amazon, TikTok, Snap, Pinterest) | High, low motivation | 4.0-5.0 if at all (gated on AAO providing a translator from existing format docs) | +| Creative agents (AudioStack-shaped) | Low, high motivation | 3.1-3.2 | +| Publisher direct (GAM/prebid path) | Medium | Blocked on native canonical pre-audit | + +**v1 stays first-class.** v1 named formats remain supported; sellers SHOULD provide server-side flatten wrappers that derive the v1 `list_creative_formats` shape from v2 product format declarations through 4.0. v2 is the *new* path, not the only path. + +## Phase status + +| Phase | Status | What's in it | +|---|---|---| +| Phase 1 | ✅ in #3307 | `asset_group_id` vocabulary registry, `scenes` schema, `zip` asset type, video/audio doc fixes | +| Phase 2 | ✅ in #3307 | 11 canonical format definitions, ProductFormatDeclaration, build capabilities, validate_input, brand_kit_override, platform-extension-ref | +| Phase 3 | TBD | Reference SDK codegen, server-side flatten wrappers, migration guide | +| Native canonical | TBD | Deferred to 3.2 after TemplateCreative + OpenRTB Native 1.2 audit | + +## Related + +- [RFC #3305](https://github.com/adcontextprotocol/adcp/issues/3305) — v2 architecture decisions and rationale +- [PR #3307](https://github.com/adcontextprotocol/adcp/pull/3307) — Phase 1 + Phase 2 implementation, on hold pending 3.1.0 beta cycle +- [Asset group vocabulary](https://adcontextprotocol.org/schemas/v3/core/asset-group-vocabulary.json) — canonical slot-name registry +- [Scenes schema](https://adcontextprotocol.org/schemas/v3/creative/scenes.json) — typed scene-by-scene structure for build_creative +- [Universal macros](/docs/creative/universal-macros) — substitution patterns referenced from canonical tracking From 170fa41d10d47d5ab91fdcc88cc0289318638c6c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 27 Apr 2026 18:50:03 -0400 Subject: [PATCH 08/41] refactor(creative): collapse build_capability into format inputs; add asset_group_id to format slots; expand vocabulary aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplifications from the working-example walkthrough: 1. Drop build_capability and build_capability_ref schemas + product field. The input contract folds into the format declaration as a parameter. External creative agents are invisible to the buyer in the typical case — buyer-seller boundary is the only relationship the protocol models. 2. Add `inputs` field to canonical format _base.json. Tells the buyer what the format requires (script for host-read, creative_brief for generative, voice_id for TTS variation). When absent, format accepts only buyer-uploaded creative. 3. Rename creative.creative_build_capabilities → creative.supported_formats on get_adcp_capabilities response. Drops "build_" framing on discovery surface naming. Each entry uses ProductFormatDeclaration shape — one primitive, two homes (sales-side inline on products, creative-agent- side as supported_formats list). 4. Add `asset_group_id` field to format.json baseIndividualAsset and baseGroupAsset. Lets v1 reference creatives declare their canonical equivalents inline (e.g., slot click_url → asset_group_id landing_page_url). Strengthens v1↔v2 migration bridge. 5. Expand `aliases` arrays in asset-group-vocabulary.json with audit- grounded sets for headlines, descriptions, images_landscape, images_vertical, images_square, logo, video, audio. 6. Update v2-overview.mdx — host-read example uses inline inputs, two- flow explanation (buyer pre-produces via build_creative on a creative agent → sync_creatives, OR sync_creatives directly with inputs → seller produces internally). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/creative/v2-overview.mdx | 87 +++++++-------- .../source/core/asset-group-vocabulary.json | 24 ++-- .../source/core/build-capability-ref.json | 32 ------ .../schemas/source/core/build-capability.json | 105 ------------------ static/schemas/source/core/format.json | 8 ++ static/schemas/source/core/product.json | 6 +- .../source/formats/canonical/_base.json | 27 +++++ static/schemas/source/index.json | 8 -- .../get-adcp-capabilities-response.json | 18 ++- 9 files changed, 105 insertions(+), 210 deletions(-) delete mode 100644 static/schemas/source/core/build-capability-ref.json delete mode 100644 static/schemas/source/core/build-capability.json diff --git a/docs/creative/v2-overview.mdx b/docs/creative/v2-overview.mdx index 74551f3f67..f654b9db10 100644 --- a/docs/creative/v2-overview.mdx +++ b/docs/creative/v2-overview.mdx @@ -19,7 +19,7 @@ v2 collapses today's separate format registry into product-bound declarations. A | Format authoring | Each platform authors its own named format files | Platforms narrow AdCP-defined canonicals; canonical IS the contract buyers validate against | | Generative formats | Each platform publishes `*_generated_*` formats | `build_creative` capability targeting a canonical; ~30 duplicate format files dissolve | | Discovery (sales) | `list_creative_formats` | `get_products` (each product carries its `format` declaration) | -| Discovery (creative agents) | `list_creative_formats` (overloaded) | `creative_build_capabilities` field on `get_adcp_capabilities` | +| Discovery (creative agents) | `list_creative_formats` (overloaded) | `creative.supported_formats` field on `get_adcp_capabilities` (same `ProductFormatDeclaration` shape as products' inline `format`) | | Tracking | Mixed across asset types and format definitions | Baked into each canonical format (VAST events for `video_vast`, MRAID+OM-SDK for `html5`, impression pixel for `image`) | | Brand identity | Sometimes redeclared as format slots | Implicit via `brand: { domain }` resolving brand.json; explicit override via `brand_kit_override` on the manifest | @@ -160,7 +160,7 @@ Different canonical (`html5`, not `image`) because the tracking model is fundame ## Worked example — Podcast 30s host-read -Host-reads are the host-recorded-from-buyer-script pattern. The product declares `audio_hosted` narrowed to publisher-host-recorded mode AND a `build_capability_ref` pointing to the publisher's creative agent that produces the audio: +Host-reads are the host-recorded-from-buyer-script pattern. The product declares `audio_hosted` narrowed to publisher-host-recorded mode with `inputs` describing what the buyer must provide: ```json test=false { @@ -179,60 +179,48 @@ Host-reads are the host-recorded-from-buyer-script pattern. The product declares "loudness_lufs": -16, "audio_source": "publisher_host_recorded", "buyer_audio_acceptance": "rejected", - "composition_model": "deterministic" + "composition_model": "deterministic", + "inputs": { + "script": { + "required": true, + "max_chars": 800, + "description": "Verbatim script the host will read. Exact wording — no improvisation; legal pre-cleared." + }, + "brand": { "required": true }, + "offering_ref": { "required": false } + }, + "production_window_business_days": 7 } }, - "build_capability_ref": { - "agent_url": "https://creative.thedailypod.example/adcp", - "capability_id": "the_daily_host_read_production", - "digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - }, "pricing_options": [ { "pricing_option_id": "cpm_host_read", "pricing_model": "cpm", "currency": "USD", "fixed_price": 35.00 } ] } ``` -The buyer's flow: - -1. `get_products` on The Daily's sales agent → finds the host-read product. -2. Reads `build_capability_ref` → calls `get_adcp_capabilities` on the creative agent at `https://creative.thedailypod.example/adcp`. -3. The capability's response declares its inputs: - - ```json test=false - { - "creative": { - "creative_build_capabilities": [ - { - "capability_id": "the_daily_host_read_production", - "target_format": "audio_hosted", - "narrowing": { - "audio_hosted": { - "duration_ms_exact": 30000, - "loudness_lufs": -16, - "audio_source": "publisher_host_recorded" - } - }, - "inputs": { - "script": { - "required": true, - "max_chars": 800, - "description": "Verbatim script the host will read. Exact wording — no improvisation; legal pre-cleared." - }, - "brand": { "required": true }, - "offering_ref": { "required": false } - }, - "production_window_business_days": 7 - } - ] - } - } - ``` - -4. Buyer calls `build_creative({ format_id: "audio_hosted", inputs: { script, brand, offering_ref } })` → host records → returns manifest with audio asset. -5. Buyer submits the resulting creative for the media buy. - -For brief-driven (talking-points-style) host-reads, the same product would point at a different capability that takes `creative_brief` instead of `script`. Same target format (`audio_hosted`); different input contract. +The format declaration tells the buyer everything they need to know — no extra capability lookup. `inputs` lives directly on the format. The buyer has two flows depending on whether the seller doubles as a creative agent and whether the buyer wants to pre-produce externally. + +### Flow 1 — buyer pre-produces (upstream creative agent) + +The buyer calls a creative agent's `build_creative` independently, gets back a rendered manifest, and submits that to the seller. Useful when the buyer has a preferred production partner (their in-house studio, AudioStack-style services) or the seller exposes itself as a creative agent. + +1. Buyer reads The Daily's product format → sees `inputs: { script, brand }` declared +2. Buyer calls `build_creative({ format: , inputs: { script, brand } })` on a creative agent — this could be The Daily's own creative-agent surface (if they expose one), or any other agent that declares it can produce this format via its `creative.supported_formats` on `get_adcp_capabilities` +3. Receives a rendered manifest with audio asset +4. Submits the rendered manifest via `sync_creatives` to The Daily's sales agent + +### Flow 2 — seller produces internally + +The buyer submits inputs directly to the seller; the seller produces internally (calls its own creative team or an upstream creative agent under the hood) and returns a registered creative. + +1. Buyer reads the same product format +2. Buyer submits via `sync_creatives` with the inputs in the manifest +3. Seller produces internally; how is invisible to the buyer +4. Returns async status; buyer polls or waits for completion + +The format's `audio_source: "publisher_host_recorded"` + `buyer_audio_acceptance: "rejected"` tells the buyer which flows are accepted. For The Daily's host-read, both flows are valid because the publisher's host needs to be the producer in either case — the difference is whether the buyer drives the build call or the seller drives it. Other products might accept Flow 1 only (buyer must pre-produce) or Flow 2 only. + +For brief-driven (talking-points-style) host-reads, the same shape applies with `creative_brief` in place of `script` in the inputs declaration. Same target format (`audio_hosted`); different input contract. ## Validation flow — `validate_input` @@ -348,7 +336,8 @@ By design, v2 doesn't introduce new vocabulary for things AdCP already handles o - **Universal macros as a new schema** — already documented at [`/docs/creative/universal-macros`](/docs/creative/universal-macros). Canonical formats reference them by name. - **`destination_kinds` as a new schema** — `url-asset.json` already has `url_type` covering URL kind disambiguation. Platform-specific destinations (Meta `messenger_thread`, etc.) are platform extensions. - **`cta_vocabulary` as a canonical pattern** — CTAs vary meaningfully across surfaces; we let products declare `cta_values` arrays inline until cross-platform demand emerges. -- **`list_build_capabilities` as a separate tool** — folded into `get_adcp_capabilities` under `creative.creative_build_capabilities`. +- **`list_build_capabilities` as a separate tool** — folded into `get_adcp_capabilities` under `creative.supported_formats`. +- **`build_capability` and `build_capability_ref` as separate concepts** — collapsed into `inputs` directly on the format declaration. The format itself tells the buyer what it requires; how production happens (buyer pre-producing through a creative agent's `build_creative`, or seller producing internally) is the buyer's flow choice, not a separate protocol concept. ## Migration diff --git a/static/schemas/source/core/asset-group-vocabulary.json b/static/schemas/source/core/asset-group-vocabulary.json index 71160d8757..2e312e1fa6 100644 --- a/static/schemas/source/core/asset-group-vocabulary.json +++ b/static/schemas/source/core/asset-group-vocabulary.json @@ -9,37 +9,44 @@ "headlines": { "description": "Pool of headline text variants for the surface to choose from.", "asset_type": "text", - "typical_use": "Multiple short headline copy variants (Google PMax/RDA, Meta promoted_offerings, etc.)" + "typical_use": "Multiple short headline copy variants (Google PMax/RDA, Meta promoted_offerings, etc.)", + "aliases": ["headline", "title", "tagline", "headline_text"] }, "descriptions": { "description": "Pool of body description text variants.", "asset_type": "text", - "typical_use": "Body-copy variants for surfaces that pick the best combination." + "typical_use": "Body-copy variants for surfaces that pick the best combination.", + "aliases": ["description", "body", "body_text", "text", "content"] }, "images_landscape": { "description": "Pool of landscape-orientation images (1.91:1 or 16:9 typical).", "asset_type": "image", - "typical_use": "Hero images for landscape-format placements (Meta feed, LinkedIn feed, Google display)." + "typical_use": "Hero images for landscape-format placements (Meta feed, LinkedIn feed, Google display).", + "aliases": ["image", "hero_image", "landscape_image", "banner_image"] }, "images_vertical": { "description": "Pool of vertical-orientation images (9:16 typical).", "asset_type": "image", - "typical_use": "Hero images for stories, reels, and other vertical placements (Snap, TikTok, Meta Stories, Pinterest)." + "typical_use": "Hero images for stories, reels, and other vertical placements (Snap, TikTok, Meta Stories, Pinterest).", + "aliases": ["vertical_image", "story_image", "portrait_image"] }, "images_square": { "description": "Pool of square-orientation images (1:1).", "asset_type": "image", - "typical_use": "Feed-context images, profile-style placements, square carousel cards." + "typical_use": "Feed-context images, profile-style placements, square carousel cards.", + "aliases": ["square_image", "feed_image"] }, "logo": { "description": "Brand logo asset (typically 1:1 or 2:1).", "asset_type": "image", - "typical_use": "Brand attribution overlay (Google PMax/RDA, Snap Story Ad, Amazon SB)." + "typical_use": "Brand attribution overlay (Google PMax/RDA, Snap Story Ad, Amazon SB).", + "aliases": ["brand_logo", "logo_image"] }, "video": { "description": "Pool of video assets.", "asset_type": "video", - "typical_use": "Video creative for video placements; orientation determined by platform constraints." + "typical_use": "Video creative for video placements; orientation determined by platform constraints.", + "aliases": ["video_file", "hero_video", "video_asset", "video_main"] }, "video_vertical": { "description": "Pool of vertical-orientation video (9:16).", @@ -54,7 +61,8 @@ "audio": { "description": "Audio asset.", "asset_type": "audio", - "typical_use": "Audio ads for streaming, podcasts, broadcast radio." + "typical_use": "Audio ads for streaming, podcasts, broadcast radio.", + "aliases": ["audio_file", "hero_audio", "audio_asset", "audio_main"] }, "companion_image": { "description": "Image displayed alongside an audio asset.", diff --git a/static/schemas/source/core/build-capability-ref.json b/static/schemas/source/core/build-capability-ref.json deleted file mode 100644 index 34757da76c..0000000000 --- a/static/schemas/source/core/build-capability-ref.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/core/build-capability-ref.json", - "title": "Build Capability Reference", - "description": "Reference to a creative agent's build_creative capability. Used on products where the creative must be produced via a specific creative agent (e.g., podcast host-read products where the publisher's host records the audio from a script). Buyers resolve the capability by calling `get_adcp_capabilities` on the agent and reading its `creative_build_capabilities` array, then call `build_creative` against the matching capability.", - "type": "object", - "required": ["agent_url", "capability_id"], - "properties": { - "agent_url": { - "type": "string", - "format": "uri", - "description": "URL of the creative agent that exposes the capability." - }, - "capability_id": { - "type": "string", - "description": "Identifier of the specific build capability within the agent (e.g., 'the_daily_host_read_production')." - }, - "digest": { - "type": "string", - "pattern": "^sha256:[a-f0-9]{64}$", - "description": "Optional SHA-256 content digest of the capability definition. Lets buyers detect when the capability has changed." - } - }, - "additionalProperties": true, - "examples": [ - { - "agent_url": "https://creative.thedailypod.example/adcp", - "capability_id": "the_daily_host_read_production", - "digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - } - ] -} diff --git a/static/schemas/source/core/build-capability.json b/static/schemas/source/core/build-capability.json deleted file mode 100644 index 3c11aab6cb..0000000000 --- a/static/schemas/source/core/build-capability.json +++ /dev/null @@ -1,105 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/core/build-capability.json", - "title": "Build Capability", - "description": "Declaration by a creative agent that it can produce manifests conforming to a canonical format with specific parameter narrowing and required inputs. Buyers discover capabilities via the `creative_build_capabilities` field on `get_adcp_capabilities` responses, then call `build_creative` against the matching capability_id with the declared inputs. Structurally a ProductFormatDeclaration narrowing minus the sales-agent-product wrapping — the schemas converge.", - "type": "object", - "required": ["capability_id", "target_format", "inputs"], - "properties": { - "capability_id": { - "type": "string", - "description": "Identifier for this specific build capability within the creative agent (e.g., 'audiostack_audio_30s', 'the_daily_host_read_production', 'midjourney_image_landscape')." - }, - "name": { - "type": "string", - "description": "Human-readable capability name." - }, - "description": { - "type": "string", - "description": "Detailed description of what this capability produces and how." - }, - "target_format": { - "type": "string", - "description": "Canonical format the capability produces (e.g., 'audio_hosted', 'image', 'video_vertical')." - }, - "narrowing": { - "$ref": "/schemas/core/product-format-declaration.json", - "description": "Parameter narrowing of the target canonical (dimensions, duration, codec ranges). Use the same shape as ProductFormatDeclaration; the canonical key MUST match `target_format`." - }, - "inputs": { - "type": "object", - "description": "Typed input vocabulary the capability consumes. Each key is a build_creative input name (creative_brief, scenes, voice_id, style_reference, script, starter_assets, product_feed, brand, offering_ref). Values describe whether each input is required and any constraints (max_chars, allowed_voices, etc.).", - "additionalProperties": { - "type": "object", - "properties": { - "required": { "type": "boolean" }, - "description": { "type": "string" }, - "max_chars": { "type": "integer", "minimum": 1 }, - "allowed_values": { "type": "array", "items": { "type": "string" } } - }, - "additionalProperties": true - } - }, - "production_window_business_days": { - "type": "integer", - "minimum": 0, - "description": "Typical production turnaround in business days. 0 for synchronous (e.g., generative AI); >0 for human-produced (e.g., podcast host-read)." - }, - "digest": { - "type": "string", - "pattern": "^sha256:[a-f0-9]{64}$", - "description": "SHA-256 content digest of this capability definition. Drives BuildCapabilityRef caching." - } - }, - "additionalProperties": true, - "examples": [ - { - "capability_id": "audiostack_audio_30s", - "name": "AudioStack 30s Audio Synthesis", - "target_format": "audio_hosted", - "narrowing": { - "audio_hosted": { - "duration_ms_exact": 30000, - "audio_codecs": ["mp3"], - "audio_channels": ["stereo"], - "loudness_lufs": -16 - } - }, - "inputs": { - "creative_brief": { - "required": true, - "max_chars": 1000, - "description": "Talking points and brand context for the synthesized audio." - }, - "voice_id": { - "required": false, - "description": "Voice selection from AudioStack's voice library. Defaults to brand voice if absent." - }, - "brand": { "required": true } - }, - "production_window_business_days": 0 - }, - { - "capability_id": "the_daily_host_read_production", - "name": "The Daily — 30s Host-Read Production", - "target_format": "audio_hosted", - "narrowing": { - "audio_hosted": { - "duration_ms_exact": 30000, - "loudness_lufs": -16, - "audio_source": "publisher_host_recorded" - } - }, - "inputs": { - "script": { - "required": true, - "max_chars": 800, - "description": "Verbatim script the host will read. Exact wording — no improvisation; legal pre-cleared." - }, - "brand": { "required": true }, - "offering_ref": { "required": false } - }, - "production_window_business_days": 7 - } - ] -} diff --git a/static/schemas/source/core/format.json b/static/schemas/source/core/format.json index fa4eb92540..4331457deb 100644 --- a/static/schemas/source/core/format.json +++ b/static/schemas/source/core/format.json @@ -29,6 +29,10 @@ "type": "array", "description": "Publisher-controlled elements rendered on top of buyer content at this asset's position (e.g., video player controls, publisher logos). Creative agents should avoid placing critical content (CTAs, logos, key copy) within overlay bounds.", "items": { "$ref": "/schemas/core/overlay.json" } + }, + "asset_group_id": { + "type": "string", + "description": "Optional canonical asset_group_id this slot fills, drawn from /schemas/core/asset-group-vocabulary.json. Lets buyers and migration tools resolve v1 author-invented slot names (e.g., `click_url`) to canonical names (e.g., `landing_page_url`). Validators MAY soft-warn when a v1 slot's asset_id is a known alias but no asset_group_id is declared." } }, "required": ["item_type", "asset_id", "asset_type", "required"] @@ -44,6 +48,10 @@ "type": "string", "description": "Descriptive label for this asset's purpose. For documentation and UI display only — manifests key assets by asset_id, not asset_role." }, + "asset_group_id": { + "type": "string", + "description": "Optional canonical asset_group_id this slot fills, drawn from /schemas/core/asset-group-vocabulary.json. Same semantics as on baseIndividualAsset — lets buyers and migration tools resolve v1 author-invented slot names to canonical names." + }, "required": { "type": "boolean", "description": "Whether this asset is required within each repetition of the group", diff --git a/static/schemas/source/core/product.json b/static/schemas/source/core/product.json index 70c6fe6e66..b9301facb0 100644 --- a/static/schemas/source/core/product.json +++ b/static/schemas/source/core/product.json @@ -43,11 +43,7 @@ }, "format": { "$ref": "/schemas/core/product-format-declaration.json", - "description": "v2 path: inline format declaration keyed by canonical format name. Products narrow exactly one canonical format with platform-specific parameters and extensions. Mutually exclusive with `format_ids` — a product is either v1 (references named formats) or v2 (carries inline declaration), not both." - }, - "build_capability_ref": { - "$ref": "/schemas/core/build-capability-ref.json", - "description": "Reference to a creative agent's build_creative capability. Used when this product requires creative produced by a specific creative agent (e.g., podcast host-read products where the publisher's host records audio from a buyer-supplied script). Buyers resolve via get_adcp_capabilities on the agent." + "description": "v2 path: inline format declaration keyed by canonical format name. Products narrow exactly one canonical format with platform-specific parameters, optional `inputs` describing what the format requires from the buyer, and optional platform extensions. Mutually exclusive with `format_ids` — a product is either v1 (references named formats) or v2 (carries inline declaration), not both." }, "placements": { "type": "array", diff --git a/static/schemas/source/formats/canonical/_base.json b/static/schemas/source/formats/canonical/_base.json index 37579c0391..36d19b7ce5 100644 --- a/static/schemas/source/formats/canonical/_base.json +++ b/static/schemas/source/formats/canonical/_base.json @@ -23,6 +23,33 @@ "type": "array", "description": "Platform-specific tracking extensions (e.g., Meta pixel, TikTok pixel) referenced when the canonical's baseline tracking is insufficient. Each entry references the tracking-extension definition.", "items": { "$ref": "/schemas/core/platform-extension-ref.json" } + }, + "inputs": { + "type": "object", + "description": "What the format requires from the buyer. When the format accepts only buyer-uploaded creative (e.g., a static image format), `inputs` is typically absent and the buyer simply uploads assets. When the format requires agent production (e.g., host-read where the publisher records the audio), `inputs` declares the script, brief, voice selection, brand context, etc. Inputs MAY be fulfilled either by the buyer pre-producing through a creative agent's `build_creative` and submitting the rendered manifest, or by the buyer submitting inputs directly via `sync_creatives` for the seller to produce internally — the format's `audio_source`/`buyer_audio_acceptance`-style parameters tell the buyer which flows are accepted.", + "additionalProperties": { + "type": "object", + "properties": { + "required": { "type": "boolean" }, + "description": { "type": "string" }, + "max_chars": { "type": "integer", "minimum": 1 }, + "max_size_kb": { "type": "integer", "minimum": 1 }, + "allowed_values": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": true + }, + "examples": [ + { + "script": { "required": true, "max_chars": 800, "description": "Verbatim script the host will read." }, + "brand": { "required": true }, + "offering_ref": { "required": false } + } + ] + }, + "production_window_business_days": { + "type": "integer", + "minimum": 0, + "description": "Typical production turnaround in business days when the format requires agent production. 0 for synchronous (e.g., generative AI); >0 for human-produced (e.g., podcast host-read). Absent when no production is required (buyer uploads complete creative)." } }, "additionalProperties": true diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index 6871518ac1..f4214f8e0d 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -206,14 +206,6 @@ "$ref": "/schemas/core/platform-extension-ref.json", "description": "Reference to a platform extension definition (URI + content digest)." }, - "build-capability-ref": { - "$ref": "/schemas/core/build-capability-ref.json", - "description": "Reference to a creative agent's build_creative capability. Used on products that require agent-produced creative." - }, - "build-capability": { - "$ref": "/schemas/core/build-capability.json", - "description": "Declaration by a creative agent of which canonical format it can build with what parameter narrowing and required inputs." - }, "store-item": { "$ref": "/schemas/core/store-item.json", "description": "A physical store or location with coordinates, address, and catchment areas for proximity targeting" diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index b7c1f02d7c..972c8a7889 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -823,11 +823,23 @@ "description": "When true, this agent can transform or resize existing manifests via build_creative. The buyer provides a creative_manifest and a target_format_id, and the agent adapts the creative to the new format.", "default": false }, - "creative_build_capabilities": { + "supported_formats": { "type": "array", - "description": "v2 path: declarations of which canonical formats this creative agent can build via build_creative, with what parameter narrowing and required inputs. Replaces the v1 list_creative_formats discovery surface for creative agents. Buyers locate a capability by canonical format and inputs, then call build_creative against the matching capability_id. Products on sales agents reference these capabilities via build_capability_ref when their creative must be agent-produced (e.g., podcast host-read products).", + "description": "v2 path: format declarations describing which canonical formats this creative agent can produce via `build_creative`. Each entry uses the same `ProductFormatDeclaration` shape as products' inline `format` field — keyed by canonical format name with parameter narrowing and `inputs` describing what the agent needs from the buyer. Replaces the v1 `list_creative_formats` discovery surface for creative agents. Each entry MAY include a `capability_id` for stable identification across versions; the format key already disambiguates which canonical the entry targets.", "items": { - "$ref": "/schemas/core/build-capability.json" + "type": "object", + "properties": { + "capability_id": { + "type": "string", + "description": "Stable identifier for this format declaration within the agent (e.g., 'audiostack_audio_30s'). Optional but recommended for declarations that may be referenced over time." + }, + "format": { + "$ref": "/schemas/core/product-format-declaration.json", + "description": "Format declaration this agent can produce. Same shape as a product's inline `format`: keyed by canonical name with parameters and inputs." + } + }, + "required": ["format"], + "additionalProperties": true } } }, From 8db52b9e387ced3fab7c603dd32567d4f0b46567 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 28 Apr 2026 09:25:44 -0400 Subject: [PATCH 09/41] docs(creative): drop "generative formats" framing in v2 overview; add preview-as-verification section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diff comment review surfaced that the comparison table's "Generative formats" row was reasoning about a category that doesn't exist at the protocol level. Sellers take inputs (script, brief, voice_id) OR assets (image, video, audio uploads) per the format declaration. Whether the seller's internal production is generative AI, host recording, transcoding, or pixel-perfect asset rendering is invisible to the buyer. There is no "generative" axis to model. - Reframe line 20 as "Format input contract" — captures the v2 collapse without leaning on a generative special case - Add "Preview as the universal what-does-this-produce surface" section after validate_input — makes explicit that preview_creative shows output regardless of submission shape (asset-driven OR input-driven). Different sellers may produce differently internally; preview surface is uniform. - Tighten "nondeterministic generative platforms" wording to "nondeterministic synthesis" — same point, less reliance on the dropped category. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/creative/v2-overview.mdx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/creative/v2-overview.mdx b/docs/creative/v2-overview.mdx index f654b9db10..2aea0b28b9 100644 --- a/docs/creative/v2-overview.mdx +++ b/docs/creative/v2-overview.mdx @@ -17,7 +17,7 @@ v2 collapses today's separate format registry into product-bound declarations. A |---|---|---| | Format identity | Compound `{ agent_url, id }` referencing a separately-defined format file | Canonical name (e.g., `image`) keyed under `format` on the product, narrowed inline | | Format authoring | Each platform authors its own named format files | Platforms narrow AdCP-defined canonicals; canonical IS the contract buyers validate against | -| Generative formats | Each platform publishes `*_generated_*` formats | `build_creative` capability targeting a canonical; ~30 duplicate format files dissolve | +| Format input contract | Each platform publishes a parallel set of `*_generated_*` format files for AI-produced creative alongside the asset-upload version (~30 duplicate files in agentic-adapters) | The format declares what it accepts via `inputs` (script, creative_brief, voice_id, starter_assets, etc.) and asset slots (image, video, audio uploads). Some products take only assets; some take only inputs; some take both. **Whether the seller's internal production is generative AI, host recording, transcoding, or asset rendering is invisible to the buyer.** No "generative" category at the protocol level; the production mechanism is implementation detail. | | Discovery (sales) | `list_creative_formats` | `get_products` (each product carries its `format` declaration) | | Discovery (creative agents) | `list_creative_formats` (overloaded) | `creative.supported_formats` field on `get_adcp_capabilities` (same `ProductFormatDeclaration` shape as products' inline `format`) | | Tracking | Mixed across asset types and format definitions | Baked into each canonical format (VAST events for `video_vast`, MRAID+OM-SDK for `html5`, impression pixel for `image`) | @@ -271,7 +271,16 @@ Response carries per-target results: } ``` -`validate_input` is the predictable-case primitive. For genuinely nondeterministic generative platforms (Veo / Sora / Runway-class), predictive validation is impossible and the platform's own post-synthesis QA loop applies — `build_creative` returns `task_failed` with a `synthesis_failed` reason if the QA loop exhausts without producing a valid artifact. There is **no protocol state for orphaned out-of-spec artifacts**. +`validate_input` is the predictable-case primitive. For genuinely nondeterministic synthesis (Veo / Sora / Runway-class), predictive validation is impossible and the platform's own post-synthesis QA loop applies — submission returns `task_failed` with a `synthesis_failed` reason if the QA loop exhausts without producing a valid artifact. There is **no protocol state for orphaned out-of-spec artifacts**. + +## Preview as the universal "what does this produce" surface + +Whether the buyer submits assets (uploaded image/video/audio) or inputs (script, creative_brief, voice_id), `preview_creative` shows what the output renders as. The seller's response to a creative submission can also include a preview URL — the buyer doesn't need a separate preview call to verify that their submission produced the intended output. Same surface, two submission shapes: + +- **Asset-driven submission**: buyer uploads finished creative assets → preview shows the rendered placement (with seller-side composition, overlays, CTA buttons applied) +- **Input-driven submission**: buyer submits script/brief/voice_id → seller produces internally (host recording, generative AI, asset rendering — invisible) → preview shows the produced output + +The buyer can iterate on inputs and inspect previews before committing to a buy. Different sellers may produce differently internally; the preview surface is uniform. This is what makes "production mechanism is invisible to the buyer" workable in practice — the buyer doesn't need to know HOW the output was produced because they can see WHAT was produced. ## Brand identity via brand.json (with override) From 5b74e25af14ed1519e3617d58d989e8dc1b973e5 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 28 Apr 2026 10:05:41 -0400 Subject: [PATCH 10/41] =?UTF-8?q?refactor(creative):=20rename=20canonical?= =?UTF-8?q?=20formats=20=E2=80=94=20asset=5Fpool=5Fcomposed=20=E2=86=92=20?= =?UTF-8?q?responsive=5Fcreative;=20brand=5Fmention=20=E2=86=92=20agent=5F?= =?UTF-8?q?placement;=20brand=20uses=20BrandRef?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diff comments surfaced two naming concerns: 1. asset_pool_composed → responsive_creative "Responsive" is the industry-recognized term Google uses (RDA, RSA, PMax, Demand Gen). Meta uses "Advantage+ creative" / older "Dynamic Creative" for the same concept. The new name aligns with how ad-tech adopters already think about this category. Files renamed: formats/canonical/asset_pool_composed.json → responsive_creative.json ProductFormatDeclaration key renamed; cross-references in _base.json and sponsored_placement.json description updated. 2. brand_mention → agent_placement "brand_mention" overloaded with affiliate / sponsored-podcast vocabulary that predates AI surfaces by decades. Architectural property is "AI surface composes a sponsored placement in its response" — distinct from si_chat (user converses with brand-owned agent; existing SI track) and from sponsored_placement (retail-media catalog-driven). agent_placement parallels sponsored_placement structurally; both are surface-composed placements differing by surface type. formats/canonical/brand_mention.json → agent_placement.json Description updated to make si_chat distinction explicit. 3. creative-manifest.json brand → BrandRef Diff comment flagged that brand: { domain } should reference brand-ref.json (the canonical BrandRef schema). Updated to use $ref instead of inlining. BrandRef carries domain plus optional brand_id for house-of-brands plus optional industries / data_subject_contestation overrides. 4. v2-overview.mdx - Discovery row consolidated: list_creative_formats deprecated by creative.supported_formats on get_adcp_capabilities (uniform replacement regardless of agent role); sales agents additionally expose get_products - Brand identity row references BrandRef explicitly with house-of-brands note - Canonical format table updated with new names, industry-term attributions, and si_chat distinction on agent_placement Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/creative/v2-overview.mdx | 11 +++++------ static/schemas/source/core/creative-manifest.json | 13 ++----------- .../source/core/product-format-declaration.json | 4 ++-- static/schemas/source/formats/canonical/_base.json | 2 +- .../{brand_mention.json => agent_placement.json} | 6 +++--- ..._pool_composed.json => responsive_creative.json} | 6 +++--- .../formats/canonical/sponsored_placement.json | 2 +- 7 files changed, 17 insertions(+), 27 deletions(-) rename static/schemas/source/formats/canonical/{brand_mention.json => agent_placement.json} (56%) rename static/schemas/source/formats/canonical/{asset_pool_composed.json => responsive_creative.json} (81%) diff --git a/docs/creative/v2-overview.mdx b/docs/creative/v2-overview.mdx index 2aea0b28b9..f35317d659 100644 --- a/docs/creative/v2-overview.mdx +++ b/docs/creative/v2-overview.mdx @@ -18,10 +18,9 @@ v2 collapses today's separate format registry into product-bound declarations. A | Format identity | Compound `{ agent_url, id }` referencing a separately-defined format file | Canonical name (e.g., `image`) keyed under `format` on the product, narrowed inline | | Format authoring | Each platform authors its own named format files | Platforms narrow AdCP-defined canonicals; canonical IS the contract buyers validate against | | Format input contract | Each platform publishes a parallel set of `*_generated_*` format files for AI-produced creative alongside the asset-upload version (~30 duplicate files in agentic-adapters) | The format declares what it accepts via `inputs` (script, creative_brief, voice_id, starter_assets, etc.) and asset slots (image, video, audio uploads). Some products take only assets; some take only inputs; some take both. **Whether the seller's internal production is generative AI, host recording, transcoding, or asset rendering is invisible to the buyer.** No "generative" category at the protocol level; the production mechanism is implementation detail. | -| Discovery (sales) | `list_creative_formats` | `get_products` (each product carries its `format` declaration) | -| Discovery (creative agents) | `list_creative_formats` (overloaded) | `creative.supported_formats` field on `get_adcp_capabilities` (same `ProductFormatDeclaration` shape as products' inline `format`) | +| Discovery | `list_creative_formats` (overloaded — used by both sales and creative agents) | `creative.supported_formats` on `get_adcp_capabilities` (uniform replacement, same `ProductFormatDeclaration` shape regardless of agent role); sales agents additionally expose `get_products` for product-level detail with `format` inline | | Tracking | Mixed across asset types and format definitions | Baked into each canonical format (VAST events for `video_vast`, MRAID+OM-SDK for `html5`, impression pixel for `image`) | -| Brand identity | Sometimes redeclared as format slots | Implicit via `brand: { domain }` resolving brand.json; explicit override via `brand_kit_override` on the manifest | +| Brand identity | Sometimes redeclared as format slots | Implicit via `brand` (a [`BrandRef`](https://adcontextprotocol.org/schemas/v3/core/brand-ref.json) — `domain` plus optional `brand_id` for house-of-brands) resolving brand.json; explicit override via `brand_kit_override` on the manifest | ## The 11 canonical formats @@ -38,8 +37,8 @@ Each canonical lives at `/schemas/formats/canonical/.json`. Tracking model | `audio_hosted` | Direct audio file (or host-read produced via build_creative) | Standard audio impression/completion | | `audio_daast` | DAAST tag | Inherent DAAST events | | `sponsored_placement` | Retail-media catalog-driven (Amazon SP, Criteo SP, CitrusAd SP) | Per-item catalog-keyed events | -| `asset_pool_composed` | Buyer asset pool, surface composes (Google PMax, RDA, Demand Gen, Meta Advantage+) | Per-asset performance breakdown | -| `brand_mention` | Text/audio AI-surface composition (ChatGPT, voice assistants, sponsored search snippets) | Mention-level impression + attribution | +| `responsive_creative` | Buyer asset pool, surface composes combinations (Google Responsive Display/Search Ads, Performance Max, Demand Gen; Meta Advantage+ creative) | Per-asset performance breakdown | +| `agent_placement` | Sponsored placement composed by an AI surface in response to a user query (ChatGPT, Perplexity, voice assistants, sponsored search snippets). Distinct from `si_chat` (brand-owned conversation; user → brand's agent). | Mention-level impression + attribution | ## Worked example — Meta Reels @@ -284,7 +283,7 @@ The buyer can iterate on inputs and inspect previews before committing to a buy. ## Brand identity via brand.json (with override) -v2 formats no longer redeclare `brand_logo`, `brand_colors`, `brand_voice`, `brand_tagline` as explicit slots. When a manifest carries `brand: { domain: "acme.example" }`, the seller fetches `https://acme.example/.well-known/brand.json` for brand context. +v2 formats no longer redeclare `brand_logo`, `brand_colors`, `brand_voice`, `brand_tagline` as explicit slots. When a manifest carries a [`BrandRef`](https://adcontextprotocol.org/schemas/v3/core/brand-ref.json) like `brand: { domain: "acme.example" }` (or with `brand_id` for house-of-brands), the seller fetches `https://acme.example/.well-known/brand.json` for brand context. For the case where brand.json is missing or stale, the manifest includes `brand_kit_override`: diff --git a/static/schemas/source/core/creative-manifest.json b/static/schemas/source/core/creative-manifest.json index 9e205c191b..f3921b78f8 100644 --- a/static/schemas/source/core/creative-manifest.json +++ b/static/schemas/source/core/creative-manifest.json @@ -39,17 +39,8 @@ "additionalProperties": true }, "brand": { - "type": "object", - "description": "Brand identity reference. When present, the seller pulls brand context (logos, colors, voice, taglines) from the brand's brand.json automatically. v2 formats no longer redeclare brand_logo / brand_colors / brand_voice as explicit slots — brand identity is implicit context.", - "properties": { - "domain": { - "type": "string", - "format": "hostname", - "description": "Brand domain. Seller fetches https:///.well-known/brand.json for brand identity context." - } - }, - "required": ["domain"], - "additionalProperties": true + "$ref": "/schemas/core/brand-ref.json", + "description": "Brand identity reference (BrandRef — `domain` plus optional `brand_id` for house-of-brands). When present, the seller pulls brand context (logos, colors, voice, taglines) from the brand's brand.json automatically. v2 formats no longer redeclare brand_logo / brand_colors / brand_voice as explicit slots — brand identity is implicit context." }, "brand_kit_override": { "type": "object", diff --git a/static/schemas/source/core/product-format-declaration.json b/static/schemas/source/core/product-format-declaration.json index d4adf519cc..9e721a34ce 100644 --- a/static/schemas/source/core/product-format-declaration.json +++ b/static/schemas/source/core/product-format-declaration.json @@ -16,8 +16,8 @@ "audio_hosted": { "$ref": "/schemas/formats/canonical/audio_hosted.json" }, "audio_daast": { "$ref": "/schemas/formats/canonical/audio_daast.json" }, "sponsored_placement": { "$ref": "/schemas/formats/canonical/sponsored_placement.json" }, - "asset_pool_composed": { "$ref": "/schemas/formats/canonical/asset_pool_composed.json" }, - "brand_mention": { "$ref": "/schemas/formats/canonical/brand_mention.json" } + "responsive_creative": { "$ref": "/schemas/formats/canonical/responsive_creative.json" }, + "agent_placement": { "$ref": "/schemas/formats/canonical/agent_placement.json" } }, "additionalProperties": false, "examples": [ diff --git a/static/schemas/source/formats/canonical/_base.json b/static/schemas/source/formats/canonical/_base.json index 36d19b7ce5..ec9e082345 100644 --- a/static/schemas/source/formats/canonical/_base.json +++ b/static/schemas/source/formats/canonical/_base.json @@ -8,7 +8,7 @@ "composition_model": { "type": "string", "enum": ["deterministic", "algorithmic"], - "description": "Whether the surface composes deterministically (buyer can predict per-slot rendering — sponsored_placement, image, video) or algorithmically (surface chooses combinations or phrasing — asset_pool_composed, brand_mention)." + "description": "Whether the surface composes deterministically (buyer can predict per-slot rendering — sponsored_placement, image, video) or algorithmically (surface chooses combinations or phrasing — responsive_creative, agent_placement)." }, "provenance_required": { "type": "boolean", diff --git a/static/schemas/source/formats/canonical/brand_mention.json b/static/schemas/source/formats/canonical/agent_placement.json similarity index 56% rename from static/schemas/source/formats/canonical/brand_mention.json rename to static/schemas/source/formats/canonical/agent_placement.json index a12411db3e..13266cf24a 100644 --- a/static/schemas/source/formats/canonical/brand_mention.json +++ b/static/schemas/source/formats/canonical/agent_placement.json @@ -1,8 +1,8 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/formats/canonical/brand_mention.json", - "title": "Canonical Format: Brand Mention (AI-surface composition)", - "description": "Text/audio AI-surface mention. Buyer supplies brand context (via `brand: { domain }` resolving brand.json) and optional offering reference; surface composes natural-language mention or sponsored snippet. **Composition is algorithmic** — LLM/search-ranker chooses phrasing and presentation. Covers ChatGPT-style brand mentions, Perplexity sponsored answers, voice-assistant sponsored mentions, sponsored search snippets. Output asset_type varies by surface: `text` for chat UIs and search snippets; `audio` (synthesized) for voice assistants. Tracking model: mention-level impression + attribution events; per-mention id keys back to brand and offering.", + "$id": "/schemas/formats/canonical/agent_placement.json", + "title": "Canonical Format: Agent Placement (AI-surface sponsored placement)", + "description": "Sponsored placement integrated into an AI-surface's response to a user. Buyer supplies a `BrandRef` (resolving brand.json for context) and optional offering reference; the surface (LLM, voice assistant, sponsored-search ranker) composes a natural-language mention, sponsored card, or audio snippet within its response to a user query. **Composition is algorithmic** — the agent chooses phrasing and presentation. Output asset_type varies by surface: `text` for chat UIs and sponsored search snippets; `audio` (synthesized) for voice assistants; `card` for structured AI-surface result cards. Tracking model: mention-level impression + attribution events; per-mention id keys back to brand and offering. Distinct from `si_chat` (which is the user-converses-with-brand's-agent pattern — brand owns the conversational surface) and from `sponsored_placement` (retail-media catalog-driven). Parallels `sponsored_placement` structurally: both are surface-composed placements; agent_placement is for AI/agentic surfaces, sponsored_placement is for retail media.", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { "output_modality": { diff --git a/static/schemas/source/formats/canonical/asset_pool_composed.json b/static/schemas/source/formats/canonical/responsive_creative.json similarity index 81% rename from static/schemas/source/formats/canonical/asset_pool_composed.json rename to static/schemas/source/formats/canonical/responsive_creative.json index 8f2303be04..835c1d59ef 100644 --- a/static/schemas/source/formats/canonical/asset_pool_composed.json +++ b/static/schemas/source/formats/canonical/responsive_creative.json @@ -1,8 +1,8 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/formats/canonical/asset_pool_composed.json", - "title": "Canonical Format: Asset-Pool Composed", - "description": "Buyer supplies a pool of typed assets (multiple headlines, descriptions, images, videos, logos); the surface algorithmically composes combinations per placement. Slots: `headlines[]`, `descriptions[]`, `images_landscape[]`, `images_square[]`, `images_vertical[]`, `videos[]`, `logo[]`, `landing_page_url`. **Composition is algorithmic** — surface picks combinations and reports per-asset performance breakdowns. Covers Google Performance Max, Responsive Display Ads, Demand Gen, and Meta Advantage+ creative. Distinct from `sponsored_placement` (catalog-driven, deterministic) and `brand_mention` (text/audio AI composition).", + "$id": "/schemas/formats/canonical/responsive_creative.json", + "title": "Canonical Format: Responsive Creative", + "description": "Buyer supplies a pool of typed assets (multiple headlines, descriptions, images, videos, logos); the surface algorithmically composes combinations per placement. Slots: `headlines[]`, `descriptions[]`, `images_landscape[]`, `images_square[]`, `images_vertical[]`, `videos[]`, `logo[]`, `landing_page_url`. **Composition is algorithmic** — surface picks combinations and reports per-asset performance breakdowns. Covers Google Responsive Display Ads (RDA), Responsive Search Ads (RSA), Performance Max (PMax), Demand Gen, and Meta Advantage+ creative. Industry term: \"Responsive\" (Google) / \"Advantage+ creative\" (Meta) / \"Dynamic Creative\" (older Meta term). Distinct from `sponsored_placement` (catalog-driven, deterministic) and `agent_placement` (AI-surface composition).", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { "headlines_min": { "type": "integer", "minimum": 0 }, diff --git a/static/schemas/source/formats/canonical/sponsored_placement.json b/static/schemas/source/formats/canonical/sponsored_placement.json index 1de5933bcf..7d4129ee14 100644 --- a/static/schemas/source/formats/canonical/sponsored_placement.json +++ b/static/schemas/source/formats/canonical/sponsored_placement.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$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), 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. Distinct from `asset_pool_composed` (algorithmic combinator from buyer pool) and `brand_mention` (text/audio AI-surface composition).", + "description": "Catalog-driven retail-media format. Slot: `source_catalog` (catalog asset — product/SKU/ASIN/GTIN catalog reference), 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. Distinct from `responsive_creative` (algorithmic combinator from buyer pool) and `agent_placement` (text/audio AI-surface composition).", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { "supported_catalog_types": { From 202ce2e6934ac53631976b04c36f1db8262bad1c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 28 Apr 2026 11:23:17 -0400 Subject: [PATCH 11/41] feat(creative): v2 migration guide + 5 reference fixtures + product.json v1/v2 oneOf + fixture validation test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 deliverables for upstream implementor review: **Migration guide** at docs/creative/v2-migration.mdx (~1500 words): - Side-by-side v1 named format → v2 product format declaration - Slot name mapping table (v1 author-invented → canonical asset_group_id) - Generative format dissolution (~30 *_generated_* files collapse into format inputs) - Brand identity slots → BrandRef + brand_kit_override - Discovery surface migration (list_creative_formats → get_products + creative.supported_formats with server-side flatten wrapper guidance) - Adopter migration paths per role (sales agent / creative agent / buyer / publisher direct) **5 reference fixtures** at static/examples/products/v2/, fully-valid Product objects that pass strict schema validation: - meta_reels_us.json (video_hosted, vertical orientation, Meta-specific extensions) - nytimes_homepage_mrec.json (image, IAB MREC 300x250) - nytimes_homepage_html5.json (html5, sibling product on same placement — different canonical because different tracking model) - the_daily_30s_host_read.json (audio_hosted with inline inputs: script + brand + offering_ref; production_window_business_days: 7) - amazon_sponsored_products.json (sponsored_placement, catalog-driven, ASIN-keyed) **Schema fix on product.json**: format_ids was unconditionally required at the schema level, blocking v2 products. Restructured to oneOf: - v1 branch: requires format_ids (named-format reference path) - v2 branch: requires format (inline ProductFormatDeclaration path) A product is one or the other, not both. **New test:v2-fixtures script** at tests/v2-fixture-validation.test.cjs. Validates all fixtures in static/examples/products/v2/ against /schemas/core/product.json. Wired into package.json scripts. **Schema validation test update**: replaced static format_ids assertion on product.json with explicit oneOf(format_ids, format) check that matches the new shape. **Doc cross-link**: v2-overview.mdx Related section links to v2-migration. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/creative/v2-migration.mdx | 283 ++++++++++++++++++ docs/creative/v2-overview.mdx | 1 + package.json | 1 + .../v2/amazon_sponsored_products.json | 42 +++ .../examples/products/v2/meta_reels_us.json | 58 ++++ .../products/v2/nytimes_homepage_html5.json | 48 +++ .../products/v2/nytimes_homepage_mrec.json | 48 +++ .../products/v2/the_daily_30s_host_read.json | 52 ++++ static/schemas/source/core/product.json | 13 +- tests/schema-validation.test.cjs | 16 +- tests/v2-fixture-validation.test.cjs | 115 +++++++ 11 files changed, 675 insertions(+), 2 deletions(-) create mode 100644 docs/creative/v2-migration.mdx create mode 100644 static/examples/products/v2/amazon_sponsored_products.json create mode 100644 static/examples/products/v2/meta_reels_us.json create mode 100644 static/examples/products/v2/nytimes_homepage_html5.json create mode 100644 static/examples/products/v2/nytimes_homepage_mrec.json create mode 100644 static/examples/products/v2/the_daily_30s_host_read.json create mode 100644 tests/v2-fixture-validation.test.cjs diff --git a/docs/creative/v2-migration.mdx b/docs/creative/v2-migration.mdx new file mode 100644 index 0000000000..2502d9774d --- /dev/null +++ b/docs/creative/v2-migration.mdx @@ -0,0 +1,283 @@ +--- +title: Creative Formats v1 → v2 Migration +description: "Concrete migration paths for sellers, creative agents, buyers, and tooling moving from v1 named formats to v2 product-bound declarations." +"og:title": "AdCP — Creative Formats v1 → v2 Migration" +testable: true +--- + +# Migration: v1 → v2 Creative Formats + +This guide walks through the shift from v1 named formats (`format_id` as `{ agent_url, id }` referencing a separately-defined format file) to v2 product-bound declarations introduced by [RFC #3305](https://github.com/adcontextprotocol/adcp/issues/3305). v1 named formats remain a first-class path through 4.x; v2 is the new path, opt-in indefinitely. + +For the architecture, read [`v2-overview`](/docs/creative/v2-overview) first. This page is just the migration mechanics. + +## What stays unchanged + +Most of AdCP doesn't change. v2 builds on the existing primitives: + +- All asset primitive schemas (`image-asset.json`, `video-asset.json`, `audio-asset.json`, `vast-asset.json`, `daast-asset.json`) — unchanged +- Catalog and offering schemas — unchanged +- Manifest envelope shape (`creative-manifest.json` keyed by `assets` map) — unchanged +- Response envelopes, error schemas, common types — unchanged +- v1 named formats (`format.json` with compound `format_id`) — still supported through 4.x +- v1 `list_creative_formats` tool — deprecated but functional through 4.x; removed at 5.0 +- All existing producers and consumers — continue to work without changes + +## Side-by-side: a v1 named format → v2 product format declaration + +### v1 — separate format file referenced by product + +```json test=false +// formats/meta_reels.json +{ + "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "meta_reels" }, + "name": "Meta Reels", + "type": "video", + "assets": [ + { + "asset_id": "video_file", + "asset_type": "video", + "asset_role": "hero_video", + "item_type": "individual", + "required": true, + "requirements": { + "min_duration_ms": 3000, + "max_duration_ms": 90000, + "aspect_ratio": "9:16", + "min_width": 1080, + "min_height": 1920, + "containers": ["mp4"], + "codecs": ["h264"] + } + } + ] +} + +// products/meta_reels_us.json +{ + "product_id": "meta_reels_us", + "name": "Meta Reels — United States", + "format_ids": [ + { "agent_url": "https://creative.adcontextprotocol.org", "id": "meta_reels" } + ], + // ... pricing, targeting, etc. +} +``` + +### v2 — inline format declaration on the product + +```json test=false +{ + "product_id": "meta_reels_us", + "name": "Meta Reels — United States", + "format": { + "video_hosted": { + "orientation": "vertical", + "aspect_ratio": "9:16", + "duration_ms_range": [3000, 90000], + "min_width": 1080, + "min_height": 1920, + "video_codecs": ["h264"], + "containers": ["mp4"], + "headline_max_chars": 25, + "primary_text_max_chars": 72, + "cta_values": ["LEARN_MORE", "SHOP_NOW", "DOWNLOAD", "SIGN_UP"], + "composition_model": "deterministic", + "platform_extensions": [ + { "uri": "https://meta.adcp/extensions/meta_pixel", "digest": "sha256:..." } + ] + } + }, + // ... pricing, targeting, etc. +} +``` + +A v2 product is **either** v1 (uses `format_ids`) **or** v2 (uses `format`) — not both. The product schema's `oneOf` enforces this. + +For five fully-validated worked examples (Meta Reels, IAB MREC, NYTimes HTML5, podcast host-read, Amazon Sponsored Products), see `static/examples/products/v2/`. Each fixture passes `npm run test:v2-fixtures`. + +## Slot name mapping (v1 → canonical) + +If a v1 format slot uses an author-invented name that the canonical vocabulary covers, the format declaration carries an optional `asset_group_id` field on the slot pointing at the canonical entry. Same as the existing `asset_role` field, but referencing the [canonical vocabulary](https://adcontextprotocol.org/schemas/v3/core/asset-group-vocabulary.json) rather than free text. + +```json test=false +// v1 format with author-invented slot name +{ + "asset_id": "click_url", + "asset_type": "url", + "item_type": "individual", + "required": true +} + +// Same v1 format with canonical pointer (additive — backwards-compatible) +{ + "asset_id": "click_url", + "asset_type": "url", + "asset_group_id": "landing_page_url", + "item_type": "individual", + "required": true +} +``` + +The vocabulary registry's `aliases` field captures common v1 alias names per canonical entry (e.g., `landing_page_url` aliases include `click_url`, `link`, `final_url`, `link_url`, `click_through_url`, `landing_url`). Six different names for the same field collapse to one canonical. + +Common alias mappings (from the audit-grounded set in `asset-group-vocabulary.json`): + +| Canonical | Common v1 aliases | +|---|---| +| `headlines` | `headline`, `title`, `tagline`, `headline_text` | +| `descriptions` | `description`, `body`, `body_text`, `text`, `content` | +| `images_landscape` | `image`, `hero_image`, `landscape_image`, `banner_image` | +| `images_vertical` | `vertical_image`, `story_image`, `portrait_image` | +| `images_square` | `square_image`, `feed_image` | +| `logo` | `brand_logo`, `logo_image` | +| `video` | `video_file`, `hero_video`, `video_asset`, `video_main` | +| `audio` | `audio_file`, `hero_audio`, `audio_asset`, `audio_main` | +| `landing_page_url` | `click_url`, `link`, `final_url`, `link_url`, `click_through_url`, `landing_url` | + +## Discovery surface migration + +`list_creative_formats` is uniformly deprecated. Replacements: + +| Role | v1 path | v2 path | +|---|---|---| +| Sales agent | `list_creative_formats` returns the seller's accepted formats | `get_products` — each product carries its `format` declaration inline. Optionally also `creative.supported_formats` on `get_adcp_capabilities` for a flat summary. | +| Creative agent (no inventory) | `list_creative_formats` overloaded as "what I can produce" | `creative.supported_formats` on `get_adcp_capabilities`. Each entry uses the same `ProductFormatDeclaration` shape as products' inline `format`. | + +Sellers SHOULD provide a server-side flatten wrapper that derives the v1 `list_creative_formats` shape from v2 product format declarations through 4.0, so existing dashboards and tooling keep working. The wrapper iterates over `get_products`, reads each product's `format` declaration, and emits a v1-compatible format file plus a `format_ids` reference. + +## Generative formats — `*_generated_*` files dissolve + +The agentic-adapters audit found ~30 `*_generated_*` format files (e.g., `meta_generated_reels`, `tiktok_generated_video_9x16`) that mirror their non-generated counterparts but accept a `creative_brief` instead of an asset upload. In v2 these collapse: + +- The format declaration's `inputs` field describes what the buyer provides (script, creative_brief, voice_id, starter_assets, scenes, etc.) +- Whether the seller's internal production is generative AI, host recording, transcoding, or asset rendering is **invisible to the buyer** +- A single canonical format (e.g., `audio_hosted`) handles both buyer-uploaded audio and agent-produced audio; the format's `audio_source` and `buyer_audio_acceptance` parameters describe which flows are accepted + +Side-by-side for an audio format: + +```json test=false +// v1: separate generated format file +{ + "format_id": { "agent_url": "...", "id": "audiostack_audio_30s_generated" }, + "name": "AudioStack 30s Audio (Generated)", + "assets": [ + { + "asset_id": "creative_brief", + "asset_type": "brief", + "required": true + }, + { + "asset_id": "audio_output", + "asset_type": "audio", + "required": false + } + ] +} + +// v2: same canonical (audio_hosted), inputs declared inline +{ + "audio_hosted": { + "duration_ms_exact": 30000, + "audio_codecs": ["mp3"], + "loudness_lufs": -16, + "audio_source": "agent_synthesized", + "buyer_audio_acceptance": "rejected", + "inputs": { + "creative_brief": { "required": true, "max_chars": 1000 }, + "voice_id": { "required": false } + }, + "production_window_business_days": 0 + } +} +``` + +## Brand identity — slots disappear + +v1 formats sometimes redeclared `brand_logo`, `brand_colors`, `brand_voice`, `brand_tagline` as explicit slots. v2 formats don't. When the manifest carries a [`BrandRef`](https://adcontextprotocol.org/schemas/v3/core/brand-ref.json) (`brand: { domain: "acme.com" }`, optionally with `brand_id` for house-of-brands), the seller fetches `brand.json` for context automatically. + +For the case where `brand.json` is missing or stale, the manifest carries `brand_kit_override`: + +```json test=false +{ + "format_id": { "agent_url": "...", "id": "..." }, + "assets": { ... }, + "brand": { "domain": "acme.example" }, + "brand_kit_override": { + "logo": { "asset_type": "image", "url": "https://cdn.acme.example/logo-2026.png", "width": 200, "height": 100 }, + "colors": { "primary": "#0066CC", "accent": "#FF6600" }, + "tagline": "Spring savings, all season" + } +} +``` + +Override fields take precedence over `brand.json` for that creative. + +## Tools — what's new vs unchanged + +| Tool | v1 | v2 | +|---|---|---| +| `get_products` | Returns products with `format_ids` | Returns products with either `format_ids` (v1 path) or `format` (v2 inline) | +| `sync_creatives` | Submit creative manifest | Unchanged. Sales agents accept manifests with assets and/or inputs per the format declaration. | +| `preview_creative` | Submit manifest, get preview | Same surface; preview shows output regardless of asset-driven vs input-driven submission. The single-render hoist in #3268 lands alongside v2. | +| `validate_input` | (didn't exist) | New buyer dry-run primitive. Validates a manifest against canonical formats and/or specific products without committing to a render. Cheap; `predicted` field carries pre-flight estimates. | +| `build_creative` | Generative tool on creative agents | Same role; creative-agent surface only. Sales agents do **not** expose `build_creative`. Creative agents may **also** expose `sync_creatives` for ad-server use cases. | +| `list_creative_formats` | Both sales and creative agents | Deprecated. Sales agents migrate to `get_products`; creative agents to `creative.supported_formats`. v1 tool stays functional through 4.x. | + +## Adopter migration paths + +### Sales agents (DSPs, SSPs, retail media networks, walled gardens) + +1. **Inventory**: enumerate your existing v1 named formats. Confirm each maps to one of the 11 v2 canonicals. +2. **Translate**: for each named format, write a v2 `ProductFormatDeclaration` narrowing the canonical with your platform's parameters. +3. **Test**: validate translated declarations against `/schemas/core/product.json` (use the `npm run test:v2-fixtures` pattern). +4. **Publish dual**: keep your v1 named formats and `list_creative_formats` working through 4.x. Add the v2 `format` field on products that have it. +5. **Flatten wrapper**: implement a server-side wrapper that derives the v1 `list_creative_formats` shape from v2 product declarations. Lets v1-era dashboards and tooling keep working. +6. **Deprecate timing**: at 5.0, remove v1 `format_ids` references on your products. Until then, both paths coexist. + +### Creative agents (transformation services like AudioStack, generative platforms) + +1. Add `creative.supported_formats` to your `get_adcp_capabilities` response. Each entry is a `ProductFormatDeclaration` describing what canonical format you can produce, with what parameters and `inputs`. +2. Continue to support `build_creative` per the existing v1 contract — it's the same tool with the same shape. +3. If you're also acting as an ad server (rare but valid), expose `sync_creatives` alongside `build_creative`. + +### Buyers / DSPs + +1. Update your client to read either `format_ids` (v1) or `format` (v2 inline) on products. +2. Use `validate_input` for cheap dry-run validation before committing to renders. +3. Use the canonical [`asset_group_id` vocabulary](https://adcontextprotocol.org/schemas/v3/core/asset-group-vocabulary.json) when constructing manifests; the registry's `aliases` field maps v1-era slot names to canonical equivalents. +4. Submit creative via `sync_creatives` as before. For input-driven products (host-read podcasts, generative video), either pre-produce via a creative agent's `build_creative` OR submit inputs directly and let the seller produce internally — both flows are valid. + +### Publisher direct (GAM/prebid path) + +1. The `zip` asset type (Phase 1) handles HTML5 banner bundles cleanly. URL-delivered HTML/JS routes through `url-asset.json` with appropriate `url_type`. +2. Tag-based delivery (VAST, third-party display tags) maps to the `display_tag`, `video_vast`, and `audio_daast` canonical formats. +3. Native canonical format is deferred to 3.2 after the TemplateCreative + OpenRTB Native 1.2 audit; until then, native formats stay on the v1 path. + +## Realistic timeline by adopter type + +| Adopter | Cost | Realistic timeline | +|---|---|---| +| DSP buyer agents (TTD-shaped) | Low | 3.1-3.2 | +| SSP/sales agents (Magnite, PubMatic, retail media) | Medium-high | 3.3-4.0 | +| Walled gardens (Meta, Google, Amazon, TikTok, Snap, Pinterest) | High, low motivation | 4.0-5.0 if at all (gated on AAO providing a translator from existing format docs) | +| Creative agents (AudioStack-shaped) | Low, high motivation | 3.1-3.2 | +| Publisher direct (GAM/prebid path) | Medium | Blocked on native canonical pre-audit | + +## Validating your migration + +Run the fixture validation against your translated products: + +```bash test=false +npm run test:v2-fixtures +``` + +The reference fixtures at `static/examples/products/v2/` are validated against `/schemas/core/product.json`. Adopters can drop their own translated products into a sibling directory and reuse the same validator pattern. + +## Related + +- [Creative Formats v2 overview](/docs/creative/v2-overview) +- [RFC #3305](https://github.com/adcontextprotocol/adcp/issues/3305) — architectural decisions and rationale +- [Asset group vocabulary](https://adcontextprotocol.org/schemas/v3/core/asset-group-vocabulary.json) +- [BrandRef schema](https://adcontextprotocol.org/schemas/v3/core/brand-ref.json) +- [Universal macros](/docs/creative/universal-macros) — substitution patterns referenced from canonical tracking diff --git a/docs/creative/v2-overview.mdx b/docs/creative/v2-overview.mdx index f35317d659..f654ae167d 100644 --- a/docs/creative/v2-overview.mdx +++ b/docs/creative/v2-overview.mdx @@ -370,6 +370,7 @@ By design, v2 doesn't introduce new vocabulary for things AdCP already handles o ## Related +- [v1 → v2 migration guide](/docs/creative/v2-migration) — concrete migration paths for sellers, creative agents, buyers, and publisher-direct integrations - [RFC #3305](https://github.com/adcontextprotocol/adcp/issues/3305) — v2 architecture decisions and rationale - [PR #3307](https://github.com/adcontextprotocol/adcp/pull/3307) — Phase 1 + Phase 2 implementation, on hold pending 3.1.0 beta cycle - [Asset group vocabulary](https://adcontextprotocol.org/schemas/v3/core/asset-group-vocabulary.json) — canonical slot-name registry diff --git a/package.json b/package.json index 025ff191af..e815365676 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test:extension-schemas": "node tests/extension-schemas.test.cjs", "test:snippets": "node tests/snippet-validation.test.cjs", "test:json-schema": "node tests/json-schema-validation.test.cjs", + "test:v2-fixtures": "node tests/v2-fixture-validation.test.cjs", "test:error-handling": "node tests/check-error-handling.cjs", "test:composed": "node tests/composed-schema-validation.test.cjs", "test:migrations": "node tests/migration-validation.test.cjs", diff --git a/static/examples/products/v2/amazon_sponsored_products.json b/static/examples/products/v2/amazon_sponsored_products.json new file mode 100644 index 0000000000..3005d58d6d --- /dev/null +++ b/static/examples/products/v2/amazon_sponsored_products.json @@ -0,0 +1,42 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "amazon_sp_search", + "name": "Amazon Sponsored Products — Search", + "description": "Catalog-driven sponsored product placement in Amazon search results. Buyer supplies a product catalog with ASINs; Amazon's surface composes per-item rendering (product image + title + price + rating + Prime badge) using its native placement template. Composition is deterministic — buyer can predict per-slot rendering from the catalog item structure. No buyer creative slots; the catalog reference is the entire input.", + "publisher_properties": [ + { + "publisher_domain": "amazon.com", + "selection_type": "all" + } + ], + "channels": ["retail_media"], + "format": { + "sponsored_placement": { + "supported_catalog_types": ["product"], + "min_items": 1, + "max_items": 50, + "fanout_mode": "per_item", + "required_catalog_fields": ["title", "image_url", "price"], + "supported_id_types": ["asin"], + "hero_asset_supported": false, + "composition_model": "deterministic" + } + }, + "delivery_type": "non_guaranteed", + "pricing_options": [ + { + "pricing_option_id": "cpc_auction", + "pricing_model": "cpc", + "currency": "USD", + "floor_price": 0.50 + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": ["hourly", "daily"], + "expected_delay_minutes": 60, + "timezone": "America/Los_Angeles", + "supports_webhooks": true, + "available_metrics": ["impressions", "clicks", "spend", "ctr", "conversions", "conversion_value", "roas", "new_to_brand_rate"], + "date_range_support": "date_range" + } +} diff --git a/static/examples/products/v2/meta_reels_us.json b/static/examples/products/v2/meta_reels_us.json new file mode 100644 index 0000000000..f5f66b05b7 --- /dev/null +++ b/static/examples/products/v2/meta_reels_us.json @@ -0,0 +1,58 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "meta_reels_us", + "name": "Meta Reels — United States", + "description": "9:16 vertical short-form video on Meta Reels (Facebook + Instagram). Buyers upload H.264 mp4 (3-90s) plus headline and primary text; Meta serves to Reels feeds with placement-native UI overlays and Meta-specific tracking via the meta_pixel extension.", + "publisher_properties": [ + { + "publisher_domain": "meta.com", + "selection_type": "all" + } + ], + "channels": ["social"], + "format": { + "video_hosted": { + "orientation": "vertical", + "aspect_ratio": "9:16", + "duration_ms_range": [3000, 90000], + "min_width": 1080, + "min_height": 1920, + "max_file_size_mb": 200, + "video_codecs": ["h264"], + "audio_codecs": ["aac"], + "containers": ["mp4"], + "headline_max_chars": 25, + "primary_text_max_chars": 72, + "captions": "recommended", + "cta_values": ["LEARN_MORE", "SHOP_NOW", "DOWNLOAD", "SIGN_UP"], + "composition_model": "deterministic", + "platform_extensions": [ + { + "uri": "https://meta.adcp/extensions/meta_pixel", + "digest": "sha256:a3f5b7c9d8e2f1a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2" + }, + { + "uri": "https://meta.adcp/extensions/meta_placements_reels", + "digest": "sha256:b8e1d4f6a9c2b5e8d1f4a7c0b3e6d9f2a5c8b1e4d7f0a3c6b9e2d5f8a1c4b7e0" + } + ] + } + }, + "delivery_type": "non_guaranteed", + "pricing_options": [ + { + "pricing_option_id": "cpm_floor", + "pricing_model": "cpm", + "currency": "USD", + "floor_price": 5.50 + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": ["hourly", "daily"], + "expected_delay_minutes": 60, + "timezone": "America/Los_Angeles", + "supports_webhooks": true, + "available_metrics": ["impressions", "clicks", "spend", "completion_rate", "viewability"], + "date_range_support": "date_range" + } +} diff --git a/static/examples/products/v2/nytimes_homepage_html5.json b/static/examples/products/v2/nytimes_homepage_html5.json new file mode 100644 index 0000000000..5841d395bf --- /dev/null +++ b/static/examples/products/v2/nytimes_homepage_html5.json @@ -0,0 +1,48 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "nytimes_homepage_html5", + "name": "NYTimes.com Homepage HTML5 Banner (300×250)", + "description": "IAB Medium Rectangle (300×250) interactive HTML5 banner placement on the NYTimes.com homepage. Buyers upload an HTML5 zip bundle (≤200KB initial load, ≤500KB polite-load with host_initiated_subload, max 30s animation, OM-SDK + clickTag macro). Different canonical from the static image MREC because the tracking model is fundamentally different (MRAID + OM-SDK vs impression pixel + click URL).", + "publisher_properties": [ + { + "publisher_domain": "nytimes.com", + "selection_type": "by_id", + "property_ids": ["homepage_above_fold"] + } + ], + "channels": ["display"], + "format": { + "html5": { + "width": 300, + "height": 250, + "max_initial_load_kb": 200, + "max_polite_load_kb": 500, + "host_initiated_subload": true, + "max_animation_duration_ms": 30000, + "max_cpu_load_percent": 30, + "om_sdk_required": true, + "clicktag_macro": "clickTag", + "backup_image_required": true, + "backup_image_max_size_kb": 50, + "ssl_required": true, + "composition_model": "deterministic" + } + }, + "delivery_type": "guaranteed", + "pricing_options": [ + { + "pricing_option_id": "cpm_homepage_html5", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 28.00 + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": ["daily"], + "expected_delay_minutes": 240, + "timezone": "America/New_York", + "supports_webhooks": false, + "available_metrics": ["impressions", "clicks", "spend", "ctr", "viewability", "engagement_rate"], + "date_range_support": "date_range" + } +} diff --git a/static/examples/products/v2/nytimes_homepage_mrec.json b/static/examples/products/v2/nytimes_homepage_mrec.json new file mode 100644 index 0000000000..9534d99006 --- /dev/null +++ b/static/examples/products/v2/nytimes_homepage_mrec.json @@ -0,0 +1,48 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "nytimes_homepage_mrec", + "name": "NYTimes.com Homepage MREC (300×250)", + "description": "IAB Medium Rectangle (300×250) static image placement on the NYTimes.com homepage above the fold. Buyers upload jpg/png/gif up to 200KB; HTTPS-only; standard impression pixel + click URL tracking via universal_macros plus IAB Open Measurement viewability via the nytimes_om_strict extension.", + "publisher_properties": [ + { + "publisher_domain": "nytimes.com", + "selection_type": "by_id", + "property_ids": ["homepage_above_fold"] + } + ], + "channels": ["display"], + "format": { + "image": { + "width": 300, + "height": 250, + "max_file_size_kb": 200, + "image_formats": ["jpg", "png", "gif"], + "ssl_required": true, + "cta_values": ["LEARN_MORE", "SHOP_NOW", "GET_OFFER"], + "composition_model": "deterministic", + "platform_extensions": [ + { + "uri": "https://nytimes.adcp/extensions/nytimes_om_strict", + "digest": "sha256:c9d2f5b8e1a4c7b0e3d6f9a2c5b8e1d4f7a0c3b6e9d2f5a8c1b4e7d0f3a6c9b2" + } + ] + } + }, + "delivery_type": "guaranteed", + "pricing_options": [ + { + "pricing_option_id": "cpm_homepage_mrec", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 22.00 + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": ["daily"], + "expected_delay_minutes": 240, + "timezone": "America/New_York", + "supports_webhooks": false, + "available_metrics": ["impressions", "clicks", "spend", "ctr", "viewability"], + "date_range_support": "date_range" + } +} diff --git a/static/examples/products/v2/the_daily_30s_host_read.json b/static/examples/products/v2/the_daily_30s_host_read.json new file mode 100644 index 0000000000..7326e42bfc --- /dev/null +++ b/static/examples/products/v2/the_daily_30s_host_read.json @@ -0,0 +1,52 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "the_daily_30s_host_read_us", + "name": "The Daily — 30s Host-Read Pre-roll (US)", + "description": "30-second podcast host-read pre-roll on The Daily. Buyer-uploaded audio is rejected (audio_source: publisher_host_recorded); buyer submits a verbatim script (≤800 chars) plus brand context, the publisher's host records the audio, and the audio is dynamically inserted at podcast playback time. 7-business-day production turnaround. Same target format (audio_hosted) as a brief-driven host-read product would use, but with `script` as the required input rather than `creative_brief`.", + "publisher_properties": [ + { + "publisher_domain": "thedailypod.example", + "selection_type": "all" + } + ], + "channels": ["podcast"], + "format": { + "audio_hosted": { + "duration_ms_exact": 30000, + "audio_codecs": ["mp3", "aac"], + "audio_sample_rates": [44100, 48000], + "audio_channels": ["stereo"], + "loudness_lufs": -16, + "audio_source": "publisher_host_recorded", + "buyer_audio_acceptance": "rejected", + "composition_model": "deterministic", + "inputs": { + "script": { + "required": true, + "max_chars": 800, + "description": "Verbatim script the host will read. Exact wording — no improvisation; legal pre-cleared." + }, + "brand": { "required": true }, + "offering_ref": { "required": false } + }, + "production_window_business_days": 7 + } + }, + "delivery_type": "guaranteed", + "pricing_options": [ + { + "pricing_option_id": "cpm_host_read", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 35.00 + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": ["daily"], + "expected_delay_minutes": 1440, + "timezone": "America/New_York", + "supports_webhooks": false, + "available_metrics": ["impressions", "spend", "completion_rate", "completed_views"], + "date_range_support": "date_range" + } +} diff --git a/static/schemas/source/core/product.json b/static/schemas/source/core/product.json index b9301facb0..b0991deec1 100644 --- a/static/schemas/source/core/product.json +++ b/static/schemas/source/core/product.json @@ -453,10 +453,21 @@ "name", "description", "publisher_properties", - "format_ids", "delivery_type", "pricing_options", "reporting_capabilities" ], + "oneOf": [ + { + "title": "v1 Product (named-format reference)", + "description": "Product references one or more named formats by structured format_id ({ agent_url, id }). The v1 path; remains supported through 4.x.", + "required": ["format_ids"] + }, + { + "title": "v2 Product (inline format declaration)", + "description": "Product carries an inline ProductFormatDeclaration narrowing exactly one canonical format. The v2 path introduced by RFC #3305.", + "required": ["format"] + } + ], "additionalProperties": true } diff --git a/tests/schema-validation.test.cjs b/tests/schema-validation.test.cjs index fd80c951d7..01314f615d 100644 --- a/tests/schema-validation.test.cjs +++ b/tests/schema-validation.test.cjs @@ -279,7 +279,8 @@ async function runTests() { await test('Core schemas have appropriate required fields', () => { const coreSchemas = schemas.filter(([path]) => path.includes('/core/')); const requiredFieldChecks = { - 'product.json': ['product_id', 'name', 'description', 'format_ids', 'delivery_type'], + // product.json: format_ids OR format is required (v1 OR v2 path) — checked separately below + 'product.json': ['product_id', 'name', 'description', 'delivery_type'], 'media-buy.json': ['media_buy_id', 'status', 'total_budget', 'packages'], 'package.json': ['package_id'], 'creative-asset.json': ['creative_id', 'name', 'format_id', 'assets'], @@ -299,6 +300,19 @@ async function runTests() { } } } + + // product.json: assert v1 (format_ids) OR v2 (format) is required via oneOf + const productEntry = coreSchemas.find(([p]) => path.basename(p) === 'product.json'); + if (productEntry) { + const [, productSchema] = productEntry; + const oneOf = productSchema.oneOf || []; + const hasV1Branch = oneOf.some((branch) => (branch.required || []).includes('format_ids')); + const hasV2Branch = oneOf.some((branch) => (branch.required || []).includes('format')); + if (!hasV1Branch || !hasV2Branch) { + return `product.json: must have a oneOf with v1 branch (required: ["format_ids"]) and v2 branch (required: ["format"]); found v1=${hasV1Branch}, v2=${hasV2Branch}`; + } + } + return true; }); diff --git a/tests/v2-fixture-validation.test.cjs b/tests/v2-fixture-validation.test.cjs new file mode 100644 index 0000000000..7239986343 --- /dev/null +++ b/tests/v2-fixture-validation.test.cjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * v2 Reference Fixture Validation Test + * + * Validates the reference Product fixtures at static/examples/products/v2/*.json + * against /schemas/core/product.json. These fixtures are the "does it really + * work?" check for the v2 RFC (#3305) — concrete fully-valid Product objects + * that adopters and tooling can validate against. + * + * Run: npm run test:v2-fixtures + */ + +const Ajv = require('ajv').default; +const addFormats = require('ajv-formats').default; +const fs = require('fs'); +const path = require('path'); + +const SCHEMAS_DIR = path.resolve(__dirname, '../static/schemas/source'); +const FIXTURES_DIR = path.resolve(__dirname, '../static/examples/products/v2'); + +const RED = '\x1b[31m'; +const GREEN = '\x1b[32m'; +const RESET = '\x1b[0m'; + +function loadAllSchemas(ajv) { + function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.name.endsWith('.json')) { + try { + const schema = JSON.parse(fs.readFileSync(full, 'utf8')); + if (schema.$id) { + try { + ajv.addSchema(schema, schema.$id); + } catch (e) { + // already added + } + } + } catch (e) { + // skip non-schema or malformed JSON + } + } + } + } + walk(SCHEMAS_DIR); +} + +function main() { + const ajv = new Ajv({ + allErrors: true, + strict: false, + discriminator: true, + }); + addFormats(ajv); + loadAllSchemas(ajv); + + const validate = ajv.getSchema('/schemas/core/product.json'); + if (!validate) { + console.error(`${RED}ERROR:${RESET} could not load /schemas/core/product.json from ${SCHEMAS_DIR}`); + process.exit(2); + } + + if (!fs.existsSync(FIXTURES_DIR)) { + console.error(`${RED}ERROR:${RESET} fixtures directory not found: ${FIXTURES_DIR}`); + process.exit(2); + } + + const fixtures = fs + .readdirSync(FIXTURES_DIR) + .filter((f) => f.endsWith('.json')) + .sort(); + + if (fixtures.length === 0) { + console.error(`${RED}ERROR:${RESET} no fixtures found in ${FIXTURES_DIR}`); + process.exit(2); + } + + console.log('v2 Reference Fixture Validation'); + console.log('================================'); + console.log(`Schema: /schemas/core/product.json`); + console.log(`Fixtures: ${FIXTURES_DIR}`); + console.log(''); + + let pass = 0; + let fail = 0; + + for (const f of fixtures) { + const full = path.join(FIXTURES_DIR, f); + const fixture = JSON.parse(fs.readFileSync(full, 'utf8')); + const valid = validate(fixture); + if (valid) { + console.log(` ${GREEN}✓${RESET} ${f}`); + pass++; + } else { + console.log(` ${RED}✗${RESET} ${f}`); + for (const err of (validate.errors || []).slice(0, 10)) { + console.log(` ${err.instancePath || '(root)'}: ${err.message}`); + } + fail++; + } + } + + console.log(''); + if (fail === 0) { + console.log(`${GREEN}✅ All ${pass} v2 reference fixtures validate against the canonical Product schema.${RESET}`); + process.exit(0); + } else { + console.log(`${RED}❌ ${fail} fixture(s) failed validation; ${pass} passed.${RESET}`); + process.exit(1); + } +} + +main(); From 77d69b0f7a07f1977b7259efd5eb84ef4c2f7db8 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 28 Apr 2026 14:25:35 -0400 Subject: [PATCH 12/41] =?UTF-8?q?fix(creative):=20address=20review=20feedb?= =?UTF-8?q?ack=20=E2=80=94=20doc/schema=20mismatches,=20missing=20fixtures?= =?UTF-8?q?,=20vocabulary=20gaps,=20slot=20declarations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementor review of #3307 surfaced concrete issues. All addressed: **Doc/schema mismatches (must-fix before adopters touch this):** - v2-overview.mdx: 'web' → 'display' on channels (web is not in the channels enum) - v2-overview.mdx: publisher_property_selector shape — replaced { type: "publisher", publisher_domain } with the schema-correct { publisher_domain, selection_type: "all" } across all four worked examples - audio_hosted.json description: removed stale build_capability_ref reference; reframed around inline inputs convention - product-format-declaration.json: host-read example description and data updated to use inline inputs (no build_capability_ref) **Missing fixtures (5/11 → 7/11 canonical coverage):** - google_performance_max.json — responsive_creative narrowing for Google PMax with full asset-pool min/max declarations and CPA pricing - chatgpt_brand_mention.json — agent_placement with text output, brand + offering_ref inputs, disclosure_required, advisory tone constraints **Vocabulary additions (filling gaps reviewer flagged):** - cta — call-to-action button label slot - price — product price slot for retail / shopping creative - disclaimer — legal disclaimer / fine print slot (regulated verticals) - phone_number — click-to-call slot - promo_code — coupon / offer code slot - subtitle_file — caption file URL slot - source_catalog — catalog reference slot for sponsored_placement - hero_asset — buyer-supplied hero/banner alongside catalog - Aliases added: cards (carousel_cards, slides, etc.), youtube_video_id (existing_yt_video_id, etc.) - Vocabulary header now explicitly defers input names — inputs use separate convention not yet canonicalized at spec level **Programmatic slot declarations:** - _base.json: new optional `slots` field — array of { asset_group_id, required, min, max } entries letting SDK codegen and validators enumerate format slots without parsing prose descriptions - responsive_creative: default slots array enumerates 9 canonical slots - agent_placement: default slots array (just landing_page_url since the format is composed-by-surface) **Other clarifications:** - _base.json synthesis_nondeterministic boolean — distinct axis from composition_model; covers Veo/Sora/Runway-class where predictive validate_input is impossible and the platform's own QA loop applies - agent_placement.tone_constraints — explicitly marked advisory (LLM/agentic surfaces have no protocol mechanism to enforce) - _base.json tracking_extensions description — clarified relationship to platform_extensions (subset for buyer convenience, not enforcement) Vocabulary version bumped 1.0.0 → 1.1.0; lastUpdated 2026-04-26 → 2026-04-28. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/creative/v2-overview.mdx | 12 ++-- .../products/v2/chatgpt_brand_mention.json | 57 +++++++++++++++ .../products/v2/google_performance_max.json | 70 +++++++++++++++++++ .../source/core/asset-group-vocabulary.json | 57 +++++++++++++-- .../core/product-format-declaration.json | 9 ++- .../source/formats/canonical/_base.json | 37 +++++++++- .../formats/canonical/agent_placement.json | 9 ++- .../formats/canonical/audio_hosted.json | 2 +- .../canonical/responsive_creative.json | 15 +++- 9 files changed, 250 insertions(+), 18 deletions(-) create mode 100644 static/examples/products/v2/chatgpt_brand_mention.json create mode 100644 static/examples/products/v2/google_performance_max.json diff --git a/docs/creative/v2-overview.mdx b/docs/creative/v2-overview.mdx index f654ae167d..f800b2ce6b 100644 --- a/docs/creative/v2-overview.mdx +++ b/docs/creative/v2-overview.mdx @@ -49,7 +49,7 @@ Meta Reels narrows `video_hosted` (vertical orientation) with Meta-specific para "product_id": "meta_reels_us", "name": "Meta Reels — United States", "publisher_properties": [ - { "type": "publisher", "publisher_domain": "meta.com" } + { "publisher_domain": "meta.com", "selection_type": "all" } ], "channels": ["social"], "format": { @@ -96,9 +96,9 @@ The canonical-as-contract value is clearest for IAB-standard formats. NYTimes an "product_id": "nytimes_homepage_mrec", "name": "NYTimes.com Homepage MREC (300×250)", "publisher_properties": [ - { "type": "publisher", "publisher_domain": "nytimes.com" } + { "publisher_domain": "nytimes.com", "selection_type": "all" } ], - "channels": ["web"], + "channels": ["display"], "format": { "image": { "width": 300, @@ -129,9 +129,9 @@ For HTML5 banners on the same placement, NYTimes publishes a *separate* product "product_id": "nytimes_homepage_html5", "name": "NYTimes.com Homepage HTML5 Banner (300×250)", "publisher_properties": [ - { "type": "publisher", "publisher_domain": "nytimes.com" } + { "publisher_domain": "nytimes.com", "selection_type": "all" } ], - "channels": ["web"], + "channels": ["display"], "format": { "html5": { "width": 300, @@ -166,7 +166,7 @@ Host-reads are the host-recorded-from-buyer-script pattern. The product declares "product_id": "the_daily_30s_host_read_us", "name": "The Daily — 30s Host-Read Pre-roll (US)", "publisher_properties": [ - { "type": "publisher", "publisher_domain": "thedailypod.example" } + { "publisher_domain": "thedailypod.example", "selection_type": "all" } ], "channels": ["podcast"], "format": { diff --git a/static/examples/products/v2/chatgpt_brand_mention.json b/static/examples/products/v2/chatgpt_brand_mention.json new file mode 100644 index 0000000000..e3268fb1f2 --- /dev/null +++ b/static/examples/products/v2/chatgpt_brand_mention.json @@ -0,0 +1,57 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "openai_chatgpt_sponsored_mention_us", + "name": "ChatGPT Sponsored Brand Mention — United States", + "description": "AI-surface sponsored placement on ChatGPT. Buyer supplies a BrandRef (resolving brand.json for context) plus optional offering reference; ChatGPT composes a natural-language sponsored mention within its response to a relevant user query. Composition is algorithmic — the agent chooses phrasing and presentation, with disclosure required and no buyer-fixed creative. Distinct from si_chat (which is the user-converses-with-brand's-agent pattern, brand-owned conversational surface). Parallels sponsored_placement structurally (both surface-composed) but for AI/agentic surfaces rather than retail-media catalog.", + "publisher_properties": [ + { + "publisher_domain": "openai.com", + "selection_type": "all" + } + ], + "channels": ["sponsored_intelligence"], + "format": { + "agent_placement": { + "output_modality": "text", + "max_mention_length_chars": 280, + "supports_offering_reference": true, + "supports_landing_page_url": true, + "tone_constraints": ["factual", "no_superlatives"], + "disclosure_required": true, + "composition_model": "algorithmic", + "inputs": { + "brand": { + "required": true, + "description": "BrandRef resolving brand.json for brand context (logos, voice, taglines, industries)." + }, + "offering_ref": { + "required": false, + "description": "Optional offering identifier to focus the mention on a specific product, service, or campaign within the brand." + } + }, + "platform_extensions": [ + { + "uri": "https://openai.adcp/extensions/chatgpt_response_card", + "digest": "sha256:f3a6c9b2e5d8f1a4c7b0e3d6f9a2c5b8e1d4f7a0c3b6e9d2f5a8c1b4e7d0f3a6" + } + ] + } + }, + "delivery_type": "non_guaranteed", + "pricing_options": [ + { + "pricing_option_id": "cpm_mention", + "pricing_model": "cpm", + "currency": "USD", + "floor_price": 18.00 + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": ["daily"], + "expected_delay_minutes": 1440, + "timezone": "UTC", + "supports_webhooks": false, + "available_metrics": ["impressions", "clicks", "spend", "ctr", "engagement_rate"], + "date_range_support": "date_range" + } +} diff --git a/static/examples/products/v2/google_performance_max.json b/static/examples/products/v2/google_performance_max.json new file mode 100644 index 0000000000..269f96bef5 --- /dev/null +++ b/static/examples/products/v2/google_performance_max.json @@ -0,0 +1,70 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "google_pmax_us", + "name": "Google Performance Max — United States", + "description": "Google Performance Max campaign — buyer supplies a pool of typed assets (multiple headlines, descriptions, landscape/square images, videos, logos) and Google's optimizer composes combinations across Search, Display, YouTube, Discover, Gmail, and Maps. Composition is algorithmic — surface picks combinations and reports per-asset performance breakdowns. Industry term: \"Responsive\" (Google) / \"Advantage+ creative\" (Meta) / \"Dynamic Creative\" (older Meta term). Distinct from sponsored_placement (catalog-driven, deterministic) and agent_placement (AI-surface composition).", + "publisher_properties": [ + { + "publisher_domain": "google.com", + "selection_type": "all" + } + ], + "channels": ["search", "display", "ctv", "olv"], + "format": { + "responsive_creative": { + "headlines_min": 3, + "headlines_max": 15, + "headline_max_chars": 30, + "long_headlines_min": 1, + "long_headlines_max": 5, + "long_headline_max_chars": 90, + "descriptions_min": 2, + "descriptions_max": 5, + "description_max_chars": 90, + "images_landscape_min": 1, + "images_landscape_max": 20, + "images_landscape_aspect_ratio": "1.91:1", + "images_square_min": 1, + "images_square_max": 20, + "videos_min": 0, + "videos_max": 5, + "video_min_duration_ms": 10000, + "video_max_duration_ms": 600000, + "logo_min": 1, + "logo_max": 5, + "logo_aspect_ratios": ["1:1", "4:1"], + "business_name_max_chars": 25, + "asset_image_max_file_size_kb": 5120, + "supports_catalog_input": true, + "composition_model": "algorithmic", + "platform_extensions": [ + { + "uri": "https://google.adcp/extensions/google_conversion_actions", + "digest": "sha256:d8f1a4c7b0e3d6f9a2c5b8e1d4f7a0c3b6e9d2f5a8c1b4e7d0f3a6c9b2e5d8f1" + }, + { + "uri": "https://google.adcp/extensions/google_audience_signals", + "digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + ] + } + }, + "delivery_type": "non_guaranteed", + "pricing_options": [ + { + "pricing_option_id": "cpa_purchase", + "pricing_model": "cpa", + "event_type": "purchase", + "currency": "USD", + "fixed_price": 25.00 + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": ["daily"], + "expected_delay_minutes": 240, + "timezone": "America/Los_Angeles", + "supports_webhooks": false, + "available_metrics": ["impressions", "clicks", "spend", "ctr", "conversions", "conversion_value", "cost_per_acquisition", "roas", "viewability"], + "date_range_support": "date_range" + } +} diff --git a/static/schemas/source/core/asset-group-vocabulary.json b/static/schemas/source/core/asset-group-vocabulary.json index 2e312e1fa6..4b271c6315 100644 --- a/static/schemas/source/core/asset-group-vocabulary.json +++ b/static/schemas/source/core/asset-group-vocabulary.json @@ -2,9 +2,9 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/asset-group-vocabulary.json", "title": "AdCP Asset Group Vocabulary Registry", - "description": "Canonical registry of asset_group_id values used in offering asset groups (OfferingAssetGroup) and in v2 product format declarations. Non-canonical IDs remain valid for platform-specific extensions; this registry codifies the recommended canonical set so that buyers and sellers share a vocabulary for the most common slot roles. Validators may emit soft warnings on non-canonical IDs to encourage convergence.", - "version": "1.0.0", - "lastUpdated": "2026-04-26", + "description": "Canonical registry of asset_group_id values used in offering asset groups (OfferingAssetGroup) and in v2 product format declarations. Non-canonical IDs remain valid for platform-specific extensions; this registry codifies the recommended canonical set so that buyers and sellers share a vocabulary for the most common slot roles. Validators may emit soft warnings on non-canonical IDs to encourage convergence.\n\n**Slots vs inputs**: this registry covers `asset_group_id` slot names (assets the buyer ships in the manifest's `assets` map). It does NOT cover production input names (`script`, `creative_brief`, `voice_id`, `brand`, `offering_ref`, `starter_assets`, `scenes`, `style_reference`, `product_feed`) used on canonical format `inputs` declarations — those are a separate convention not yet canonicalized at the spec level. v2 leaves input names as a per-format convention; future revisions may canonicalize them.", + "version": "1.1.0", + "lastUpdated": "2026-04-28", "vocabulary": { "headlines": { "description": "Pool of headline text variants for the surface to choose from.", @@ -87,7 +87,53 @@ "cards": { "description": "Per-item carousel card array.", "asset_type": "object", - "typical_use": "Carousel slides on Meta, TikTok, Pinterest, LinkedIn, Reddit. Each card carries its own image/video, headline, and link." + "typical_use": "Carousel slides on Meta, TikTok, Pinterest, LinkedIn, Reddit. Each card carries its own image/video, headline, and link.", + "aliases": ["carousel_cards", "slides", "carousel_items", "carousel_slides"] + }, + "cta": { + "description": "Call-to-action button label or text.", + "asset_type": "text", + "typical_use": "CTA button on display/video/social ad creative (e.g., \"Shop Now\", \"Learn More\"). Most products narrow to a fixed enum via `cta_values`; this canonical entry names the slot itself.", + "aliases": ["cta_text", "call_to_action", "action_text", "button_text"] + }, + "price": { + "description": "Product price slot rendered on the creative.", + "asset_type": "text", + "typical_use": "Pinterest shopping pins, Amazon Sponsored Brand product cards, retail-media catalog rendering. Often pulled from a catalog field via `field_bindings`." + }, + "disclaimer": { + "description": "Required legal disclaimer or fine print.", + "asset_type": "text", + "typical_use": "Pharma, financial services, alcohol, sweepstakes — non-optional in regulated verticals. Receivers may render at reduced size below the primary creative.", + "aliases": ["legal_text", "fine_print", "legal_disclaimer"] + }, + "phone_number": { + "description": "Phone number for click-to-call placements (E.164 preferred).", + "asset_type": "text", + "typical_use": "Google call-only ads, Bing call extensions, click-to-call retail/local placements." + }, + "promo_code": { + "description": "Promotional / offer code rendered on the creative.", + "asset_type": "text", + "typical_use": "Retail / promo creative carrying a coupon code (e.g., \"SAVE20\"). Distinct from offering metadata; this is the rendered text.", + "aliases": ["offer_code", "coupon_code", "discount_code"] + }, + "subtitle_file": { + "description": "Subtitle / closed-caption file for video assets.", + "asset_type": "url", + "typical_use": "WebVTT or SRT URL paired with a `video` slot. Required by accessibility-strict products; recommended for most video formats.", + "aliases": ["caption_file", "captions", "subtitles"] + }, + "source_catalog": { + "description": "Catalog asset reference for catalog-driven products.", + "asset_type": "catalog", + "typical_use": "Required input on `sponsored_placement` formats. Buyer references a synced catalog by `catalog_id` plus optional item filters." + }, + "hero_asset": { + "description": "Optional buyer-supplied hero/banner asset alongside a catalog reference.", + "asset_type": "image", + "typical_use": "Pinterest Collection, Snap Collection, Amazon SB Product Collection — buyer supplies a hero image; surface composes catalog tiles below it.", + "aliases": ["hero_banner", "collection_hero"] }, "landing_page_url": { "description": "Click-through destination URL for the ad.", @@ -111,7 +157,8 @@ "youtube_video_id": { "description": "Externally-hosted YouTube video reference.", "asset_type": "text", - "typical_use": "Reference an existing YouTube video for Google instream/bumper/non-skippable, PMax." + "typical_use": "Reference an existing YouTube video for Google instream/bumper/non-skippable, PMax.", + "aliases": ["existing_yt_video_id", "yt_video_id", "youtube_id"] }, "pin_id": { "description": "Externally-hosted Pinterest creative reference.", diff --git a/static/schemas/source/core/product-format-declaration.json b/static/schemas/source/core/product-format-declaration.json index 9e721a34ce..7569f55f86 100644 --- a/static/schemas/source/core/product-format-declaration.json +++ b/static/schemas/source/core/product-format-declaration.json @@ -59,7 +59,7 @@ } }, { - "description": "Podcast 30s host-read — narrows audio_hosted, requires build_capability_ref on parent product", + "description": "Podcast 30s host-read — narrows audio_hosted with inline inputs declaring what the buyer must supply (script + brand). Buyer-uploaded audio rejected; seller produces internally or buyer pre-produces via a creative agent's build_creative.", "data": { "audio_hosted": { "duration_ms_exact": 30000, @@ -69,7 +69,12 @@ "loudness_lufs": -16, "audio_source": "publisher_host_recorded", "buyer_audio_acceptance": "rejected", - "composition_model": "deterministic" + "composition_model": "deterministic", + "inputs": { + "script": { "required": true, "max_chars": 800 }, + "brand": { "required": true } + }, + "production_window_business_days": 7 } } } diff --git a/static/schemas/source/formats/canonical/_base.json b/static/schemas/source/formats/canonical/_base.json index ec9e082345..dd346663ec 100644 --- a/static/schemas/source/formats/canonical/_base.json +++ b/static/schemas/source/formats/canonical/_base.json @@ -21,9 +21,44 @@ }, "tracking_extensions": { "type": "array", - "description": "Platform-specific tracking extensions (e.g., Meta pixel, TikTok pixel) referenced when the canonical's baseline tracking is insufficient. Each entry references the tracking-extension definition.", + "description": "Subset of platform_extensions specifically scoped to tracking concerns (pixel IDs, conversion event taxonomies, viewability vendors, OM-SDK partners). Functionally equivalent to listing tracking-related extensions under platform_extensions; the separate field surfaces \"what's tracking-related\" without forcing buyers to fetch each extension definition to find out. When present, every entry MUST also be present in (or implied by) platform_extensions. Producers MAY omit tracking_extensions and put everything under platform_extensions; the split is for buyer convenience, not schema enforcement.", "items": { "$ref": "/schemas/core/platform-extension-ref.json" } }, + "synthesis_nondeterministic": { + "type": "boolean", + "description": "When true, the format's production pipeline is genuinely nondeterministic — the platform cannot guarantee that synthesis from a given input set produces in-spec output. Veo / Sora / Runway-class generative video, and other AI-synthesis flows where output dimensions, duration, or quality vary per run. Implies a different validation contract: predictive `validate_input` is impossible; the platform's own post-synthesis QA loop applies; if the QA loop exhausts without producing a valid artifact, `build_creative` returns task_failed with a synthesis_failed reason. Distinct from `composition_model` (which describes how the surface composes per-slot rendering, not whether synthesis is deterministic). When false or absent, the format's production is predictable enough that `validate_input` can predict output properties from input properties.", + "default": false + }, + "slots": { + "type": "array", + "description": "Programmatic declaration of which canonical asset_group_id slots a manifest targeting this format must (or may) populate. Lets SDK codegen and validators enumerate expected slots without parsing the format's prose description. Each entry references an asset_group_id from the canonical vocabulary registry. Format-specific narrowing parameters (e.g., headline_max_chars on responsive_creative) live as flat properties on the format declaration; this `slots` field captures the structural slot inventory.", + "items": { + "type": "object", + "required": ["asset_group_id"], + "properties": { + "asset_group_id": { + "type": "string", + "description": "Canonical asset_group_id from /schemas/core/asset-group-vocabulary.json. Non-canonical IDs are valid but trigger soft warnings." + }, + "required": { + "type": "boolean", + "description": "Whether this slot is required for a valid manifest.", + "default": false + }, + "min": { + "type": "integer", + "minimum": 0, + "description": "Minimum count for repeatable / pool slots." + }, + "max": { + "type": "integer", + "minimum": 1, + "description": "Maximum count for repeatable / pool slots." + } + }, + "additionalProperties": true + } + }, "inputs": { "type": "object", "description": "What the format requires from the buyer. When the format accepts only buyer-uploaded creative (e.g., a static image format), `inputs` is typically absent and the buyer simply uploads assets. When the format requires agent production (e.g., host-read where the publisher records the audio), `inputs` declares the script, brief, voice selection, brand context, etc. Inputs MAY be fulfilled either by the buyer pre-producing through a creative agent's `build_creative` and submitting the rendered manifest, or by the buyer submitting inputs directly via `sync_creatives` for the seller to produce internally — the format's `audio_source`/`buyer_audio_acceptance`-style parameters tell the buyer which flows are accepted.", diff --git a/static/schemas/source/formats/canonical/agent_placement.json b/static/schemas/source/formats/canonical/agent_placement.json index 13266cf24a..186c1fe4bf 100644 --- a/static/schemas/source/formats/canonical/agent_placement.json +++ b/static/schemas/source/formats/canonical/agent_placement.json @@ -2,9 +2,14 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/formats/canonical/agent_placement.json", "title": "Canonical Format: Agent Placement (AI-surface sponsored placement)", - "description": "Sponsored placement integrated into an AI-surface's response to a user. Buyer supplies a `BrandRef` (resolving brand.json for context) and optional offering reference; the surface (LLM, voice assistant, sponsored-search ranker) composes a natural-language mention, sponsored card, or audio snippet within its response to a user query. **Composition is algorithmic** — the agent chooses phrasing and presentation. Output asset_type varies by surface: `text` for chat UIs and sponsored search snippets; `audio` (synthesized) for voice assistants; `card` for structured AI-surface result cards. Tracking model: mention-level impression + attribution events; per-mention id keys back to brand and offering. Distinct from `si_chat` (which is the user-converses-with-brand's-agent pattern — brand owns the conversational surface) and from `sponsored_placement` (retail-media catalog-driven). Parallels `sponsored_placement` structurally: both are surface-composed placements; agent_placement is for AI/agentic surfaces, sponsored_placement is for retail media.", + "description": "Sponsored placement integrated into an AI-surface's response to a user. Buyer supplies a `BrandRef` (resolving brand.json for context) and optional offering reference; the surface (LLM, voice assistant, sponsored-search ranker) composes a natural-language mention, sponsored card, or audio snippet within its response to a user query. **Composition is algorithmic** — the agent chooses phrasing and presentation. Output asset_type varies by surface: `text` for chat UIs and sponsored search snippets; `audio` (synthesized) for voice assistants; `card` for structured AI-surface result cards. Tracking model: mention-level impression + attribution events; per-mention id keys back to brand and offering. Distinct from `si_chat` (which is the user-converses-with-brand's-agent pattern — brand owns the conversational surface) and from `sponsored_placement` (retail-media catalog-driven). Parallels `sponsored_placement` structurally: both are surface-composed placements; agent_placement is for AI/agentic surfaces, sponsored_placement is for retail media. Buyer-supplied data (brand, offering_ref, optional landing page URL) lives on the `inputs` field per the standard inputs convention; there are no buyer-fillable creative slots on the manifest.", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { + "slots": { + "default": [ + { "asset_group_id": "landing_page_url", "required": false, "min": 0, "max": 1 } + ] + }, "output_modality": { "type": "string", "enum": ["text", "audio", "card"], @@ -31,7 +36,7 @@ "tone_constraints": { "type": "array", "items": { "type": "string" }, - "description": "Optional brand-voice constraints the surface must honor (e.g., ['formal', 'no_superlatives'])." + "description": "**Advisory only.** Buyer-declared brand-voice preferences the surface SHOULD honor (e.g., ['formal', 'no_superlatives']). LLM/agentic surfaces have no protocol-level mechanism to verify enforcement — adopters that need hard guarantees should rely on brand.json voice declarations and post-mention review rather than this field. Future revisions may tie this to a structured tone vocabulary; for now treat as free-text guidance." }, "disclosure_required": { "type": "boolean", diff --git a/static/schemas/source/formats/canonical/audio_hosted.json b/static/schemas/source/formats/canonical/audio_hosted.json index 608a471658..1223e838ab 100644 --- a/static/schemas/source/formats/canonical/audio_hosted.json +++ b/static/schemas/source/formats/canonical/audio_hosted.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/formats/canonical/audio_hosted.json", "title": "Canonical Format: Hosted Audio", - "description": "Direct audio file (mp3/aac/wav) hosted by the buyer (or produced by a creative agent — see build_capability_ref for podcast host-read flows). Slot: `audio` (audio asset), optional `companion_image` (image), `brand_name`, `landing_page_url`. Tracking model: standard impression + completion + companion-image-click pixels via universal_macros. Distinct from `audio_daast` (DAAST tag, inherent DAAST event tracking). For podcast host-reads, the audio is typically produced via a creative agent's build_creative capability with a `script` or `creative_brief` input; the product declares `audio_source: 'publisher_host_recorded'` and references the production capability via `build_capability_ref`.", + "description": "Direct audio file (mp3/aac/wav) hosted by the buyer, or produced internally by the seller from buyer-supplied inputs. Slot: `audio` (audio asset), optional `companion_image` (image), `brand_name`, `landing_page_url`. Tracking model: standard impression + completion + companion-image-click pixels via universal_macros. Distinct from `audio_daast` (DAAST tag, inherent DAAST event tracking). For podcast host-reads and other agent-produced audio, the format declares `audio_source: 'publisher_host_recorded'` (or `'agent_synthesized'`) and `buyer_audio_acceptance: 'rejected'`, with the input contract (script, creative_brief, voice_id, brand) declared inline via the `inputs` field on the format declaration. Buyers may either submit inputs directly via `sync_creatives` (seller produces internally) or pre-produce via a creative agent's `build_creative` and submit the rendered manifest. External creative-agent supply chains are invisible to the buyer.", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { "duration_ms_range": { diff --git a/static/schemas/source/formats/canonical/responsive_creative.json b/static/schemas/source/formats/canonical/responsive_creative.json index 835c1d59ef..c367362936 100644 --- a/static/schemas/source/formats/canonical/responsive_creative.json +++ b/static/schemas/source/formats/canonical/responsive_creative.json @@ -2,9 +2,22 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/formats/canonical/responsive_creative.json", "title": "Canonical Format: Responsive Creative", - "description": "Buyer supplies a pool of typed assets (multiple headlines, descriptions, images, videos, logos); the surface algorithmically composes combinations per placement. Slots: `headlines[]`, `descriptions[]`, `images_landscape[]`, `images_square[]`, `images_vertical[]`, `videos[]`, `logo[]`, `landing_page_url`. **Composition is algorithmic** — surface picks combinations and reports per-asset performance breakdowns. Covers Google Responsive Display Ads (RDA), Responsive Search Ads (RSA), Performance Max (PMax), Demand Gen, and Meta Advantage+ creative. Industry term: \"Responsive\" (Google) / \"Advantage+ creative\" (Meta) / \"Dynamic Creative\" (older Meta term). Distinct from `sponsored_placement` (catalog-driven, deterministic) and `agent_placement` (AI-surface composition).", + "description": "Buyer supplies a pool of typed assets (multiple headlines, descriptions, images, videos, logos); the surface algorithmically composes combinations per placement. **Composition is algorithmic** — surface picks combinations and reports per-asset performance breakdowns. Covers Google Responsive Display Ads (RDA), Responsive Search Ads (RSA), Performance Max (PMax), Demand Gen, and Meta Advantage+ creative. Industry term: \"Responsive\" (Google) / \"Advantage+ creative\" (Meta) / \"Dynamic Creative\" (older Meta term). Distinct from `sponsored_placement` (catalog-driven, deterministic) and `agent_placement` (AI-surface composition). The structured `slots` field below enumerates expected canonical asset_group_id slots; per-slot count/length narrowing lives in flat parameters (`headlines_min`, `headline_max_chars`, etc.).", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { + "slots": { + "default": [ + { "asset_group_id": "headlines", "required": true, "min": 3, "max": 15 }, + { "asset_group_id": "long_headlines", "required": false, "min": 1, "max": 5 }, + { "asset_group_id": "descriptions", "required": true, "min": 2, "max": 5 }, + { "asset_group_id": "images_landscape", "required": false, "min": 1, "max": 20 }, + { "asset_group_id": "images_square", "required": false, "min": 1, "max": 20 }, + { "asset_group_id": "images_vertical", "required": false, "min": 1, "max": 20 }, + { "asset_group_id": "video", "required": false, "min": 0, "max": 5 }, + { "asset_group_id": "logo", "required": true, "min": 1, "max": 5 }, + { "asset_group_id": "landing_page_url", "required": true, "min": 1, "max": 1 } + ] + }, "headlines_min": { "type": "integer", "minimum": 0 }, "headlines_max": { "type": "integer", "minimum": 0 }, "headline_max_chars": { "type": "integer", "minimum": 1 }, From 6dcf8dd026c7cd0b53763d04568f4c37f8836de3 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 28 Apr 2026 14:41:19 -0400 Subject: [PATCH 13/41] feat(creative): manifest inputs field; bundled extensions schema + fixture; agent_placement landing_page_url moves to inputs; product_card carve-out documented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continuing review-feedback work: **Manifest `inputs` field:** - creative-manifest.json gains optional `inputs` field for input-driven submissions (script, creative_brief, voice_id, offering_ref, landing_page_url, etc.). Buyers populate it for products whose format declares an inputs contract; v1 consumers ignore it. Some formats accept both — buyer can mix uploaded assets with inputs that drive seller-side composition. **agent_placement landing_page_url moves from slot to input:** - agent_placement has no buyer-fillable creative slots by definition; the format is composed-by-surface. Putting landing_page_url in the manifest's assets map (slot semantic) muddled "rendered creative" with "metadata the agent uses." It's now declared as an input on the format, populated via the manifest's `inputs` field. - agent_placement.json slots default is now empty; description added explaining the inputs-only nature - chatgpt_brand_mention.json fixture updated to declare landing_page_url in inputs **Bundled extensions schema + fixture:** - get-products-response.json gains optional `extensions` field — keyed by `@` patternProperties matching SHA-256 digests; each value is { extends, fields, version, description } - New fixture at static/examples/get_products_responses/v2/ meta_with_bundled_extensions.json — two Meta products (Reels + Feed Image) sharing meta_pixel extension plus separate placements_reels / placements_feed extensions, all bundled in the response under uri@digest keys - v2-fixture-validation test extended to cover get_products response fixtures alongside Product fixtures **product_card carve-out documented:** - v2-migration.mdx adds a section explaining why product_card and product_card_detailed stay on v1 format_id even on v2 products: they're the UI rendering of the product itself, not the ad creative the product accepts. Different purpose, different schema lifecycle. v2-only clients can ignore product_card if they don't render product UIs. Validation: 8 fixtures pass (7 products + 1 get_products response with bundled extensions). All schema/json-schema/v2-fixtures tests green. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/creative/v2-migration.mdx | 16 ++ .../v2/meta_with_bundled_extensions.json | 171 ++++++++++++++++++ .../products/v2/chatgpt_brand_mention.json | 4 + .../source/core/creative-manifest.json | 5 + .../formats/canonical/agent_placement.json | 5 +- .../media-buy/get-products-response.json | 29 +++ tests/v2-fixture-validation.test.cjs | 33 +++- 7 files changed, 259 insertions(+), 4 deletions(-) create mode 100644 static/examples/get_products_responses/v2/meta_with_bundled_extensions.json diff --git a/docs/creative/v2-migration.mdx b/docs/creative/v2-migration.mdx index 2502d9774d..1fcb9ded22 100644 --- a/docs/creative/v2-migration.mdx +++ b/docs/creative/v2-migration.mdx @@ -264,6 +264,22 @@ Override fields take precedence over `brand.json` for that creative. | Creative agents (AudioStack-shaped) | Low, high motivation | 3.1-3.2 | | Publisher direct (GAM/prebid path) | Medium | Blocked on native canonical pre-audit | +## What stays on v1: `product_card` and `product_card_detailed` + +The `product_card` and `product_card_detailed` fields on the `Product` object reference v1 format_ids (e.g., `product_card_standard`) even on v2 products. **This is a deliberate carve-out, not an oversight.** + +The two fields serve a different purpose than the `format` declaration: + +- `product.format` — describes the **ad creative** the product accepts (what the buyer ships, what the surface renders to end users) +- `product.product_card[_detailed]` — describes the **UI rendering of the product itself** (what catalog browsers, dashboards, and admin interfaces display so humans and agents can see what a product is) + +Mixing these would conflate "ad creative" with "product UI metadata." `product_card` keeps a fixed v1 format reference because: +- Its dimensions and structure are fixed (e.g., `product_card_standard` is a 300×400 visual card) +- The format isn't authored by sellers; it's an AdCP-defined display convention +- v2-only clients can ignore `product_card` entirely if they don't render product UIs + +Implication for v2-only consumers: if you parse a Product object and need to render its visual card, you still need to understand the v1 format_id reference for `product_card`/`product_card_detailed`. The `format` field uses the v2 declaration; the card fields stay v1. v2-only adopters who don't render product cards can ignore them. + ## Validating your migration Run the fixture validation against your translated products: diff --git a/static/examples/get_products_responses/v2/meta_with_bundled_extensions.json b/static/examples/get_products_responses/v2/meta_with_bundled_extensions.json new file mode 100644 index 0000000000..4d24c98790 --- /dev/null +++ b/static/examples/get_products_responses/v2/meta_with_bundled_extensions.json @@ -0,0 +1,171 @@ +{ + "$schema": "/schemas/media-buy/get-products-response.json", + "products": [ + { + "product_id": "meta_reels_us", + "name": "Meta Reels — United States", + "description": "9:16 vertical short-form video on Meta Reels (Facebook + Instagram). Buyers upload H.264 mp4 (3-90s) plus headline and primary text; Meta serves to Reels feeds with placement-native UI overlays and Meta-specific tracking via the meta_pixel extension.", + "publisher_properties": [ + { "publisher_domain": "meta.com", "selection_type": "all" } + ], + "channels": ["social"], + "format": { + "video_hosted": { + "orientation": "vertical", + "aspect_ratio": "9:16", + "duration_ms_range": [3000, 90000], + "min_width": 1080, + "min_height": 1920, + "max_file_size_mb": 200, + "video_codecs": ["h264"], + "audio_codecs": ["aac"], + "containers": ["mp4"], + "headline_max_chars": 25, + "primary_text_max_chars": 72, + "captions": "recommended", + "cta_values": ["LEARN_MORE", "SHOP_NOW", "DOWNLOAD", "SIGN_UP"], + "composition_model": "deterministic", + "platform_extensions": [ + { + "uri": "https://meta.adcp/extensions/meta_pixel", + "digest": "sha256:a3f5b7c9d8e2f1a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2" + }, + { + "uri": "https://meta.adcp/extensions/meta_placements_reels", + "digest": "sha256:b8e1d4f6a9c2b5e8d1f4a7c0b3e6d9f2a5c8b1e4d7f0a3c6b9e2d5f8a1c4b7e0" + } + ] + } + }, + "delivery_type": "non_guaranteed", + "pricing_options": [ + { "pricing_option_id": "cpm_floor", "pricing_model": "cpm", "currency": "USD", "floor_price": 5.50 } + ], + "reporting_capabilities": { + "available_reporting_frequencies": ["hourly", "daily"], + "expected_delay_minutes": 60, + "timezone": "America/Los_Angeles", + "supports_webhooks": true, + "available_metrics": ["impressions", "clicks", "spend", "completion_rate", "viewability"], + "date_range_support": "date_range" + } + }, + { + "product_id": "meta_feed_image_us", + "name": "Meta Feed Image — United States", + "description": "Static image placement in Meta feed (Facebook + Instagram). 1.91:1, 1:1, or 4:5 aspect ratios accepted. Same Meta-specific tracking as Reels via meta_pixel extension.", + "publisher_properties": [ + { "publisher_domain": "meta.com", "selection_type": "all" } + ], + "channels": ["social"], + "format": { + "image": { + "width": 1080, + "height": 1080, + "max_file_size_kb": 30000, + "image_formats": ["jpg", "png"], + "ssl_required": true, + "headline_max_chars": 40, + "body_text_max_chars": 125, + "cta_values": ["LEARN_MORE", "SHOP_NOW", "DOWNLOAD", "SIGN_UP"], + "composition_model": "deterministic", + "platform_extensions": [ + { + "uri": "https://meta.adcp/extensions/meta_pixel", + "digest": "sha256:a3f5b7c9d8e2f1a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2" + }, + { + "uri": "https://meta.adcp/extensions/meta_placements_feed", + "digest": "sha256:c2d4e6f8a0b2c4d6e8f0a2c4d6e8f0a2c4d6e8f0a2c4d6e8f0a2c4d6e8f0a2c4" + } + ] + } + }, + "delivery_type": "non_guaranteed", + "pricing_options": [ + { "pricing_option_id": "cpm_floor", "pricing_model": "cpm", "currency": "USD", "floor_price": 4.00 } + ], + "reporting_capabilities": { + "available_reporting_frequencies": ["hourly", "daily"], + "expected_delay_minutes": 60, + "timezone": "America/Los_Angeles", + "supports_webhooks": true, + "available_metrics": ["impressions", "clicks", "spend", "ctr", "viewability"], + "date_range_support": "date_range" + } + } + ], + "extensions": { + "https://meta.adcp/extensions/meta_pixel@sha256:a3f5b7c9d8e2f1a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2": { + "extends": "tracking", + "version": "2.1.0", + "description": "Meta Pixel + Conversions API integration. Required for conversion-event optimization on Meta products.", + "fields": { + "type": "object", + "required": ["pixel_id"], + "properties": { + "pixel_id": { + "type": "string", + "pattern": "^[0-9]{15,16}$", + "description": "Meta Pixel ID (15-16 digit numeric)." + }, + "conversion_event": { + "type": "string", + "enum": ["PURCHASE", "LEAD", "COMPLETE_REGISTRATION", "ADD_TO_CART", "INITIATE_CHECKOUT", "SUBSCRIBE", "VIEW_CONTENT"], + "description": "Standard Meta conversion event the pixel fires on. Custom events use the `custom_event_name` field instead." + }, + "custom_event_name": { + "type": "string", + "description": "Custom Meta conversion event name. Used when conversion_event is omitted." + }, + "test_event_code": { + "type": "string", + "description": "Optional Meta Events Manager test event code for sandbox/preview verification." + } + } + } + }, + "https://meta.adcp/extensions/meta_placements_reels@sha256:b8e1d4f6a9c2b5e8d1f4a7c0b3e6d9f2a5c8b1e4d7f0a3c6b9e2d5f8a1c4b7e0": { + "extends": "placement", + "version": "1.0.0", + "description": "Meta-specific placement enum scoped to Reels surfaces (Facebook Reels + Instagram Reels).", + "fields": { + "type": "object", + "properties": { + "placement": { + "type": "string", + "const": "reels", + "description": "Discriminator — products extending this surface accept only Reels placement." + }, + "facebook_reels": { + "type": "boolean", + "default": true, + "description": "Whether Facebook Reels delivery is enabled." + }, + "instagram_reels": { + "type": "boolean", + "default": true, + "description": "Whether Instagram Reels delivery is enabled." + } + } + } + }, + "https://meta.adcp/extensions/meta_placements_feed@sha256:c2d4e6f8a0b2c4d6e8f0a2c4d6e8f0a2c4d6e8f0a2c4d6e8f0a2c4d6e8f0a2c4": { + "extends": "placement", + "version": "1.0.0", + "description": "Meta-specific placement enum scoped to feed surfaces (Facebook Feed + Instagram Feed).", + "fields": { + "type": "object", + "properties": { + "placement": { + "type": "string", + "const": "feed", + "description": "Discriminator — products extending this surface accept feed placements." + }, + "facebook_feed": { "type": "boolean", "default": true }, + "instagram_feed": { "type": "boolean", "default": true } + } + } + } + } +} diff --git a/static/examples/products/v2/chatgpt_brand_mention.json b/static/examples/products/v2/chatgpt_brand_mention.json index e3268fb1f2..842f4295d9 100644 --- a/static/examples/products/v2/chatgpt_brand_mention.json +++ b/static/examples/products/v2/chatgpt_brand_mention.json @@ -27,6 +27,10 @@ "offering_ref": { "required": false, "description": "Optional offering identifier to focus the mention on a specific product, service, or campaign within the brand." + }, + "landing_page_url": { + "required": false, + "description": "Optional URL the surface attaches to mentions as a citation or learn-more link. Distinct from a creative-asset slot — this is metadata the agent uses, not a rendered creative element." } }, "platform_extensions": [ diff --git a/static/schemas/source/core/creative-manifest.json b/static/schemas/source/core/creative-manifest.json index f3921b78f8..7676abd8bc 100644 --- a/static/schemas/source/core/creative-manifest.json +++ b/static/schemas/source/core/creative-manifest.json @@ -42,6 +42,11 @@ "$ref": "/schemas/core/brand-ref.json", "description": "Brand identity reference (BrandRef — `domain` plus optional `brand_id` for house-of-brands). When present, the seller pulls brand context (logos, colors, voice, taglines) from the brand's brand.json automatically. v2 formats no longer redeclare brand_logo / brand_colors / brand_voice as explicit slots — brand identity is implicit context." }, + "inputs": { + "type": "object", + "description": "Buyer-supplied inputs for input-driven creative submission. Used when the target format declares an `inputs` contract (e.g., a podcast host-read with `script` + `brand` inputs, or an agent_placement with `brand` + `offering_ref` + `landing_page_url`). Keys SHOULD match the input names declared on the format; values are the buyer's submission. v1 consumers ignore this field. For asset-driven submission (image/video/audio uploads), use the `assets` map instead. Some formats accept both — buyer can mix uploaded assets with inputs that drive seller-side composition. Input names are not yet canonicalized at the spec level; common conventions: `script`, `creative_brief`, `voice_id`, `offering_ref`, `landing_page_url`, `style_reference`, `starter_assets`, `scenes`, `product_feed`.", + "additionalProperties": true + }, "brand_kit_override": { "type": "object", "description": "Explicit brand-kit override for the case where brand.json is missing, stale, or inappropriate for this specific creative. When present, takes precedence over brand.json lookups for the supplied fields. Sellers use brand.json as the default and the override as the per-creative authoritative source.", diff --git a/static/schemas/source/formats/canonical/agent_placement.json b/static/schemas/source/formats/canonical/agent_placement.json index 186c1fe4bf..b1b89b5c4f 100644 --- a/static/schemas/source/formats/canonical/agent_placement.json +++ b/static/schemas/source/formats/canonical/agent_placement.json @@ -6,9 +6,8 @@ "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { "slots": { - "default": [ - { "asset_group_id": "landing_page_url", "required": false, "min": 0, "max": 1 } - ] + "default": [], + "description": "agent_placement has no buyer-fillable creative slots — the manifest's `assets` map is empty for this canonical. Buyer-supplied data (brand, offering_ref, landing_page_url) lives on the manifest's `inputs` field, mirroring the format's `inputs` declaration." }, "output_modality": { "type": "string", diff --git a/static/schemas/source/media-buy/get-products-response.json b/static/schemas/source/media-buy/get-products-response.json index 53b2cd3598..a36752ecf5 100644 --- a/static/schemas/source/media-buy/get-products-response.json +++ b/static/schemas/source/media-buy/get-products-response.json @@ -12,6 +12,35 @@ "$ref": "/schemas/core/product.json" } }, + "extensions": { + "type": "object", + "description": "Bundled platform-extension definitions referenced by any product in `products`. Keyed by `@` (e.g., `https://meta.adcp/extensions/meta_pixel@sha256:abc...`). When present, lets buyers resolve `platform_extensions` references on product format declarations without a separate fetch. Buyer SDKs cache by URI@digest; subsequent get_products responses MAY omit definitions the buyer already has cached and rely on the digest match. Each value is an extension definition with `extends` (the canonical concept it extends, e.g., `tracking`), `fields` (the schema for additional fields the extension contributes), `version`, and optional `description`.", + "patternProperties": { + "^https?://[^@]+@sha256:[a-f0-9]{64}$": { + "type": "object", + "required": ["extends", "fields"], + "properties": { + "extends": { + "type": "string", + "description": "Canonical concept this extension extends (e.g., `tracking`, `cta_vocabulary`, `destinations`, `placement`)." + }, + "fields": { + "type": "object", + "description": "JSON Schema fragment declaring the additional fields this extension contributes." + }, + "version": { + "type": "string", + "description": "Semantic version of the extension definition. Distinct from the digest — version is human-readable; digest is the integrity check." + }, + "description": { + "type": "string" + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, "proposals": { "type": "array", "description": "Optional array of proposed media plans with budget allocations across products. Publishers include proposals when they can provide strategic guidance based on the brief. Proposals are actionable - buyers can refine them via follow-up get_products calls within the same session, or execute them directly via create_media_buy.", diff --git a/tests/v2-fixture-validation.test.cjs b/tests/v2-fixture-validation.test.cjs index 7239986343..30b5b23e7d 100644 --- a/tests/v2-fixture-validation.test.cjs +++ b/tests/v2-fixture-validation.test.cjs @@ -17,6 +17,7 @@ const path = require('path'); const SCHEMAS_DIR = path.resolve(__dirname, '../static/schemas/source'); const FIXTURES_DIR = path.resolve(__dirname, '../static/examples/products/v2'); +const RESPONSE_FIXTURES_DIR = path.resolve(__dirname, '../static/examples/get_products_responses/v2'); const RED = '\x1b[31m'; const GREEN = '\x1b[32m'; @@ -102,9 +103,39 @@ function main() { } } + // Validate get_products response fixtures (with bundled extensions) if present + if (fs.existsSync(RESPONSE_FIXTURES_DIR)) { + const responseValidate = ajv.getSchema('/schemas/media-buy/get-products-response.json'); + if (responseValidate) { + const responseFixtures = fs + .readdirSync(RESPONSE_FIXTURES_DIR) + .filter((f) => f.endsWith('.json')) + .sort(); + if (responseFixtures.length > 0) { + console.log(''); + console.log('get_products response fixtures:'); + for (const f of responseFixtures) { + const full = path.join(RESPONSE_FIXTURES_DIR, f); + const fixture = JSON.parse(fs.readFileSync(full, 'utf8')); + const valid = responseValidate(fixture); + if (valid) { + console.log(` ${GREEN}✓${RESET} ${f}`); + pass++; + } else { + console.log(` ${RED}✗${RESET} ${f}`); + for (const err of (responseValidate.errors || []).slice(0, 10)) { + console.log(` ${err.instancePath || '(root)'}: ${err.message}`); + } + fail++; + } + } + } + } + } + console.log(''); if (fail === 0) { - console.log(`${GREEN}✅ All ${pass} v2 reference fixtures validate against the canonical Product schema.${RESET}`); + console.log(`${GREEN}✅ All ${pass} v2 reference fixtures validate.${RESET}`); process.exit(0); } else { console.log(`${RED}❌ ${fail} fixture(s) failed validation; ${pass} passed.${RESET}`); From 53f8dbeef553db6d5fa8538de78071a37b5b3a6c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 28 Apr 2026 15:38:58 -0400 Subject: [PATCH 14/41] refactor(creative): drop inputs concept, add format_kind discriminator, type product_card inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three architectural simplifications from review feedback: **1. Drop `inputs` as a separate concept.** Post build_capability collapse, the inputs/assets distinction had lost its anchor — both maps were "things the buyer ships." Adopters had to guess: is `script` an asset or an input? Is `landing_page_url` rendered or contextual? The answer varied by format and was invisible without reading prose. The new model: format declares `slots`; manifest has one `assets` map. Some assets are rendered verbatim (image, video); some are consumed for production (script, creative_brief, scenes); the seller dispatches per the format's slot declaration. Buyer mental model is uniform — "here's what I'm shipping." Concretely: - _base.json: removed `inputs` field; kept production_window_business_days - creative-manifest.json: removed `inputs` field - audio_hosted.json description: rewritten around slot-based submission ("buyer ships a script text asset to the script slot") - agent_placement.json slots default: now declares offering_ref + landing_page_url as text/url assets (no inputs map) - asset-group-vocabulary.json: added canonical entries for `script`, `creative_brief`, `scenes`, `voice_id`, `offering_ref`, `style_reference`, `starter_assets`. Vocabulary header rewritten to cover everything the buyer ships (rendered + consumed-for-production). **2. format_kind discriminator on ProductFormatDeclaration.** Was: keyed-union shape `{ format: { video_hosted: { ...params } } }` generates awkward TS/Pydantic codegen (11 separate "is this image / html5 / ... " probes per access). Now: `{ format: { format_kind: "video_hosted", params: { ... } } }` generates clean tagged unions. JSON Schema `discriminator` keyword with oneOf branches. Each branch is `format_kind: const ""` + `params: $ref to that canonical's schema`. All 7 product fixtures + 1 get_products response fixture restructured to the new shape. v2-overview.mdx + v2-migration.mdx examples restructured. **3. Typed product_card and product_card_detailed.** Was: `format_id` + `manifest` indirection (referenced v1 product_card_standard format file). Now: inline typed structure on Product: - product_card: image + title + description + price_label + cta_label - product_card_detailed: hero_image + carousel_images + title + description + specifications array + price_label + cta_label Drops the v1 format_id punt the reviewer flagged. product_card serves a different purpose (UI rendering of the product itself, not the ad creative the product accepts) — typing it inline avoids conflating ad-creative formats with UI-display metadata. Validation: 8 fixtures pass; all schema tests green. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/creative/v2-migration.mdx | 22 +-- docs/creative/v2-overview.mdx | 31 +++-- .../v2/meta_with_bundled_extensions.json | 124 +++++++++++++---- .../v2/amazon_sponsored_products.json | 39 ++++-- .../products/v2/chatgpt_brand_mention.json | 44 ++++-- .../products/v2/google_performance_max.json | 33 ++++- .../examples/products/v2/meta_reels_us.json | 46 +++++-- .../products/v2/nytimes_homepage_html5.json | 26 +++- .../products/v2/nytimes_homepage_mrec.json | 37 +++-- .../products/v2/the_daily_30s_host_read.json | 54 ++++++-- .../source/core/asset-group-vocabulary.json | 40 +++++- .../source/core/creative-manifest.json | 5 - .../core/product-format-declaration.json | 129 ++++++++++++++---- static/schemas/source/core/product.json | 84 ++++++++---- .../source/formats/canonical/_base.json | 24 +--- .../formats/canonical/agent_placement.json | 9 +- .../formats/canonical/audio_hosted.json | 2 +- 17 files changed, 556 insertions(+), 193 deletions(-) diff --git a/docs/creative/v2-migration.mdx b/docs/creative/v2-migration.mdx index 1fcb9ded22..3f0581265b 100644 --- a/docs/creative/v2-migration.mdx +++ b/docs/creative/v2-migration.mdx @@ -71,7 +71,8 @@ Most of AdCP doesn't change. v2 builds on the existing primitives: "product_id": "meta_reels_us", "name": "Meta Reels — United States", "format": { - "video_hosted": { + "format_kind": "video_hosted", + "params": { "orientation": "vertical", "aspect_ratio": "9:16", "duration_ms_range": [3000, 90000], @@ -92,9 +93,9 @@ Most of AdCP doesn't change. v2 builds on the existing primitives: } ``` -A v2 product is **either** v1 (uses `format_ids`) **or** v2 (uses `format`) — not both. The product schema's `oneOf` enforces this. +The v2 `format` is a discriminated union: `format_kind` names the canonical format; `params` carries that canonical's parameter schema. SDKs codegen clean tagged unions in TypeScript and Pydantic. A v2 product is **either** v1 (uses `format_ids`) **or** v2 (uses `format`) — not both. The product schema's `oneOf` enforces this. -For five fully-validated worked examples (Meta Reels, IAB MREC, NYTimes HTML5, podcast host-read, Amazon Sponsored Products), see `static/examples/products/v2/`. Each fixture passes `npm run test:v2-fixtures`. +For seven fully-validated worked examples (Meta Reels, IAB MREC, NYTimes HTML5, podcast host-read, Amazon Sponsored Products, Google PMax, ChatGPT brand mention), see `static/examples/products/v2/`. Each fixture passes `npm run test:v2-fixtures`. ## Slot name mapping (v1 → canonical) @@ -175,23 +176,26 @@ Side-by-side for an audio format: ] } -// v2: same canonical (audio_hosted), inputs declared inline +// v2: same canonical (audio_hosted), buyer-shipped assets declared as slots { - "audio_hosted": { + "format_kind": "audio_hosted", + "params": { "duration_ms_exact": 30000, "audio_codecs": ["mp3"], "loudness_lufs": -16, "audio_source": "agent_synthesized", "buyer_audio_acceptance": "rejected", - "inputs": { - "creative_brief": { "required": true, "max_chars": 1000 }, - "voice_id": { "required": false } - }, + "slots": [ + { "asset_group_id": "creative_brief", "required": true, "asset_type": "brief", "max_chars": 1000 }, + { "asset_group_id": "voice_id", "required": false, "asset_type": "text" } + ], "production_window_business_days": 0 } } ``` +Note the v2 manifest has no separate `inputs` map — the buyer ships the brief and voice_id as `text`/`brief` assets in the `assets` map under those slot names. The seller dispatches per the format's slot declaration: brief → consume for synthesis; rendered audio is what comes out the other side. + ## Brand identity — slots disappear v1 formats sometimes redeclared `brand_logo`, `brand_colors`, `brand_voice`, `brand_tagline` as explicit slots. v2 formats don't. When the manifest carries a [`BrandRef`](https://adcontextprotocol.org/schemas/v3/core/brand-ref.json) (`brand: { domain: "acme.com" }`, optionally with `brand_id` for house-of-brands), the seller fetches `brand.json` for context automatically. diff --git a/docs/creative/v2-overview.mdx b/docs/creative/v2-overview.mdx index f800b2ce6b..f9ebb7632d 100644 --- a/docs/creative/v2-overview.mdx +++ b/docs/creative/v2-overview.mdx @@ -53,7 +53,8 @@ Meta Reels narrows `video_hosted` (vertical orientation) with Meta-specific para ], "channels": ["social"], "format": { - "video_hosted": { + "format_kind": "video_hosted", + "params": { "orientation": "vertical", "aspect_ratio": "9:16", "duration_ms_range": [3000, 90000], @@ -100,7 +101,8 @@ The canonical-as-contract value is clearest for IAB-standard formats. NYTimes an ], "channels": ["display"], "format": { - "image": { + "format_kind": "image", + "params": { "width": 300, "height": 250, "max_file_size_kb": 200, @@ -133,7 +135,8 @@ For HTML5 banners on the same placement, NYTimes publishes a *separate* product ], "channels": ["display"], "format": { - "html5": { + "format_kind": "html5", + "params": { "width": 300, "height": 250, "max_initial_load_kb": 200, @@ -170,7 +173,8 @@ Host-reads are the host-recorded-from-buyer-script pattern. The product declares ], "channels": ["podcast"], "format": { - "audio_hosted": { + "format_kind": "audio_hosted", + "params": { "duration_ms_exact": 30000, "audio_codecs": ["mp3", "aac"], "audio_sample_rates": [44100, 48000], @@ -179,15 +183,20 @@ Host-reads are the host-recorded-from-buyer-script pattern. The product declares "audio_source": "publisher_host_recorded", "buyer_audio_acceptance": "rejected", "composition_model": "deterministic", - "inputs": { - "script": { + "slots": [ + { + "asset_group_id": "script", "required": true, + "asset_type": "text", "max_chars": 800, - "description": "Verbatim script the host will read. Exact wording — no improvisation; legal pre-cleared." + "description": "Verbatim script the host reads." }, - "brand": { "required": true }, - "offering_ref": { "required": false } - }, + { + "asset_group_id": "offering_ref", + "required": false, + "asset_type": "text" + } + ], "production_window_business_days": 7 } }, @@ -197,7 +206,7 @@ Host-reads are the host-recorded-from-buyer-script pattern. The product declares } ``` -The format declaration tells the buyer everything they need to know — no extra capability lookup. `inputs` lives directly on the format. The buyer has two flows depending on whether the seller doubles as a creative agent and whether the buyer wants to pre-produce externally. +The format declaration tells the buyer everything they need to know — no extra capability lookup. The buyer ships a `script` text asset under that slot in the manifest's `assets` map; brand context comes from the manifest's top-level `brand` BrandRef. There is no separate "inputs" map — everything the buyer ships lives in `assets`. The buyer has two flows depending on whether the seller doubles as a creative agent and whether the buyer wants to pre-produce externally. ### Flow 1 — buyer pre-produces (upstream creative agent) diff --git a/static/examples/get_products_responses/v2/meta_with_bundled_extensions.json b/static/examples/get_products_responses/v2/meta_with_bundled_extensions.json index 4d24c98790..b17a4f0a4b 100644 --- a/static/examples/get_products_responses/v2/meta_with_bundled_extensions.json +++ b/static/examples/get_products_responses/v2/meta_with_bundled_extensions.json @@ -6,24 +6,44 @@ "name": "Meta Reels — United States", "description": "9:16 vertical short-form video on Meta Reels (Facebook + Instagram). Buyers upload H.264 mp4 (3-90s) plus headline and primary text; Meta serves to Reels feeds with placement-native UI overlays and Meta-specific tracking via the meta_pixel extension.", "publisher_properties": [ - { "publisher_domain": "meta.com", "selection_type": "all" } + { + "publisher_domain": "meta.com", + "selection_type": "all" + } + ], + "channels": [ + "social" ], - "channels": ["social"], "format": { - "video_hosted": { + "format_kind": "video_hosted", + "params": { "orientation": "vertical", "aspect_ratio": "9:16", - "duration_ms_range": [3000, 90000], + "duration_ms_range": [ + 3000, + 90000 + ], "min_width": 1080, "min_height": 1920, "max_file_size_mb": 200, - "video_codecs": ["h264"], - "audio_codecs": ["aac"], - "containers": ["mp4"], + "video_codecs": [ + "h264" + ], + "audio_codecs": [ + "aac" + ], + "containers": [ + "mp4" + ], "headline_max_chars": 25, "primary_text_max_chars": 72, "captions": "recommended", - "cta_values": ["LEARN_MORE", "SHOP_NOW", "DOWNLOAD", "SIGN_UP"], + "cta_values": [ + "LEARN_MORE", + "SHOP_NOW", + "DOWNLOAD", + "SIGN_UP" + ], "composition_model": "deterministic", "platform_extensions": [ { @@ -39,14 +59,28 @@ }, "delivery_type": "non_guaranteed", "pricing_options": [ - { "pricing_option_id": "cpm_floor", "pricing_model": "cpm", "currency": "USD", "floor_price": 5.50 } + { + "pricing_option_id": "cpm_floor", + "pricing_model": "cpm", + "currency": "USD", + "floor_price": 5.5 + } ], "reporting_capabilities": { - "available_reporting_frequencies": ["hourly", "daily"], + "available_reporting_frequencies": [ + "hourly", + "daily" + ], "expected_delay_minutes": 60, "timezone": "America/Los_Angeles", "supports_webhooks": true, - "available_metrics": ["impressions", "clicks", "spend", "completion_rate", "viewability"], + "available_metrics": [ + "impressions", + "clicks", + "spend", + "completion_rate", + "viewability" + ], "date_range_support": "date_range" } }, @@ -55,19 +89,33 @@ "name": "Meta Feed Image — United States", "description": "Static image placement in Meta feed (Facebook + Instagram). 1.91:1, 1:1, or 4:5 aspect ratios accepted. Same Meta-specific tracking as Reels via meta_pixel extension.", "publisher_properties": [ - { "publisher_domain": "meta.com", "selection_type": "all" } + { + "publisher_domain": "meta.com", + "selection_type": "all" + } + ], + "channels": [ + "social" ], - "channels": ["social"], "format": { - "image": { + "format_kind": "image", + "params": { "width": 1080, "height": 1080, "max_file_size_kb": 30000, - "image_formats": ["jpg", "png"], + "image_formats": [ + "jpg", + "png" + ], "ssl_required": true, "headline_max_chars": 40, "body_text_max_chars": 125, - "cta_values": ["LEARN_MORE", "SHOP_NOW", "DOWNLOAD", "SIGN_UP"], + "cta_values": [ + "LEARN_MORE", + "SHOP_NOW", + "DOWNLOAD", + "SIGN_UP" + ], "composition_model": "deterministic", "platform_extensions": [ { @@ -83,14 +131,28 @@ }, "delivery_type": "non_guaranteed", "pricing_options": [ - { "pricing_option_id": "cpm_floor", "pricing_model": "cpm", "currency": "USD", "floor_price": 4.00 } + { + "pricing_option_id": "cpm_floor", + "pricing_model": "cpm", + "currency": "USD", + "floor_price": 4 + } ], "reporting_capabilities": { - "available_reporting_frequencies": ["hourly", "daily"], + "available_reporting_frequencies": [ + "hourly", + "daily" + ], "expected_delay_minutes": 60, "timezone": "America/Los_Angeles", "supports_webhooks": true, - "available_metrics": ["impressions", "clicks", "spend", "ctr", "viewability"], + "available_metrics": [ + "impressions", + "clicks", + "spend", + "ctr", + "viewability" + ], "date_range_support": "date_range" } } @@ -102,7 +164,9 @@ "description": "Meta Pixel + Conversions API integration. Required for conversion-event optimization on Meta products.", "fields": { "type": "object", - "required": ["pixel_id"], + "required": [ + "pixel_id" + ], "properties": { "pixel_id": { "type": "string", @@ -111,7 +175,15 @@ }, "conversion_event": { "type": "string", - "enum": ["PURCHASE", "LEAD", "COMPLETE_REGISTRATION", "ADD_TO_CART", "INITIATE_CHECKOUT", "SUBSCRIBE", "VIEW_CONTENT"], + "enum": [ + "PURCHASE", + "LEAD", + "COMPLETE_REGISTRATION", + "ADD_TO_CART", + "INITIATE_CHECKOUT", + "SUBSCRIBE", + "VIEW_CONTENT" + ], "description": "Standard Meta conversion event the pixel fires on. Custom events use the `custom_event_name` field instead." }, "custom_event_name": { @@ -162,8 +234,14 @@ "const": "feed", "description": "Discriminator — products extending this surface accept feed placements." }, - "facebook_feed": { "type": "boolean", "default": true }, - "instagram_feed": { "type": "boolean", "default": true } + "facebook_feed": { + "type": "boolean", + "default": true + }, + "instagram_feed": { + "type": "boolean", + "default": true + } } } } diff --git a/static/examples/products/v2/amazon_sponsored_products.json b/static/examples/products/v2/amazon_sponsored_products.json index 3005d58d6d..b78fe4275f 100644 --- a/static/examples/products/v2/amazon_sponsored_products.json +++ b/static/examples/products/v2/amazon_sponsored_products.json @@ -9,15 +9,26 @@ "selection_type": "all" } ], - "channels": ["retail_media"], + "channels": [ + "retail_media" + ], "format": { - "sponsored_placement": { - "supported_catalog_types": ["product"], + "format_kind": "sponsored_placement", + "params": { + "supported_catalog_types": [ + "product" + ], "min_items": 1, "max_items": 50, "fanout_mode": "per_item", - "required_catalog_fields": ["title", "image_url", "price"], - "supported_id_types": ["asin"], + "required_catalog_fields": [ + "title", + "image_url", + "price" + ], + "supported_id_types": [ + "asin" + ], "hero_asset_supported": false, "composition_model": "deterministic" } @@ -28,15 +39,27 @@ "pricing_option_id": "cpc_auction", "pricing_model": "cpc", "currency": "USD", - "floor_price": 0.50 + "floor_price": 0.5 } ], "reporting_capabilities": { - "available_reporting_frequencies": ["hourly", "daily"], + "available_reporting_frequencies": [ + "hourly", + "daily" + ], "expected_delay_minutes": 60, "timezone": "America/Los_Angeles", "supports_webhooks": true, - "available_metrics": ["impressions", "clicks", "spend", "ctr", "conversions", "conversion_value", "roas", "new_to_brand_rate"], + "available_metrics": [ + "impressions", + "clicks", + "spend", + "ctr", + "conversions", + "conversion_value", + "roas", + "new_to_brand_rate" + ], "date_range_support": "date_range" } } diff --git a/static/examples/products/v2/chatgpt_brand_mention.json b/static/examples/products/v2/chatgpt_brand_mention.json index 842f4295d9..0c8d226c18 100644 --- a/static/examples/products/v2/chatgpt_brand_mention.json +++ b/static/examples/products/v2/chatgpt_brand_mention.json @@ -9,30 +9,36 @@ "selection_type": "all" } ], - "channels": ["sponsored_intelligence"], + "channels": [ + "sponsored_intelligence" + ], "format": { - "agent_placement": { + "format_kind": "agent_placement", + "params": { "output_modality": "text", "max_mention_length_chars": 280, "supports_offering_reference": true, "supports_landing_page_url": true, - "tone_constraints": ["factual", "no_superlatives"], + "tone_constraints": [ + "factual", + "no_superlatives" + ], "disclosure_required": true, "composition_model": "algorithmic", - "inputs": { - "brand": { - "required": true, - "description": "BrandRef resolving brand.json for brand context (logos, voice, taglines, industries)." - }, - "offering_ref": { + "slots": [ + { + "asset_group_id": "offering_ref", "required": false, + "asset_type": "text", "description": "Optional offering identifier to focus the mention on a specific product, service, or campaign within the brand." }, - "landing_page_url": { + { + "asset_group_id": "landing_page_url", "required": false, - "description": "Optional URL the surface attaches to mentions as a citation or learn-more link. Distinct from a creative-asset slot — this is metadata the agent uses, not a rendered creative element." + "asset_type": "url", + "description": "Optional URL the surface MAY attach to mentions as a citation or learn-more link." } - }, + ], "platform_extensions": [ { "uri": "https://openai.adcp/extensions/chatgpt_response_card", @@ -47,15 +53,23 @@ "pricing_option_id": "cpm_mention", "pricing_model": "cpm", "currency": "USD", - "floor_price": 18.00 + "floor_price": 18 } ], "reporting_capabilities": { - "available_reporting_frequencies": ["daily"], + "available_reporting_frequencies": [ + "daily" + ], "expected_delay_minutes": 1440, "timezone": "UTC", "supports_webhooks": false, - "available_metrics": ["impressions", "clicks", "spend", "ctr", "engagement_rate"], + "available_metrics": [ + "impressions", + "clicks", + "spend", + "ctr", + "engagement_rate" + ], "date_range_support": "date_range" } } diff --git a/static/examples/products/v2/google_performance_max.json b/static/examples/products/v2/google_performance_max.json index 269f96bef5..72188d8d1c 100644 --- a/static/examples/products/v2/google_performance_max.json +++ b/static/examples/products/v2/google_performance_max.json @@ -9,9 +9,15 @@ "selection_type": "all" } ], - "channels": ["search", "display", "ctv", "olv"], + "channels": [ + "search", + "display", + "ctv", + "olv" + ], "format": { - "responsive_creative": { + "format_kind": "responsive_creative", + "params": { "headlines_min": 3, "headlines_max": 15, "headline_max_chars": 30, @@ -32,7 +38,10 @@ "video_max_duration_ms": 600000, "logo_min": 1, "logo_max": 5, - "logo_aspect_ratios": ["1:1", "4:1"], + "logo_aspect_ratios": [ + "1:1", + "4:1" + ], "business_name_max_chars": 25, "asset_image_max_file_size_kb": 5120, "supports_catalog_input": true, @@ -56,15 +65,27 @@ "pricing_model": "cpa", "event_type": "purchase", "currency": "USD", - "fixed_price": 25.00 + "fixed_price": 25 } ], "reporting_capabilities": { - "available_reporting_frequencies": ["daily"], + "available_reporting_frequencies": [ + "daily" + ], "expected_delay_minutes": 240, "timezone": "America/Los_Angeles", "supports_webhooks": false, - "available_metrics": ["impressions", "clicks", "spend", "ctr", "conversions", "conversion_value", "cost_per_acquisition", "roas", "viewability"], + "available_metrics": [ + "impressions", + "clicks", + "spend", + "ctr", + "conversions", + "conversion_value", + "cost_per_acquisition", + "roas", + "viewability" + ], "date_range_support": "date_range" } } diff --git a/static/examples/products/v2/meta_reels_us.json b/static/examples/products/v2/meta_reels_us.json index f5f66b05b7..59f361aeb6 100644 --- a/static/examples/products/v2/meta_reels_us.json +++ b/static/examples/products/v2/meta_reels_us.json @@ -9,22 +9,39 @@ "selection_type": "all" } ], - "channels": ["social"], + "channels": [ + "social" + ], "format": { - "video_hosted": { + "format_kind": "video_hosted", + "params": { "orientation": "vertical", "aspect_ratio": "9:16", - "duration_ms_range": [3000, 90000], + "duration_ms_range": [ + 3000, + 90000 + ], "min_width": 1080, "min_height": 1920, "max_file_size_mb": 200, - "video_codecs": ["h264"], - "audio_codecs": ["aac"], - "containers": ["mp4"], + "video_codecs": [ + "h264" + ], + "audio_codecs": [ + "aac" + ], + "containers": [ + "mp4" + ], "headline_max_chars": 25, "primary_text_max_chars": 72, "captions": "recommended", - "cta_values": ["LEARN_MORE", "SHOP_NOW", "DOWNLOAD", "SIGN_UP"], + "cta_values": [ + "LEARN_MORE", + "SHOP_NOW", + "DOWNLOAD", + "SIGN_UP" + ], "composition_model": "deterministic", "platform_extensions": [ { @@ -44,15 +61,24 @@ "pricing_option_id": "cpm_floor", "pricing_model": "cpm", "currency": "USD", - "floor_price": 5.50 + "floor_price": 5.5 } ], "reporting_capabilities": { - "available_reporting_frequencies": ["hourly", "daily"], + "available_reporting_frequencies": [ + "hourly", + "daily" + ], "expected_delay_minutes": 60, "timezone": "America/Los_Angeles", "supports_webhooks": true, - "available_metrics": ["impressions", "clicks", "spend", "completion_rate", "viewability"], + "available_metrics": [ + "impressions", + "clicks", + "spend", + "completion_rate", + "viewability" + ], "date_range_support": "date_range" } } diff --git a/static/examples/products/v2/nytimes_homepage_html5.json b/static/examples/products/v2/nytimes_homepage_html5.json index 5841d395bf..1a4cef1854 100644 --- a/static/examples/products/v2/nytimes_homepage_html5.json +++ b/static/examples/products/v2/nytimes_homepage_html5.json @@ -7,12 +7,17 @@ { "publisher_domain": "nytimes.com", "selection_type": "by_id", - "property_ids": ["homepage_above_fold"] + "property_ids": [ + "homepage_above_fold" + ] } ], - "channels": ["display"], + "channels": [ + "display" + ], "format": { - "html5": { + "format_kind": "html5", + "params": { "width": 300, "height": 250, "max_initial_load_kb": 200, @@ -34,15 +39,24 @@ "pricing_option_id": "cpm_homepage_html5", "pricing_model": "cpm", "currency": "USD", - "fixed_price": 28.00 + "fixed_price": 28 } ], "reporting_capabilities": { - "available_reporting_frequencies": ["daily"], + "available_reporting_frequencies": [ + "daily" + ], "expected_delay_minutes": 240, "timezone": "America/New_York", "supports_webhooks": false, - "available_metrics": ["impressions", "clicks", "spend", "ctr", "viewability", "engagement_rate"], + "available_metrics": [ + "impressions", + "clicks", + "spend", + "ctr", + "viewability", + "engagement_rate" + ], "date_range_support": "date_range" } } diff --git a/static/examples/products/v2/nytimes_homepage_mrec.json b/static/examples/products/v2/nytimes_homepage_mrec.json index 9534d99006..bf608d2b50 100644 --- a/static/examples/products/v2/nytimes_homepage_mrec.json +++ b/static/examples/products/v2/nytimes_homepage_mrec.json @@ -7,18 +7,31 @@ { "publisher_domain": "nytimes.com", "selection_type": "by_id", - "property_ids": ["homepage_above_fold"] + "property_ids": [ + "homepage_above_fold" + ] } ], - "channels": ["display"], + "channels": [ + "display" + ], "format": { - "image": { + "format_kind": "image", + "params": { "width": 300, "height": 250, "max_file_size_kb": 200, - "image_formats": ["jpg", "png", "gif"], + "image_formats": [ + "jpg", + "png", + "gif" + ], "ssl_required": true, - "cta_values": ["LEARN_MORE", "SHOP_NOW", "GET_OFFER"], + "cta_values": [ + "LEARN_MORE", + "SHOP_NOW", + "GET_OFFER" + ], "composition_model": "deterministic", "platform_extensions": [ { @@ -34,15 +47,23 @@ "pricing_option_id": "cpm_homepage_mrec", "pricing_model": "cpm", "currency": "USD", - "fixed_price": 22.00 + "fixed_price": 22 } ], "reporting_capabilities": { - "available_reporting_frequencies": ["daily"], + "available_reporting_frequencies": [ + "daily" + ], "expected_delay_minutes": 240, "timezone": "America/New_York", "supports_webhooks": false, - "available_metrics": ["impressions", "clicks", "spend", "ctr", "viewability"], + "available_metrics": [ + "impressions", + "clicks", + "spend", + "ctr", + "viewability" + ], "date_range_support": "date_range" } } diff --git a/static/examples/products/v2/the_daily_30s_host_read.json b/static/examples/products/v2/the_daily_30s_host_read.json index 7326e42bfc..a4aeddbe36 100644 --- a/static/examples/products/v2/the_daily_30s_host_read.json +++ b/static/examples/products/v2/the_daily_30s_host_read.json @@ -2,33 +2,50 @@ "$schema": "/schemas/core/product.json", "product_id": "the_daily_30s_host_read_us", "name": "The Daily — 30s Host-Read Pre-roll (US)", - "description": "30-second podcast host-read pre-roll on The Daily. Buyer-uploaded audio is rejected (audio_source: publisher_host_recorded); buyer submits a verbatim script (≤800 chars) plus brand context, the publisher's host records the audio, and the audio is dynamically inserted at podcast playback time. 7-business-day production turnaround. Same target format (audio_hosted) as a brief-driven host-read product would use, but with `script` as the required input rather than `creative_brief`.", + "description": "30-second podcast host-read pre-roll on The Daily. Buyer-uploaded audio is rejected (audio_source: publisher_host_recorded); buyer submits a verbatim script (≤800 chars) as a text asset under the `script` slot, plus brand context via the manifest's BrandRef. The publisher's host records the audio, which is dynamically inserted at podcast playback time. 7-business-day production turnaround. A brief-driven host-read product would have the same shape with `creative_brief` (brief asset_type) in the slots instead of `script` (text asset_type).", "publisher_properties": [ { "publisher_domain": "thedailypod.example", "selection_type": "all" } ], - "channels": ["podcast"], + "channels": [ + "podcast" + ], "format": { - "audio_hosted": { + "format_kind": "audio_hosted", + "params": { "duration_ms_exact": 30000, - "audio_codecs": ["mp3", "aac"], - "audio_sample_rates": [44100, 48000], - "audio_channels": ["stereo"], + "audio_codecs": [ + "mp3", + "aac" + ], + "audio_sample_rates": [ + 44100, + 48000 + ], + "audio_channels": [ + "stereo" + ], "loudness_lufs": -16, "audio_source": "publisher_host_recorded", "buyer_audio_acceptance": "rejected", "composition_model": "deterministic", - "inputs": { - "script": { + "slots": [ + { + "asset_group_id": "script", "required": true, + "asset_type": "text", "max_chars": 800, - "description": "Verbatim script the host will read. Exact wording — no improvisation; legal pre-cleared." + "description": "Verbatim script the host reads — exact wording; no improvisation; legal pre-cleared." }, - "brand": { "required": true }, - "offering_ref": { "required": false } - }, + { + "asset_group_id": "offering_ref", + "required": false, + "asset_type": "text", + "description": "Optional offering identifier from the buyer's catalog to focus the host-read." + } + ], "production_window_business_days": 7 } }, @@ -38,15 +55,22 @@ "pricing_option_id": "cpm_host_read", "pricing_model": "cpm", "currency": "USD", - "fixed_price": 35.00 + "fixed_price": 35 } ], "reporting_capabilities": { - "available_reporting_frequencies": ["daily"], + "available_reporting_frequencies": [ + "daily" + ], "expected_delay_minutes": 1440, "timezone": "America/New_York", "supports_webhooks": false, - "available_metrics": ["impressions", "spend", "completion_rate", "completed_views"], + "available_metrics": [ + "impressions", + "spend", + "completion_rate", + "completed_views" + ], "date_range_support": "date_range" } } diff --git a/static/schemas/source/core/asset-group-vocabulary.json b/static/schemas/source/core/asset-group-vocabulary.json index 4b271c6315..6e14777764 100644 --- a/static/schemas/source/core/asset-group-vocabulary.json +++ b/static/schemas/source/core/asset-group-vocabulary.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/asset-group-vocabulary.json", "title": "AdCP Asset Group Vocabulary Registry", - "description": "Canonical registry of asset_group_id values used in offering asset groups (OfferingAssetGroup) and in v2 product format declarations. Non-canonical IDs remain valid for platform-specific extensions; this registry codifies the recommended canonical set so that buyers and sellers share a vocabulary for the most common slot roles. Validators may emit soft warnings on non-canonical IDs to encourage convergence.\n\n**Slots vs inputs**: this registry covers `asset_group_id` slot names (assets the buyer ships in the manifest's `assets` map). It does NOT cover production input names (`script`, `creative_brief`, `voice_id`, `brand`, `offering_ref`, `starter_assets`, `scenes`, `style_reference`, `product_feed`) used on canonical format `inputs` declarations — those are a separate convention not yet canonicalized at the spec level. v2 leaves input names as a per-format convention; future revisions may canonicalize them.", + "description": "Canonical registry of asset_group_id values used in offering asset groups (OfferingAssetGroup) and in v2 product format declarations. Non-canonical IDs remain valid for platform-specific extensions; this registry codifies the recommended canonical set so that buyers and sellers share a vocabulary for the most common slot roles. Validators may emit soft warnings on non-canonical IDs to encourage convergence.\n\nThe registry covers everything the buyer ships in the manifest's `assets` map — both directly-rendered creative content (image, video, audio) AND content the seller consumes for production (script, creative_brief, scenes). The seller dispatches per the format's slot declaration. There is no separate \"inputs\" map on the manifest; everything is an asset.", "version": "1.1.0", "lastUpdated": "2026-04-28", "vocabulary": { @@ -164,6 +164,44 @@ "description": "Externally-hosted Pinterest creative reference.", "asset_type": "text", "typical_use": "Reference an existing Pin (parallel to youtube_video_id)." + }, + "script": { + "description": "Verbatim text the seller's production reads, dubs, or transforms into the rendered output.", + "asset_type": "text", + "typical_use": "Podcast host-read products (host reads buyer's script verbatim), TTS audio synthesis, video voiceover. Distinct from `creative_brief` (looser talking-points style guidance).", + "aliases": ["script_text", "host_script", "voiceover_script"] + }, + "creative_brief": { + "description": "Talking points, brand context, and creative direction for seller-side or generative production.", + "asset_type": "brief", + "typical_use": "Generative AI products (text-to-image, text-to-video), brief-driven podcast host-reads (host has discretion within brief), creative-template platforms. Distinct from `script` (verbatim wording) — `creative_brief` gives the producer creative latitude.", + "aliases": ["brief", "creative_direction", "talking_points"] + }, + "scenes": { + "description": "Structured scene-by-scene plan for generative video production.", + "asset_type": "object", + "typical_use": "Generative video products (Veo/Sora/Runway-class). Each scene declares order, duration_ms, description, optional voiceover and caption. See /schemas/creative/scenes.json for the typed structure.", + "aliases": ["storyboard"] + }, + "voice_id": { + "description": "Voice selection from a format-defined enum or registered voice catalog.", + "asset_type": "text", + "typical_use": "TTS audio synthesis — buyer selects from the platform's voice library. The exact enum is per-format; the canonical name normalizes the slot key." + }, + "offering_ref": { + "description": "Reference to a specific offering (product, service, campaign) within the buyer's brand catalog.", + "asset_type": "text", + "typical_use": "agent_placement products (focus the AI-surface mention on a specific offering); host-read products (specify which offering is being promoted in the read). Value is an offering_id matched against the buyer's catalog." + }, + "style_reference": { + "description": "Reference asset (image, video) providing visual style guidance for generative production.", + "asset_type": "image", + "typical_use": "Image-to-image variation, style transfer, brand-consistent generative imagery. Buyer supplies a reference; seller produces output in the same visual style." + }, + "starter_assets": { + "description": "Pool of reference assets the seller uses as a starting point for transformation or variation.", + "asset_type": "object", + "typical_use": "Generative platforms that take an existing creative and produce variations (different sizes, orientations, durations). Buyer supplies the starter; seller derives outputs." } }, "governance": { diff --git a/static/schemas/source/core/creative-manifest.json b/static/schemas/source/core/creative-manifest.json index 7676abd8bc..f3921b78f8 100644 --- a/static/schemas/source/core/creative-manifest.json +++ b/static/schemas/source/core/creative-manifest.json @@ -42,11 +42,6 @@ "$ref": "/schemas/core/brand-ref.json", "description": "Brand identity reference (BrandRef — `domain` plus optional `brand_id` for house-of-brands). When present, the seller pulls brand context (logos, colors, voice, taglines) from the brand's brand.json automatically. v2 formats no longer redeclare brand_logo / brand_colors / brand_voice as explicit slots — brand identity is implicit context." }, - "inputs": { - "type": "object", - "description": "Buyer-supplied inputs for input-driven creative submission. Used when the target format declares an `inputs` contract (e.g., a podcast host-read with `script` + `brand` inputs, or an agent_placement with `brand` + `offering_ref` + `landing_page_url`). Keys SHOULD match the input names declared on the format; values are the buyer's submission. v1 consumers ignore this field. For asset-driven submission (image/video/audio uploads), use the `assets` map instead. Some formats accept both — buyer can mix uploaded assets with inputs that drive seller-side composition. Input names are not yet canonicalized at the spec level; common conventions: `script`, `creative_brief`, `voice_id`, `offering_ref`, `landing_page_url`, `style_reference`, `starter_assets`, `scenes`, `product_feed`.", - "additionalProperties": true - }, "brand_kit_override": { "type": "object", "description": "Explicit brand-kit override for the case where brand.json is missing, stale, or inappropriate for this specific creative. When present, takes precedence over brand.json lookups for the supplied fields. Sellers use brand.json as the default and the override as the per-creative authoritative source.", diff --git a/static/schemas/source/core/product-format-declaration.json b/static/schemas/source/core/product-format-declaration.json index 7569f55f86..87c6bd33c7 100644 --- a/static/schemas/source/core/product-format-declaration.json +++ b/static/schemas/source/core/product-format-declaration.json @@ -2,29 +2,106 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/product-format-declaration.json", "title": "Product Format Declaration", - "description": "Inline format declaration on a product. Keyed by canonical format name; a product narrows exactly one canonical with platform-specific parameters and extensions. Replaces the v1 named-format pattern (where products referenced a separately-defined format by `format_id`). v1 named formats remain supported through the deprecation cycle; v2 product-bound declarations are opt-in.", + "description": "Inline format declaration on a product. The `format_kind` discriminator names which canonical format the product narrows; `params` carries the canonical's parameter schema (slots, dimensions, durations, codecs, character limits, platform_extensions, tracking_extensions, etc.). Discriminated-union shape generates clean tagged unions in TypeScript and Pydantic codegen. Replaces v1's named-format pattern (where products referenced a separately-defined format file via compound `format_id`). v1 named formats remain supported through the deprecation cycle; v2 product-bound declarations are opt-in.", "type": "object", - "minProperties": 1, - "maxProperties": 1, - "properties": { - "image": { "$ref": "/schemas/formats/canonical/image.json" }, - "html5": { "$ref": "/schemas/formats/canonical/html5.json" }, - "display_tag": { "$ref": "/schemas/formats/canonical/display_tag.json" }, - "image_carousel": { "$ref": "/schemas/formats/canonical/image_carousel.json" }, - "video_hosted": { "$ref": "/schemas/formats/canonical/video_hosted.json" }, - "video_vast": { "$ref": "/schemas/formats/canonical/video_vast.json" }, - "audio_hosted": { "$ref": "/schemas/formats/canonical/audio_hosted.json" }, - "audio_daast": { "$ref": "/schemas/formats/canonical/audio_daast.json" }, - "sponsored_placement": { "$ref": "/schemas/formats/canonical/sponsored_placement.json" }, - "responsive_creative": { "$ref": "/schemas/formats/canonical/responsive_creative.json" }, - "agent_placement": { "$ref": "/schemas/formats/canonical/agent_placement.json" } - }, - "additionalProperties": false, + "required": ["format_kind", "params"], + "discriminator": { "propertyName": "format_kind" }, + "oneOf": [ + { + "title": "Image Format Declaration", + "properties": { + "format_kind": { "type": "string", "const": "image" }, + "params": { "$ref": "/schemas/formats/canonical/image.json" } + }, + "required": ["format_kind", "params"] + }, + { + "title": "HTML5 Format Declaration", + "properties": { + "format_kind": { "type": "string", "const": "html5" }, + "params": { "$ref": "/schemas/formats/canonical/html5.json" } + }, + "required": ["format_kind", "params"] + }, + { + "title": "Display Tag Format Declaration", + "properties": { + "format_kind": { "type": "string", "const": "display_tag" }, + "params": { "$ref": "/schemas/formats/canonical/display_tag.json" } + }, + "required": ["format_kind", "params"] + }, + { + "title": "Image Carousel Format Declaration", + "properties": { + "format_kind": { "type": "string", "const": "image_carousel" }, + "params": { "$ref": "/schemas/formats/canonical/image_carousel.json" } + }, + "required": ["format_kind", "params"] + }, + { + "title": "Hosted Video Format Declaration", + "properties": { + "format_kind": { "type": "string", "const": "video_hosted" }, + "params": { "$ref": "/schemas/formats/canonical/video_hosted.json" } + }, + "required": ["format_kind", "params"] + }, + { + "title": "VAST Video Format Declaration", + "properties": { + "format_kind": { "type": "string", "const": "video_vast" }, + "params": { "$ref": "/schemas/formats/canonical/video_vast.json" } + }, + "required": ["format_kind", "params"] + }, + { + "title": "Hosted Audio Format Declaration", + "properties": { + "format_kind": { "type": "string", "const": "audio_hosted" }, + "params": { "$ref": "/schemas/formats/canonical/audio_hosted.json" } + }, + "required": ["format_kind", "params"] + }, + { + "title": "DAAST Audio Format Declaration", + "properties": { + "format_kind": { "type": "string", "const": "audio_daast" }, + "params": { "$ref": "/schemas/formats/canonical/audio_daast.json" } + }, + "required": ["format_kind", "params"] + }, + { + "title": "Sponsored Placement Format Declaration", + "properties": { + "format_kind": { "type": "string", "const": "sponsored_placement" }, + "params": { "$ref": "/schemas/formats/canonical/sponsored_placement.json" } + }, + "required": ["format_kind", "params"] + }, + { + "title": "Responsive Creative Format Declaration", + "properties": { + "format_kind": { "type": "string", "const": "responsive_creative" }, + "params": { "$ref": "/schemas/formats/canonical/responsive_creative.json" } + }, + "required": ["format_kind", "params"] + }, + { + "title": "Agent Placement Format Declaration", + "properties": { + "format_kind": { "type": "string", "const": "agent_placement" }, + "params": { "$ref": "/schemas/formats/canonical/agent_placement.json" } + }, + "required": ["format_kind", "params"] + } + ], "examples": [ { "description": "Meta Reels — narrows video_hosted (vertical orientation)", "data": { - "video_hosted": { + "format_kind": "video_hosted", + "params": { "orientation": "vertical", "aspect_ratio": "9:16", "duration_ms_range": [3000, 90000], @@ -47,7 +124,8 @@ { "description": "IAB Medium Rectangle (300x250) — narrows image", "data": { - "image": { + "format_kind": "image", + "params": { "width": 300, "height": 250, "max_file_size_kb": 200, @@ -59,9 +137,10 @@ } }, { - "description": "Podcast 30s host-read — narrows audio_hosted with inline inputs declaring what the buyer must supply (script + brand). Buyer-uploaded audio rejected; seller produces internally or buyer pre-produces via a creative agent's build_creative.", + "description": "Podcast 30s host-read — narrows audio_hosted with a `script` slot the seller's host reads verbatim. No separate `inputs` map; the script lives in the manifest's `assets` like any other text asset.", "data": { - "audio_hosted": { + "format_kind": "audio_hosted", + "params": { "duration_ms_exact": 30000, "audio_codecs": ["mp3", "aac"], "audio_sample_rates": [44100, 48000], @@ -70,10 +149,10 @@ "audio_source": "publisher_host_recorded", "buyer_audio_acceptance": "rejected", "composition_model": "deterministic", - "inputs": { - "script": { "required": true, "max_chars": 800 }, - "brand": { "required": true } - }, + "slots": [ + { "asset_group_id": "script", "required": true, "asset_type": "text", "max_chars": 800 }, + { "asset_group_id": "offering_ref", "required": false, "asset_type": "text" } + ], "production_window_business_days": 7 } } diff --git a/static/schemas/source/core/product.json b/static/schemas/source/core/product.json index b0991deec1..ef174c0e68 100644 --- a/static/schemas/source/core/product.json +++ b/static/schemas/source/core/product.json @@ -269,42 +269,78 @@ }, "product_card": { "type": "object", - "description": "Optional standard visual card (300x400px) for displaying this product in user interfaces. Can be rendered via preview_creative or pre-generated.", + "description": "Optional standard visual card for displaying this product in user interfaces (catalog browsers, dashboards, agent UIs). Distinct from `format` — product_card describes the UI rendering of the product itself, not the ad creative the product accepts. Typed inline; no format_id indirection. Receivers render the card directly from these fields.", "properties": { - "format_id": { - "$ref": "/schemas/core/format-id.json", - "description": "Creative format defining the card layout (typically product_card_standard)" + "image": { + "$ref": "/schemas/core/assets/image-asset.json", + "description": "Hero image for the card. Recommended ~300x400 (4:3 portrait) for the standard card layout; receivers may scale." }, - "manifest": { - "type": "object", - "description": "Asset manifest for rendering the card, structure defined by the format", - "additionalProperties": true + "title": { + "type": "string", + "description": "Card title (typically the product name).", + "maxLength": 60 + }, + "description": { + "type": "string", + "description": "Short descriptive blurb shown below the title.", + "maxLength": 200 + }, + "price_label": { + "type": "string", + "description": "Formatted price or pricing summary (e.g., 'From $5 CPM', 'Auction floor $0.50 CPC'). Free-text — receivers render verbatim.", + "maxLength": 30 + }, + "cta_label": { + "type": "string", + "description": "Call-to-action button label (e.g., 'View details', 'Get proposal').", + "maxLength": 25 } }, - "required": [ - "format_id", - "manifest" - ], "additionalProperties": true }, "product_card_detailed": { "type": "object", - "description": "Optional detailed card with carousel and full specifications. Provides rich product presentation similar to media kit pages.", + "description": "Optional detailed card with hero + carousel + structured specifications, for rich product presentation (media-kit-style pages, full product detail views). Distinct from `format` — describes the UI rendering of the product itself, not the ad creative the product accepts. Typed inline; no format_id indirection.", "properties": { - "format_id": { - "$ref": "/schemas/core/format-id.json", - "description": "Creative format defining the detailed card layout (typically product_card_detailed)" + "hero_image": { + "$ref": "/schemas/core/assets/image-asset.json", + "description": "Primary hero image at the top of the detailed view." + }, + "carousel_images": { + "type": "array", + "description": "Additional images for a swipeable carousel below the hero.", + "items": { "$ref": "/schemas/core/assets/image-asset.json" } + }, + "title": { + "type": "string", + "description": "Page title (typically the product name)." + }, + "description": { + "type": "string", + "description": "Full descriptive copy. Markdown allowed in client renderers that support it; otherwise treat as plain text." }, - "manifest": { - "type": "object", - "description": "Asset manifest for rendering the detailed card, structure defined by the format", - "additionalProperties": true + "specifications": { + "type": "array", + "description": "Structured key/value specifications (e.g., 'Aspect ratio: 9:16', 'Duration: 30s'). Each item is a labeled fact about the product.", + "items": { + "type": "object", + "required": ["label", "value"], + "properties": { + "label": { "type": "string", "maxLength": 60 }, + "value": { "type": "string", "maxLength": 200 } + }, + "additionalProperties": true + } + }, + "price_label": { + "type": "string", + "description": "Formatted price or pricing summary." + }, + "cta_label": { + "type": "string", + "description": "Call-to-action button label." } }, - "required": [ - "format_id", - "manifest" - ], "additionalProperties": true }, "collections": { diff --git a/static/schemas/source/formats/canonical/_base.json b/static/schemas/source/formats/canonical/_base.json index dd346663ec..b20c2ea0e2 100644 --- a/static/schemas/source/formats/canonical/_base.json +++ b/static/schemas/source/formats/canonical/_base.json @@ -59,32 +59,10 @@ "additionalProperties": true } }, - "inputs": { - "type": "object", - "description": "What the format requires from the buyer. When the format accepts only buyer-uploaded creative (e.g., a static image format), `inputs` is typically absent and the buyer simply uploads assets. When the format requires agent production (e.g., host-read where the publisher records the audio), `inputs` declares the script, brief, voice selection, brand context, etc. Inputs MAY be fulfilled either by the buyer pre-producing through a creative agent's `build_creative` and submitting the rendered manifest, or by the buyer submitting inputs directly via `sync_creatives` for the seller to produce internally — the format's `audio_source`/`buyer_audio_acceptance`-style parameters tell the buyer which flows are accepted.", - "additionalProperties": { - "type": "object", - "properties": { - "required": { "type": "boolean" }, - "description": { "type": "string" }, - "max_chars": { "type": "integer", "minimum": 1 }, - "max_size_kb": { "type": "integer", "minimum": 1 }, - "allowed_values": { "type": "array", "items": { "type": "string" } } - }, - "additionalProperties": true - }, - "examples": [ - { - "script": { "required": true, "max_chars": 800, "description": "Verbatim script the host will read." }, - "brand": { "required": true }, - "offering_ref": { "required": false } - } - ] - }, "production_window_business_days": { "type": "integer", "minimum": 0, - "description": "Typical production turnaround in business days when the format requires agent production. 0 for synchronous (e.g., generative AI); >0 for human-produced (e.g., podcast host-read). Absent when no production is required (buyer uploads complete creative)." + "description": "Typical production turnaround in business days when the format requires seller-side production (e.g., host-recording from a buyer-supplied script). 0 for synchronous (e.g., generative AI); >0 for human-produced (e.g., podcast host-read). Absent when no production is required (buyer uploads complete creative)." } }, "additionalProperties": true diff --git a/static/schemas/source/formats/canonical/agent_placement.json b/static/schemas/source/formats/canonical/agent_placement.json index b1b89b5c4f..fbb4c69d36 100644 --- a/static/schemas/source/formats/canonical/agent_placement.json +++ b/static/schemas/source/formats/canonical/agent_placement.json @@ -2,12 +2,15 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/formats/canonical/agent_placement.json", "title": "Canonical Format: Agent Placement (AI-surface sponsored placement)", - "description": "Sponsored placement integrated into an AI-surface's response to a user. Buyer supplies a `BrandRef` (resolving brand.json for context) and optional offering reference; the surface (LLM, voice assistant, sponsored-search ranker) composes a natural-language mention, sponsored card, or audio snippet within its response to a user query. **Composition is algorithmic** — the agent chooses phrasing and presentation. Output asset_type varies by surface: `text` for chat UIs and sponsored search snippets; `audio` (synthesized) for voice assistants; `card` for structured AI-surface result cards. Tracking model: mention-level impression + attribution events; per-mention id keys back to brand and offering. Distinct from `si_chat` (which is the user-converses-with-brand's-agent pattern — brand owns the conversational surface) and from `sponsored_placement` (retail-media catalog-driven). Parallels `sponsored_placement` structurally: both are surface-composed placements; agent_placement is for AI/agentic surfaces, sponsored_placement is for retail media. Buyer-supplied data (brand, offering_ref, optional landing page URL) lives on the `inputs` field per the standard inputs convention; there are no buyer-fillable creative slots on the manifest.", + "description": "Sponsored placement integrated into an AI-surface's response to a user. Buyer supplies a `BrandRef` (resolving brand.json for context), an optional `offering_ref` to focus the mention on a specific offering, and an optional `landing_page_url` the surface MAY attach as a citation. The surface (LLM, voice assistant, sponsored-search ranker) composes a natural-language mention, sponsored card, or audio snippet within its response to a user query. **Composition is algorithmic** — the agent chooses phrasing and presentation. Output asset_type varies by surface: `text` for chat UIs and sponsored search snippets; `audio` (synthesized) for voice assistants; `card` for structured AI-surface result cards. Tracking model: mention-level impression + attribution events; per-mention id keys back to brand and offering. Distinct from `si_chat` (which is the user-converses-with-brand's-agent pattern — brand owns the conversational surface) and from `sponsored_placement` (retail-media catalog-driven). Parallels `sponsored_placement` structurally: both are surface-composed placements; agent_placement is for AI/agentic surfaces, sponsored_placement is for retail media.", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { "slots": { - "default": [], - "description": "agent_placement has no buyer-fillable creative slots — the manifest's `assets` map is empty for this canonical. Buyer-supplied data (brand, offering_ref, landing_page_url) lives on the manifest's `inputs` field, mirroring the format's `inputs` declaration." + "default": [ + { "asset_group_id": "offering_ref", "required": false, "asset_type": "text" }, + { "asset_group_id": "landing_page_url", "required": false, "asset_type": "url" } + ], + "description": "agent_placement has minimal buyer-shipped slots — the surface composes the rendered output from brand context (resolved via the manifest's top-level `brand` BrandRef) plus optional offering_ref and landing_page_url assets. None of these assets are rendered verbatim by the buyer; the agent chooses how to use them." }, "output_modality": { "type": "string", diff --git a/static/schemas/source/formats/canonical/audio_hosted.json b/static/schemas/source/formats/canonical/audio_hosted.json index 1223e838ab..8029ea5604 100644 --- a/static/schemas/source/formats/canonical/audio_hosted.json +++ b/static/schemas/source/formats/canonical/audio_hosted.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/formats/canonical/audio_hosted.json", "title": "Canonical Format: Hosted Audio", - "description": "Direct audio file (mp3/aac/wav) hosted by the buyer, or produced internally by the seller from buyer-supplied inputs. Slot: `audio` (audio asset), optional `companion_image` (image), `brand_name`, `landing_page_url`. Tracking model: standard impression + completion + companion-image-click pixels via universal_macros. Distinct from `audio_daast` (DAAST tag, inherent DAAST event tracking). For podcast host-reads and other agent-produced audio, the format declares `audio_source: 'publisher_host_recorded'` (or `'agent_synthesized'`) and `buyer_audio_acceptance: 'rejected'`, with the input contract (script, creative_brief, voice_id, brand) declared inline via the `inputs` field on the format declaration. Buyers may either submit inputs directly via `sync_creatives` (seller produces internally) or pre-produce via a creative agent's `build_creative` and submit the rendered manifest. External creative-agent supply chains are invisible to the buyer.", + "description": "Direct audio creative — buyer ships an `audio` asset (mp3/aac/wav) for asset-driven products, or ships a `script` / `creative_brief` text asset for products where the seller produces audio internally (podcast host-reads, TTS synthesis). Optional companion slots: `companion_image`, `brand_name`, `landing_page_url`. Tracking model: standard impression + completion + companion-image-click pixels via universal_macros. Distinct from `audio_daast` (DAAST tag, inherent DAAST event tracking). For host-reads and synthesized audio, the format declares `audio_source: 'publisher_host_recorded'` or `'agent_synthesized'` plus `buyer_audio_acceptance: 'rejected'`; the format's `slots` declaration enumerates which assets the buyer ships (e.g., `script` text asset for host-reads). The seller decides how to consume each asset (render verbatim vs produce audio from text) — there is no separate manifest 'inputs' map; everything the buyer ships goes in `assets`.", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { "duration_ms_range": { From a7734e381672e6652b232eb7e6e532adbb5d55d5 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 28 Apr 2026 15:49:49 -0400 Subject: [PATCH 15/41] docs(creative): tighten style_reference description with brand.json relationship MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer asked whether style_reference is redundant with brand.json. Not redundant — different scope: - brand.json: declarative brand-level style (hex colors, voice description, logo, tagline) stable across campaigns - style_reference: per-creative reference image ("make it look like THIS") that ships in the manifest for image-to-image variation, style transfer, hero-shot lighting reuse For pure brand-style consistency, BrandRef → brand.json is sufficient and style_reference isn't needed. style_reference is for the cases where the buyer wants to convey style by example rather than by declarative attribute. Industry parallels: MidJourney --sref, Adobe Firefly structure/style reference, Runway/Pika style image inputs. Without canonicalization each platform invents its own slot name (reference_image, style_image, inspiration_image, structure_reference). Tightened the canonical entry's description to make the brand.json relationship explicit; added aliases for the common platform-invented slot names. Co-Authored-By: Claude Opus 4.7 (1M context) --- static/schemas/source/core/asset-group-vocabulary.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/static/schemas/source/core/asset-group-vocabulary.json b/static/schemas/source/core/asset-group-vocabulary.json index 6e14777764..101d3f836e 100644 --- a/static/schemas/source/core/asset-group-vocabulary.json +++ b/static/schemas/source/core/asset-group-vocabulary.json @@ -194,9 +194,10 @@ "typical_use": "agent_placement products (focus the AI-surface mention on a specific offering); host-read products (specify which offering is being promoted in the read). Value is an offering_id matched against the buyer's catalog." }, "style_reference": { - "description": "Reference asset (image, video) providing visual style guidance for generative production.", + "description": "Reference asset (image) providing per-creative visual style guidance for generative production. Distinct from brand.json — brand.json carries declarative brand-level style (hex colors, voice descriptions, logo asset, tagline) that's stable across campaigns; `style_reference` carries a per-creative reference image (\"make it look like THIS\") that ships in the manifest. For pure brand-style consistency, the manifest's BrandRef resolving brand.json suffices and `style_reference` isn't needed.", "asset_type": "image", - "typical_use": "Image-to-image variation, style transfer, brand-consistent generative imagery. Buyer supplies a reference; seller produces output in the same visual style." + "typical_use": "Image-to-image variation (MidJourney --sref, Adobe Firefly structure/style reference), style transfer, reusing a hero shot's lighting/composition for new creative. Buyer supplies a reference image; seller's generative pipeline produces output matching the visual style.", + "aliases": ["reference_image", "style_image", "inspiration_image", "structure_reference"] }, "starter_assets": { "description": "Pool of reference assets the seller uses as a starting point for transformation or variation.", From 550b0429a7e81cd5150914922588d21655d5df18 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 28 Apr 2026 20:10:45 -0400 Subject: [PATCH 16/41] fix(creative): doc/schema cleanup; full canonical fixture coverage; nondeterministic + provenance examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on the inputs→slots collapse: **Doc prose mismatches** (adopters reading docs got the wrong model): - v2-overview.mdx line 20 architectural-shift table: "inputs concept" → "slots array enumerating everything the buyer ships" - v2-overview.mdx host-read prose / Flow 1 / Flow 2 / preview section: rewritten around the slot/asset model. No more "input-driven" framing. - v2-overview.mdx "What's NOT in v2" build_capability bullet: collapsed into the canonical slot/asset model, not into a non-existent inputs map. - v2-migration.mdx *_generated_* collapse bullet, sales agent bullet, creative agent migration step 1, buyer migration step 4: all reframed around slots. - v2-migration.mdx product_card section: rewritten — product_card is typed inline now, not a v1 punt. Old framing was inconsistent with the typed-inline change in commit 53f8dbeef. **Schema description mismatches** (codegen tools read these): - product.json `format` field description: removed "keyed by canonical format name" and "optional inputs"; describes the format_kind + params discriminator shape and the slots-on-format model. - creative-manifest.json `assets` description: covers both v1 (asset_id-keyed) and v2 (asset_group_id-keyed) paths with the current canonical example slot names; added zip to the asset_type list. **Slot schema typing** — _base.json's slots schema now defines asset_type (enum of 16 asset types), max_chars, max_size_kb, and description as first-class fields instead of slipping through additionalProperties: true. Codegen now sees asset_type info per slot. Existing fixtures + canonical defaults updated to include asset_type on every slot entry. **Vocabulary gap**: added long_headlines canonical entry. responsive_ creative.json's default slots referenced it but it wasn't in the registry — soft-warning case the registry was meant to catch caught the canonical itself. Now consistent. **sponsored_placement default slots** — added (was the only canonical without a default; reviewer flagged the asymmetry). Default slots: source_catalog (catalog asset, required), hero_asset (image, optional), landing_page_url (url, optional). **Canonical-policy doc** — added x-canonical-policy-required-params-not- enforced annotation on _base.json explaining the intentional choice: canonicals are loose contracts; products narrow them; required-param enforcement happens at the product level, not the canonical level. **4 missing canonical fixtures added** (5/11 → 11/11 canonical coverage): - gam_3p_display_tag.json (display_tag canonical) - meta_carousel.json (image_carousel canonical with polymorphic items) - youtube_vast_preroll.json (video_vast canonical with VPAID-disabled skippable pre-roll) - triton_daast_audio_30s.json (audio_daast canonical) **Veo fixture** (veo_generative_video_15s.json) exercises both synthesis_nondeterministic: true and provenance_required: true with a representative scenes-driven generative video shape. Closes the "no fixture demonstrates these flags" gap. **validate_input example slot key** — changed video_main → video to match v2 canonical vocabulary. **Phase status table** — Phase 3 now shows what actually shipped (the migration guide, fixtures, fixture-validation test); Phase 4 is the SDK codegen / flatten wrapper work. Validation: 13 fixtures pass (12 products + 1 get_products response with bundled extensions). All schema/json-schema tests green. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/creative/v2-migration.mdx | 47 ++++++++++------ docs/creative/v2-overview.mdx | 33 +++++------ .../products/v2/gam_3p_display_tag.json | 42 ++++++++++++++ .../examples/products/v2/meta_carousel.json | 45 +++++++++++++++ .../products/v2/triton_daast_audio_30s.json | 38 +++++++++++++ .../products/v2/veo_generative_video_15s.json | 48 ++++++++++++++++ .../products/v2/youtube_vast_preroll.json | 55 +++++++++++++++++++ .../source/core/asset-group-vocabulary.json | 6 ++ .../source/core/creative-manifest.json | 2 +- static/schemas/source/core/product.json | 2 +- .../source/formats/canonical/_base.json | 26 ++++++++- .../formats/canonical/agent_placement.json | 4 +- .../canonical/responsive_creative.json | 18 +++--- .../canonical/sponsored_placement.json | 7 +++ 14 files changed, 324 insertions(+), 49 deletions(-) create mode 100644 static/examples/products/v2/gam_3p_display_tag.json create mode 100644 static/examples/products/v2/meta_carousel.json create mode 100644 static/examples/products/v2/triton_daast_audio_30s.json create mode 100644 static/examples/products/v2/veo_generative_video_15s.json create mode 100644 static/examples/products/v2/youtube_vast_preroll.json diff --git a/docs/creative/v2-migration.mdx b/docs/creative/v2-migration.mdx index 3f0581265b..83c1074eaf 100644 --- a/docs/creative/v2-migration.mdx +++ b/docs/creative/v2-migration.mdx @@ -95,7 +95,7 @@ Most of AdCP doesn't change. v2 builds on the existing primitives: The v2 `format` is a discriminated union: `format_kind` names the canonical format; `params` carries that canonical's parameter schema. SDKs codegen clean tagged unions in TypeScript and Pydantic. A v2 product is **either** v1 (uses `format_ids`) **or** v2 (uses `format`) — not both. The product schema's `oneOf` enforces this. -For seven fully-validated worked examples (Meta Reels, IAB MREC, NYTimes HTML5, podcast host-read, Amazon Sponsored Products, Google PMax, ChatGPT brand mention), see `static/examples/products/v2/`. Each fixture passes `npm run test:v2-fixtures`. +For 12 fully-validated worked examples spanning all 11 canonical formats (Meta Reels, IAB MREC, NYTimes HTML5, podcast host-read, Amazon Sponsored Products, Google PMax, ChatGPT brand mention, Meta Carousel, YouTube VAST pre-roll, Triton DAAST audio, Veo generative video, GAM 3P display tag), see `static/examples/products/v2/`. The Veo fixture exercises `synthesis_nondeterministic: true` and `provenance_required: true`. Each fixture passes `npm run test:v2-fixtures`. ## Slot name mapping (v1 → canonical) @@ -151,7 +151,7 @@ Sellers SHOULD provide a server-side flatten wrapper that derives the v1 `list_c The agentic-adapters audit found ~30 `*_generated_*` format files (e.g., `meta_generated_reels`, `tiktok_generated_video_9x16`) that mirror their non-generated counterparts but accept a `creative_brief` instead of an asset upload. In v2 these collapse: -- The format declaration's `inputs` field describes what the buyer provides (script, creative_brief, voice_id, starter_assets, scenes, etc.) +- The format declaration's `slots` array enumerates everything the buyer ships in the manifest's `assets` map — each entry is a canonical `asset_group_id` paired with an `asset_type`. Some slots are rendered verbatim (image / video / audio); some are consumed for production (text script → host-read audio; brief → synthesized image; scenes → generated video). The seller dispatches per slot. - Whether the seller's internal production is generative AI, host recording, transcoding, or asset rendering is **invisible to the buyer** - A single canonical format (e.g., `audio_hosted`) handles both buyer-uploaded audio and agent-produced audio; the format's `audio_source` and `buyer_audio_acceptance` parameters describe which flows are accepted @@ -222,8 +222,8 @@ Override fields take precedence over `brand.json` for that creative. | Tool | v1 | v2 | |---|---|---| | `get_products` | Returns products with `format_ids` | Returns products with either `format_ids` (v1 path) or `format` (v2 inline) | -| `sync_creatives` | Submit creative manifest | Unchanged. Sales agents accept manifests with assets and/or inputs per the format declaration. | -| `preview_creative` | Submit manifest, get preview | Same surface; preview shows output regardless of asset-driven vs input-driven submission. The single-render hoist in #3268 lands alongside v2. | +| `sync_creatives` | Submit creative manifest | Unchanged. Sales agents accept manifests with `assets` keyed by slot name per the format's `slots` declaration. | +| `preview_creative` | Submit manifest, get preview | Same surface; preview shows output regardless of whether the slots ship rendered creative (image/video/audio) or production content (script/brief/scenes). The single-render hoist in #3268 lands alongside v2. | | `validate_input` | (didn't exist) | New buyer dry-run primitive. Validates a manifest against canonical formats and/or specific products without committing to a render. Cheap; `predicted` field carries pre-flight estimates. | | `build_creative` | Generative tool on creative agents | Same role; creative-agent surface only. Sales agents do **not** expose `build_creative`. Creative agents may **also** expose `sync_creatives` for ad-server use cases. | | `list_creative_formats` | Both sales and creative agents | Deprecated. Sales agents migrate to `get_products`; creative agents to `creative.supported_formats`. v1 tool stays functional through 4.x. | @@ -241,7 +241,7 @@ Override fields take precedence over `brand.json` for that creative. ### Creative agents (transformation services like AudioStack, generative platforms) -1. Add `creative.supported_formats` to your `get_adcp_capabilities` response. Each entry is a `ProductFormatDeclaration` describing what canonical format you can produce, with what parameters and `inputs`. +1. Add `creative.supported_formats` to your `get_adcp_capabilities` response. Each entry is a `ProductFormatDeclaration` describing what canonical format you can produce, with parameter narrowing and `slots` declaring the assets you accept as production input. 2. Continue to support `build_creative` per the existing v1 contract — it's the same tool with the same shape. 3. If you're also acting as an ad server (rare but valid), expose `sync_creatives` alongside `build_creative`. @@ -250,7 +250,7 @@ Override fields take precedence over `brand.json` for that creative. 1. Update your client to read either `format_ids` (v1) or `format` (v2 inline) on products. 2. Use `validate_input` for cheap dry-run validation before committing to renders. 3. Use the canonical [`asset_group_id` vocabulary](https://adcontextprotocol.org/schemas/v3/core/asset-group-vocabulary.json) when constructing manifests; the registry's `aliases` field maps v1-era slot names to canonical equivalents. -4. Submit creative via `sync_creatives` as before. For input-driven products (host-read podcasts, generative video), either pre-produce via a creative agent's `build_creative` OR submit inputs directly and let the seller produce internally — both flows are valid. +4. Submit creative via `sync_creatives` as before. For products whose slots accept production content (host-read podcasts ship a `script` text asset; generative video ships a `creative_brief` and `scenes`), either pre-produce via a creative agent's `build_creative` to get back a manifest with rendered assets OR submit the production-content assets directly and let the seller produce internally — both flows are valid. ### Publisher direct (GAM/prebid path) @@ -268,21 +268,34 @@ Override fields take precedence over `brand.json` for that creative. | Creative agents (AudioStack-shaped) | Low, high motivation | 3.1-3.2 | | Publisher direct (GAM/prebid path) | Medium | Blocked on native canonical pre-audit | -## What stays on v1: `product_card` and `product_card_detailed` +## `product_card` and `product_card_detailed` are typed inline -The `product_card` and `product_card_detailed` fields on the `Product` object reference v1 format_ids (e.g., `product_card_standard`) even on v2 products. **This is a deliberate carve-out, not an oversight.** +The `product_card` and `product_card_detailed` fields on the `Product` object are typed inline structures in v2 — no `format_id` indirection, no manifest. They describe the **UI rendering of the product itself** (what catalog browsers, dashboards, and admin interfaces display so humans and agents can see what a product is). Distinct from `format` (which describes the ad creative the product accepts). -The two fields serve a different purpose than the `format` declaration: - -- `product.format` — describes the **ad creative** the product accepts (what the buyer ships, what the surface renders to end users) -- `product.product_card[_detailed]` — describes the **UI rendering of the product itself** (what catalog browsers, dashboards, and admin interfaces display so humans and agents can see what a product is) +```json test=false +"product_card": { + "image": { "asset_type": "image", "url": "https://...", "width": 300, "height": 400 }, + "title": "Meta Reels — United States", + "description": "9:16 vertical short-form video on Meta Reels.", + "price_label": "From $5.50 CPM", + "cta_label": "View details" +} -Mixing these would conflate "ad creative" with "product UI metadata." `product_card` keeps a fixed v1 format reference because: -- Its dimensions and structure are fixed (e.g., `product_card_standard` is a 300×400 visual card) -- The format isn't authored by sellers; it's an AdCP-defined display convention -- v2-only clients can ignore `product_card` entirely if they don't render product UIs +"product_card_detailed": { + "hero_image": { "asset_type": "image", "url": "...", "width": 1200, "height": 600 }, + "carousel_images": [ { "asset_type": "image", "url": "...", ... }, ... ], + "title": "Meta Reels — United States", + "description": "Full markdown-friendly product description...", + "specifications": [ + { "label": "Aspect ratio", "value": "9:16" }, + { "label": "Duration", "value": "3-90s" } + ], + "price_label": "From $5.50 CPM", + "cta_label": "Get proposal" +} +``` -Implication for v2-only consumers: if you parse a Product object and need to render its visual card, you still need to understand the v1 format_id reference for `product_card`/`product_card_detailed`. The `format` field uses the v2 declaration; the card fields stay v1. v2-only adopters who don't render product cards can ignore them. +Migration from v1 (where `product_card` was `{ format_id, manifest }` referencing a `product_card_standard` format file): drop the format reference; populate the typed fields directly. The image, title, and description flatten out of what was previously a manifest. v2-only adopters who don't render product cards can ignore both fields entirely. ## Validating your migration diff --git a/docs/creative/v2-overview.mdx b/docs/creative/v2-overview.mdx index f9ebb7632d..fb1b8fde06 100644 --- a/docs/creative/v2-overview.mdx +++ b/docs/creative/v2-overview.mdx @@ -17,7 +17,7 @@ v2 collapses today's separate format registry into product-bound declarations. A |---|---|---| | Format identity | Compound `{ agent_url, id }` referencing a separately-defined format file | Canonical name (e.g., `image`) keyed under `format` on the product, narrowed inline | | Format authoring | Each platform authors its own named format files | Platforms narrow AdCP-defined canonicals; canonical IS the contract buyers validate against | -| Format input contract | Each platform publishes a parallel set of `*_generated_*` format files for AI-produced creative alongside the asset-upload version (~30 duplicate files in agentic-adapters) | The format declares what it accepts via `inputs` (script, creative_brief, voice_id, starter_assets, etc.) and asset slots (image, video, audio uploads). Some products take only assets; some take only inputs; some take both. **Whether the seller's internal production is generative AI, host recording, transcoding, or asset rendering is invisible to the buyer.** No "generative" category at the protocol level; the production mechanism is implementation detail. | +| Format submission contract | Each platform publishes a parallel set of `*_generated_*` format files for AI-produced creative alongside the asset-upload version (~30 duplicate files in agentic-adapters) | The format declares a single `slots` array enumerating everything the buyer ships in the manifest's `assets` map, each entry a canonical `asset_group_id` paired with an `asset_type` (image / video / audio for direct rendering; text / brief / object / url for content the seller consumes for production). Buyer mental model is uniform — one `assets` map, no separate "inputs" concept. **Whether the seller's internal production is generative AI, host recording, transcoding, or asset rendering is invisible to the buyer.** No "generative" category at the protocol level; the production mechanism is implementation detail. | | Discovery | `list_creative_formats` (overloaded — used by both sales and creative agents) | `creative.supported_formats` on `get_adcp_capabilities` (uniform replacement, same `ProductFormatDeclaration` shape regardless of agent role); sales agents additionally expose `get_products` for product-level detail with `format` inline | | Tracking | Mixed across asset types and format definitions | Baked into each canonical format (VAST events for `video_vast`, MRAID+OM-SDK for `html5`, impression pixel for `image`) | | Brand identity | Sometimes redeclared as format slots | Implicit via `brand` (a [`BrandRef`](https://adcontextprotocol.org/schemas/v3/core/brand-ref.json) — `domain` plus optional `brand_id` for house-of-brands) resolving brand.json; explicit override via `brand_kit_override` on the manifest | @@ -162,7 +162,7 @@ Different canonical (`html5`, not `image`) because the tracking model is fundame ## Worked example — Podcast 30s host-read -Host-reads are the host-recorded-from-buyer-script pattern. The product declares `audio_hosted` narrowed to publisher-host-recorded mode with `inputs` describing what the buyer must provide: +Host-reads are the host-recorded-from-buyer-script pattern. The product declares `audio_hosted` narrowed to publisher-host-recorded mode with `slots` describing what the buyer ships (a `script` text asset; the publisher's host records audio from it): ```json test=false { @@ -212,23 +212,23 @@ The format declaration tells the buyer everything they need to know — no extra The buyer calls a creative agent's `build_creative` independently, gets back a rendered manifest, and submits that to the seller. Useful when the buyer has a preferred production partner (their in-house studio, AudioStack-style services) or the seller exposes itself as a creative agent. -1. Buyer reads The Daily's product format → sees `inputs: { script, brand }` declared -2. Buyer calls `build_creative({ format: , inputs: { script, brand } })` on a creative agent — this could be The Daily's own creative-agent surface (if they expose one), or any other agent that declares it can produce this format via its `creative.supported_formats` on `get_adcp_capabilities` +1. Buyer reads The Daily's product format → sees `slots: [{ asset_group_id: "script", asset_type: "text", required: true }]` declared +2. Buyer calls `build_creative({ format: , assets: { script: { asset_type: "text", content: "..." } }, brand: { domain: "..." } })` on a creative agent — this could be The Daily's own creative-agent surface (if they expose one), or any other agent that declares it can produce this format via its `creative.supported_formats` on `get_adcp_capabilities` 3. Receives a rendered manifest with audio asset 4. Submits the rendered manifest via `sync_creatives` to The Daily's sales agent ### Flow 2 — seller produces internally -The buyer submits inputs directly to the seller; the seller produces internally (calls its own creative team or an upstream creative agent under the hood) and returns a registered creative. +The buyer submits assets directly to the seller; the seller produces internally (calls its own creative team or an upstream creative agent under the hood) and returns a registered creative. 1. Buyer reads the same product format -2. Buyer submits via `sync_creatives` with the inputs in the manifest +2. Buyer submits via `sync_creatives` with the assets in the manifest (e.g., a `script` text asset under that slot in the `assets` map) 3. Seller produces internally; how is invisible to the buyer 4. Returns async status; buyer polls or waits for completion The format's `audio_source: "publisher_host_recorded"` + `buyer_audio_acceptance: "rejected"` tells the buyer which flows are accepted. For The Daily's host-read, both flows are valid because the publisher's host needs to be the producer in either case — the difference is whether the buyer drives the build call or the seller drives it. Other products might accept Flow 1 only (buyer must pre-produce) or Flow 2 only. -For brief-driven (talking-points-style) host-reads, the same shape applies with `creative_brief` in place of `script` in the inputs declaration. Same target format (`audio_hosted`); different input contract. +For brief-driven (talking-points-style) host-reads, the same shape applies with a `creative_brief` slot (asset_type `brief`) in place of the `script` slot. Same target format (`audio_hosted`); different slot declaration. ## Validation flow — `validate_input` @@ -239,7 +239,7 @@ Buyers can dry-run a manifest against canonicals and/or specific products withou "manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_30s" }, "assets": { - "video_main": { + "video": { "asset_type": "video", "url": "https://cdn.acme.example/spring-30s.mp4", "duration_ms": 30000, @@ -283,12 +283,12 @@ Response carries per-target results: ## Preview as the universal "what does this produce" surface -Whether the buyer submits assets (uploaded image/video/audio) or inputs (script, creative_brief, voice_id), `preview_creative` shows what the output renders as. The seller's response to a creative submission can also include a preview URL — the buyer doesn't need a separate preview call to verify that their submission produced the intended output. Same surface, two submission shapes: +Buyers ship assets per the format's `slots` declaration; `preview_creative` shows what the output renders as. The seller's response to a creative submission can also include a preview URL — the buyer doesn't need a separate preview call to verify that their submission produced the intended output. Same surface, two production paths: -- **Asset-driven submission**: buyer uploads finished creative assets → preview shows the rendered placement (with seller-side composition, overlays, CTA buttons applied) -- **Input-driven submission**: buyer submits script/brief/voice_id → seller produces internally (host recording, generative AI, asset rendering — invisible) → preview shows the produced output +- **Direct rendering**: buyer ships finished creative assets (image, video, audio) → seller renders them on the placement → preview shows the rendered output (with seller-side composition, overlays, CTA buttons applied). +- **Seller-side production**: buyer ships content the seller consumes (script text, creative_brief, voice_id selection) → seller produces the rendered asset internally (host recording, generative AI synthesis, transcoding — invisible) → preview shows the produced output. -The buyer can iterate on inputs and inspect previews before committing to a buy. Different sellers may produce differently internally; the preview surface is uniform. This is what makes "production mechanism is invisible to the buyer" workable in practice — the buyer doesn't need to know HOW the output was produced because they can see WHAT was produced. +The buyer can iterate on shipped assets and inspect previews before committing to a buy. Different sellers may produce differently internally; the preview surface is uniform. This is what makes "production mechanism is invisible to the buyer" workable in practice — the buyer doesn't need to know HOW the output was produced because they can see WHAT was produced. ## Brand identity via brand.json (with override) @@ -354,7 +354,7 @@ By design, v2 doesn't introduce new vocabulary for things AdCP already handles o - **`destination_kinds` as a new schema** — `url-asset.json` already has `url_type` covering URL kind disambiguation. Platform-specific destinations (Meta `messenger_thread`, etc.) are platform extensions. - **`cta_vocabulary` as a canonical pattern** — CTAs vary meaningfully across surfaces; we let products declare `cta_values` arrays inline until cross-platform demand emerges. - **`list_build_capabilities` as a separate tool** — folded into `get_adcp_capabilities` under `creative.supported_formats`. -- **`build_capability` and `build_capability_ref` as separate concepts** — collapsed into `inputs` directly on the format declaration. The format itself tells the buyer what it requires; how production happens (buyer pre-producing through a creative agent's `build_creative`, or seller producing internally) is the buyer's flow choice, not a separate protocol concept. +- **`build_capability`, `build_capability_ref`, and a separate `inputs` map** — collapsed into the canonical `slots` model on the format declaration. The format declares slots (canonical `asset_group_id` + `asset_type` + constraints); the manifest has a single `assets` map keyed by slot name; the seller dispatches per the format (render assets verbatim or consume them for production). The format itself tells the buyer what it requires; how production happens is implementation detail. ## Migration @@ -372,9 +372,10 @@ By design, v2 doesn't introduce new vocabulary for things AdCP already handles o | Phase | Status | What's in it | |---|---|---| -| Phase 1 | ✅ in #3307 | `asset_group_id` vocabulary registry, `scenes` schema, `zip` asset type, video/audio doc fixes | -| Phase 2 | ✅ in #3307 | 11 canonical format definitions, ProductFormatDeclaration, build capabilities, validate_input, brand_kit_override, platform-extension-ref | -| Phase 3 | TBD | Reference SDK codegen, server-side flatten wrappers, migration guide | +| Phase 1 | ✅ in #3307 | `asset_group_id` vocabulary registry (canonical entries + audit-grounded aliases), `scenes` schema, `zip` asset type, video/audio doc fixes | +| Phase 2 | ✅ in #3307 | 11 canonical format definitions with structured `slots` declaration, `ProductFormatDeclaration` (format_kind discriminator + params), `validate_input` primitive, `creative.supported_formats` on get_adcp_capabilities, `brand_kit_override`, `platform-extension-ref`, typed inline `product_card` / `product_card_detailed`, `format_ids` ⊕ `format` oneOf on Product | +| Phase 3 | ✅ in #3307 | v1↔v2 migration guide, 7 fully-validated reference Product fixtures + 1 get_products response fixture with bundled extensions, fixture-validation test suite (`npm run test:v2-fixtures`) | +| Phase 4 | TBD | Reference SDK codegen (TypeScript first, then Python), server-side flatten wrapper reference implementation | | Native canonical | TBD | Deferred to 3.2 after TemplateCreative + OpenRTB Native 1.2 audit | ## Related diff --git a/static/examples/products/v2/gam_3p_display_tag.json b/static/examples/products/v2/gam_3p_display_tag.json new file mode 100644 index 0000000000..6d21d9e9a1 --- /dev/null +++ b/static/examples/products/v2/gam_3p_display_tag.json @@ -0,0 +1,42 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "gam_publisher_3p_display_tag_300x250", + "name": "GAM Publisher — 3P Display Tag (300×250)", + "description": "Third-party-served display tag (JS or iframe) on a GAM-managed publisher placement. Buyer's adserver hosts the creative; the publisher calls the tag URL at impression time. 200KB max-snippet-size and a runtime allowlist (no eval, no document.write, no setTimeout, no javascript: / data: URLs in click trackers) apply at the GAM level — these are publisher-policy constraints, not protocol-level.", + "publisher_properties": [ + { "publisher_domain": "examplepublisher.example", "selection_type": "all" } + ], + "channels": ["display"], + "format": { + "format_kind": "display_tag", + "params": { + "width": 300, + "height": 250, + "supported_tag_types": ["iframe", "javascript", "1x1_redirect"], + "ssl_required": true, + "max_redirect_depth": 4, + "max_response_time_ms": 1500, + "backup_image_required": true, + "backup_image_max_size_kb": 50, + "om_sdk_required": false, + "composition_model": "deterministic", + "slots": [ + { "asset_group_id": "tag_url", "asset_type": "url", "required": true }, + { "asset_group_id": "backup_image", "asset_type": "image", "required": true, "max_size_kb": 50 }, + { "asset_group_id": "landing_page_url", "asset_type": "url", "required": false } + ] + } + }, + "delivery_type": "non_guaranteed", + "pricing_options": [ + { "pricing_option_id": "cpm_floor", "pricing_model": "cpm", "currency": "USD", "floor_price": 1.50 } + ], + "reporting_capabilities": { + "available_reporting_frequencies": ["daily"], + "expected_delay_minutes": 240, + "timezone": "UTC", + "supports_webhooks": false, + "available_metrics": ["impressions", "clicks", "spend", "ctr", "viewability"], + "date_range_support": "date_range" + } +} diff --git a/static/examples/products/v2/meta_carousel.json b/static/examples/products/v2/meta_carousel.json new file mode 100644 index 0000000000..b8c6d65eee --- /dev/null +++ b/static/examples/products/v2/meta_carousel.json @@ -0,0 +1,45 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "meta_carousel_us", + "name": "Meta Carousel — United States", + "description": "Multi-card swipeable carousel on Meta feed (Facebook + Instagram). 2-10 cards, square aspect, polymorphic per-card asset (image OR video). Each card carries its own headline + click URL. Surface composes the swipeable presentation; buyer ships the cards array.", + "publisher_properties": [ + { "publisher_domain": "meta.com", "selection_type": "all" } + ], + "channels": ["social"], + "format": { + "format_kind": "image_carousel", + "params": { + "card_aspect_ratio": "1:1", + "min_cards": 2, + "max_cards": 10, + "allowed_card_asset_types": ["image", "video"], + "card_image_max_file_size_kb": 30000, + "card_video_max_duration_ms": 240000, + "primary_text_max_chars": 125, + "card_headline_max_chars": 40, + "ssl_required": true, + "composition_model": "deterministic", + "slots": [ + { "asset_group_id": "cards", "asset_type": "object", "required": true, "min": 2, "max": 10 }, + { "asset_group_id": "primary_text", "asset_type": "text", "required": false, "max_chars": 125 }, + { "asset_group_id": "landing_page_url", "asset_type": "url", "required": true } + ], + "platform_extensions": [ + { "uri": "https://meta.adcp/extensions/meta_pixel", "digest": "sha256:a3f5b7c9d8e2f1a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2" } + ] + } + }, + "delivery_type": "non_guaranteed", + "pricing_options": [ + { "pricing_option_id": "cpm_floor", "pricing_model": "cpm", "currency": "USD", "floor_price": 4.50 } + ], + "reporting_capabilities": { + "available_reporting_frequencies": ["hourly", "daily"], + "expected_delay_minutes": 60, + "timezone": "America/Los_Angeles", + "supports_webhooks": true, + "available_metrics": ["impressions", "clicks", "spend", "ctr", "engagement_rate", "viewability"], + "date_range_support": "date_range" + } +} diff --git a/static/examples/products/v2/triton_daast_audio_30s.json b/static/examples/products/v2/triton_daast_audio_30s.json new file mode 100644 index 0000000000..544deb1017 --- /dev/null +++ b/static/examples/products/v2/triton_daast_audio_30s.json @@ -0,0 +1,38 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "triton_daast_audio_30s", + "name": "Triton Audio DAAST (30s)", + "description": "DAAST 1.1 audio tag on Triton-managed streaming radio inventory. Buyer ships a DAAST tag (URL or inline XML); the streaming server fires DAAST events (impression / quartiles / click / complete / error) inherent to the spec. Audio analog of VAST.", + "publisher_properties": [ + { "publisher_domain": "triton.example", "selection_type": "all" } + ], + "channels": ["streaming_audio", "radio"], + "format": { + "format_kind": "audio_daast", + "params": { + "daast_version": "1.1", + "duration_ms_exact": 30000, + "linear_required": true, + "max_wrapper_depth": 3, + "ssl_required": true, + "companion_image_required": false, + "composition_model": "deterministic", + "slots": [ + { "asset_group_id": "daast_tag", "asset_type": "daast", "required": true }, + { "asset_group_id": "landing_page_url", "asset_type": "url", "required": false } + ] + } + }, + "delivery_type": "non_guaranteed", + "pricing_options": [ + { "pricing_option_id": "cpm_floor", "pricing_model": "cpm", "currency": "USD", "floor_price": 12.00 } + ], + "reporting_capabilities": { + "available_reporting_frequencies": ["daily"], + "expected_delay_minutes": 1440, + "timezone": "UTC", + "supports_webhooks": false, + "available_metrics": ["impressions", "spend", "completion_rate", "completed_views", "quartile_data"], + "date_range_support": "date_range" + } +} diff --git a/static/examples/products/v2/veo_generative_video_15s.json b/static/examples/products/v2/veo_generative_video_15s.json new file mode 100644 index 0000000000..1ab4d6a41f --- /dev/null +++ b/static/examples/products/v2/veo_generative_video_15s.json @@ -0,0 +1,48 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "veo_generative_video_vertical_15s", + "name": "Veo — 15s Generative Vertical Video", + "description": "Generative video synthesis from a creative_brief plus structured scenes. Buyer ships a brief (≤500 chars) and a scenes plan (3 scenes summing to 15s); Veo synthesizes the video. Genuinely nondeterministic — synthesis from in-spec inputs may produce out-of-spec frames; the platform's post-synthesis QA loop validates and reseeds up to N attempts before surfacing output. If the QA loop exhausts, build_creative returns task_failed with synthesis_failed reason. provenance_required: true means every produced asset carries a C2PA provenance manifest attributing synthesis to Veo (not the seller).", + "publisher_properties": [ + { "publisher_domain": "veo.example", "selection_type": "all" } + ], + "channels": ["social", "olv"], + "format": { + "format_kind": "video_hosted", + "params": { + "orientation": "vertical", + "aspect_ratio": "9:16", + "duration_ms_exact": 15000, + "min_width": 1080, + "min_height": 1920, + "video_codecs": ["h264"], + "containers": ["mp4"], + "frame_rates": [24, 30], + "audio_source": "agent_synthesized", + "buyer_audio_acceptance": "rejected", + "captions": "recommended", + "composition_model": "deterministic", + "synthesis_nondeterministic": true, + "provenance_required": true, + "production_window_business_days": 0, + "slots": [ + { "asset_group_id": "creative_brief", "asset_type": "brief", "required": true, "max_chars": 500 }, + { "asset_group_id": "scenes", "asset_type": "object", "required": true, "min": 1, "max": 5 }, + { "asset_group_id": "style_reference", "asset_type": "image", "required": false }, + { "asset_group_id": "landing_page_url", "asset_type": "url", "required": false } + ] + } + }, + "delivery_type": "non_guaranteed", + "pricing_options": [ + { "pricing_option_id": "cpm_floor", "pricing_model": "cpm", "currency": "USD", "floor_price": 18.00 } + ], + "reporting_capabilities": { + "available_reporting_frequencies": ["daily"], + "expected_delay_minutes": 240, + "timezone": "UTC", + "supports_webhooks": false, + "available_metrics": ["impressions", "clicks", "spend", "completion_rate", "viewability"], + "date_range_support": "date_range" + } +} diff --git a/static/examples/products/v2/youtube_vast_preroll.json b/static/examples/products/v2/youtube_vast_preroll.json new file mode 100644 index 0000000000..5d587991da --- /dev/null +++ b/static/examples/products/v2/youtube_vast_preroll.json @@ -0,0 +1,55 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "youtube_vast_preroll_15s_skippable", + "name": "YouTube VAST Pre-roll (15s skippable, In-Stream)", + "description": "VAST tag pre-roll on YouTube In-Stream inventory, 16:9 horizontal, 5-second skippable threshold. Buyer ships a VAST 4.x tag (URL or inline XML); YouTube fires VAST events (impression / quartiles / click / complete / error / skip) inherent to the spec. VPAID 2.0 supported but discouraged — Google deprecates VPAID in 2026.", + "publisher_properties": [ + { "publisher_domain": "youtube.com", "selection_type": "all" } + ], + "channels": ["olv", "ctv"], + "format": { + "format_kind": "video_vast", + "params": { + "orientation": "horizontal", + "aspect_ratio": "16:9", + "vast_version": "4.2", + "vpaid_enabled": false, + "simid_supported": false, + "duration_ms_range": [6000, 30000], + "min_width": 1280, + "min_height": 720, + "linear_required": true, + "skippable_after_ms": 5000, + "max_wrapper_depth": 5, + "ssl_required": true, + "composition_model": "deterministic", + "slots": [ + { "asset_group_id": "vast_tag", "asset_type": "vast", "required": true }, + { "asset_group_id": "landing_page_url", "asset_type": "url", "required": false } + ], + "platform_extensions": [ + { "uri": "https://google.adcp/extensions/google_universal_ad_id", "digest": "sha256:b3c5e7f9a1c3e5b7d9f1a3c5e7b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5" } + ] + } + }, + "delivery_type": "non_guaranteed", + "pricing_options": [ + { + "pricing_option_id": "cpv_skippable", + "pricing_model": "cpv", + "currency": "USD", + "floor_price": 0.05, + "parameters": { + "view_threshold": { "duration_seconds": 30 } + } + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": ["daily"], + "expected_delay_minutes": 240, + "timezone": "America/Los_Angeles", + "supports_webhooks": false, + "available_metrics": ["impressions", "completed_views", "spend", "completion_rate", "viewability", "video_completions", "quartile_data"], + "date_range_support": "date_range" + } +} diff --git a/static/schemas/source/core/asset-group-vocabulary.json b/static/schemas/source/core/asset-group-vocabulary.json index 101d3f836e..393468e591 100644 --- a/static/schemas/source/core/asset-group-vocabulary.json +++ b/static/schemas/source/core/asset-group-vocabulary.json @@ -12,6 +12,12 @@ "typical_use": "Multiple short headline copy variants (Google PMax/RDA, Meta promoted_offerings, etc.)", "aliases": ["headline", "title", "tagline", "headline_text"] }, + "long_headlines": { + "description": "Pool of longer headline text variants (typically 60-90 chars) used by responsive formats that render in placements with more available text space.", + "asset_type": "text", + "typical_use": "Google Responsive Display Ads / Performance Max / Demand Gen — render alongside short `headlines` when the placement supports a longer headline string. Distinct from `headlines` (short, ≤30 chars typical).", + "aliases": ["long_headline_pool", "extended_headlines"] + }, "descriptions": { "description": "Pool of body description text variants.", "asset_type": "text", diff --git a/static/schemas/source/core/creative-manifest.json b/static/schemas/source/core/creative-manifest.json index f3921b78f8..12c21f15ab 100644 --- a/static/schemas/source/core/creative-manifest.json +++ b/static/schemas/source/core/creative-manifest.json @@ -11,7 +11,7 @@ }, "assets": { "type": "object", - "description": "Map of asset IDs to actual asset content. Each key MUST match an asset_id from the format's assets array (e.g., 'banner_image', 'clickthrough_url', 'video_file', 'vast_tag'). The asset_id is the technical identifier used to match assets to format requirements.\n\nEach asset value carries an `asset_type` discriminator (image, video, audio, vast, daast, text, markdown, url, html, css, webhook, javascript, brief, catalog) that selects the matching asset schema. Validators with OpenAPI-style discriminator support use `asset_type` to report errors against only the selected branch instead of all branches.", + "description": "Map of slot keys to actual asset content. v1 path: each key matches an `asset_id` from the format's `assets` array (e.g., 'banner_image', 'clickthrough_url', 'video_file', 'vast_tag'). v2 path: each key matches an `asset_group_id` from the format's `slots` declaration drawn from the canonical vocabulary registry (e.g., 'images_landscape', 'video', 'landing_page_url', 'vast_tag', 'script', 'creative_brief'). Either path produces the same envelope shape; only the slot-key vocabulary differs.\n\nEach asset value carries an `asset_type` discriminator (image, video, audio, vast, daast, text, markdown, url, html, css, webhook, javascript, brief, catalog, zip) that selects the matching asset schema. Validators with OpenAPI-style discriminator support use `asset_type` to report errors against only the selected branch instead of all branches.", "patternProperties": { "^[a-z0-9_]+$": { "oneOf": [ diff --git a/static/schemas/source/core/product.json b/static/schemas/source/core/product.json index ef174c0e68..09c8fdd73f 100644 --- a/static/schemas/source/core/product.json +++ b/static/schemas/source/core/product.json @@ -43,7 +43,7 @@ }, "format": { "$ref": "/schemas/core/product-format-declaration.json", - "description": "v2 path: inline format declaration keyed by canonical format name. Products narrow exactly one canonical format with platform-specific parameters, optional `inputs` describing what the format requires from the buyer, and optional platform extensions. Mutually exclusive with `format_ids` — a product is either v1 (references named formats) or v2 (carries inline declaration), not both." + "description": "v2 path: inline format declaration. Discriminated by `format_kind` (the canonical format name) with `params` carrying that canonical's parameter schema (slots, dimensions, durations, codec ranges, character limits, platform_extensions, tracking_extensions). The format's `slots` array enumerates everything the buyer ships in the manifest's `assets` map. Mutually exclusive with `format_ids` — a product is either v1 (references named formats) or v2 (carries inline declaration), not both." }, "placements": { "type": "array", diff --git a/static/schemas/source/formats/canonical/_base.json b/static/schemas/source/formats/canonical/_base.json index b20c2ea0e2..e5aec5775d 100644 --- a/static/schemas/source/formats/canonical/_base.json +++ b/static/schemas/source/formats/canonical/_base.json @@ -31,15 +31,20 @@ }, "slots": { "type": "array", - "description": "Programmatic declaration of which canonical asset_group_id slots a manifest targeting this format must (or may) populate. Lets SDK codegen and validators enumerate expected slots without parsing the format's prose description. Each entry references an asset_group_id from the canonical vocabulary registry. Format-specific narrowing parameters (e.g., headline_max_chars on responsive_creative) live as flat properties on the format declaration; this `slots` field captures the structural slot inventory.", + "description": "Programmatic declaration of which canonical asset_group_id slots a manifest targeting this format must (or may) populate. Lets SDK codegen and validators enumerate expected slots without parsing the format's prose description. Each entry references an asset_group_id from the canonical vocabulary registry, paired with an `asset_type` so the validator knows which asset schema to apply. Format-level narrowing parameters that apply across all slots (e.g., flat `headline_max_chars` on responsive_creative) may also live on the format declaration; per-slot constraints (a specific slot's `max_chars` or `max_size_kb`) live on the slot entry.", "items": { "type": "object", - "required": ["asset_group_id"], + "required": ["asset_group_id", "asset_type"], "properties": { "asset_group_id": { "type": "string", "description": "Canonical asset_group_id from /schemas/core/asset-group-vocabulary.json. Non-canonical IDs are valid but trigger soft warnings." }, + "asset_type": { + "type": "string", + "enum": ["image", "video", "audio", "text", "markdown", "url", "html", "css", "javascript", "vast", "daast", "webhook", "brief", "catalog", "zip", "object"], + "description": "Discriminator selecting the asset schema this slot accepts. SDK codegen uses this to type the slot value. `object` is a fallback for structured non-asset inputs that don't fit a primitive asset_type (rare; prefer specific types when possible)." + }, "required": { "type": "boolean", "description": "Whether this slot is required for a valid manifest.", @@ -54,6 +59,20 @@ "type": "integer", "minimum": 1, "description": "Maximum count for repeatable / pool slots." + }, + "max_chars": { + "type": "integer", + "minimum": 1, + "description": "Per-slot character limit for text / markdown / brief assets." + }, + "max_size_kb": { + "type": "integer", + "minimum": 1, + "description": "Per-slot file size limit in kilobytes for image / video / audio / zip assets." + }, + "description": { + "type": "string", + "description": "Human-readable description of what the slot expects from the buyer." } }, "additionalProperties": true @@ -65,5 +84,6 @@ "description": "Typical production turnaround in business days when the format requires seller-side production (e.g., host-recording from a buyer-supplied script). 0 for synchronous (e.g., generative AI); >0 for human-produced (e.g., podcast host-read). Absent when no production is required (buyer uploads complete creative)." } }, - "additionalProperties": true + "additionalProperties": true, + "x-canonical-policy-required-params-not-enforced": "Canonical format schemas intentionally do NOT mark any of their parameter fields as `required` and use `additionalProperties: true`. The canonical is the loose contract — describing what parameters MAY narrow it. Products narrow the canonical with concrete values (and may also leave most parameters absent — buyers infer behavior from the canonical's prose description for any unspecified parameter). This is intentional, not an oversight: enforcing required params on the canonical would force every product to declare every parameter, which is the wrong direction (we want products to declare only the values that differ from sensible canonical defaults). Adopters writing strict validators should validate against the canonical's structural shape (what fields ARE valid) and apply business rules separately for product-level narrowing minima." } diff --git a/static/schemas/source/formats/canonical/agent_placement.json b/static/schemas/source/formats/canonical/agent_placement.json index fbb4c69d36..e0697791b1 100644 --- a/static/schemas/source/formats/canonical/agent_placement.json +++ b/static/schemas/source/formats/canonical/agent_placement.json @@ -7,8 +7,8 @@ "properties": { "slots": { "default": [ - { "asset_group_id": "offering_ref", "required": false, "asset_type": "text" }, - { "asset_group_id": "landing_page_url", "required": false, "asset_type": "url" } + { "asset_group_id": "offering_ref", "asset_type": "text", "required": false }, + { "asset_group_id": "landing_page_url", "asset_type": "url", "required": false } ], "description": "agent_placement has minimal buyer-shipped slots — the surface composes the rendered output from brand context (resolved via the manifest's top-level `brand` BrandRef) plus optional offering_ref and landing_page_url assets. None of these assets are rendered verbatim by the buyer; the agent chooses how to use them." }, diff --git a/static/schemas/source/formats/canonical/responsive_creative.json b/static/schemas/source/formats/canonical/responsive_creative.json index c367362936..6204e8c2af 100644 --- a/static/schemas/source/formats/canonical/responsive_creative.json +++ b/static/schemas/source/formats/canonical/responsive_creative.json @@ -7,15 +7,15 @@ "properties": { "slots": { "default": [ - { "asset_group_id": "headlines", "required": true, "min": 3, "max": 15 }, - { "asset_group_id": "long_headlines", "required": false, "min": 1, "max": 5 }, - { "asset_group_id": "descriptions", "required": true, "min": 2, "max": 5 }, - { "asset_group_id": "images_landscape", "required": false, "min": 1, "max": 20 }, - { "asset_group_id": "images_square", "required": false, "min": 1, "max": 20 }, - { "asset_group_id": "images_vertical", "required": false, "min": 1, "max": 20 }, - { "asset_group_id": "video", "required": false, "min": 0, "max": 5 }, - { "asset_group_id": "logo", "required": true, "min": 1, "max": 5 }, - { "asset_group_id": "landing_page_url", "required": true, "min": 1, "max": 1 } + { "asset_group_id": "headlines", "asset_type": "text", "required": true, "min": 3, "max": 15 }, + { "asset_group_id": "long_headlines", "asset_type": "text", "required": false, "min": 1, "max": 5 }, + { "asset_group_id": "descriptions", "asset_type": "text", "required": true, "min": 2, "max": 5 }, + { "asset_group_id": "images_landscape", "asset_type": "image", "required": false, "min": 1, "max": 20 }, + { "asset_group_id": "images_square", "asset_type": "image", "required": false, "min": 1, "max": 20 }, + { "asset_group_id": "images_vertical", "asset_type": "image", "required": false, "min": 1, "max": 20 }, + { "asset_group_id": "video", "asset_type": "video", "required": false, "min": 0, "max": 5 }, + { "asset_group_id": "logo", "asset_type": "image", "required": true, "min": 1, "max": 5 }, + { "asset_group_id": "landing_page_url", "asset_type": "url", "required": true, "min": 1, "max": 1 } ] }, "headlines_min": { "type": "integer", "minimum": 0 }, diff --git a/static/schemas/source/formats/canonical/sponsored_placement.json b/static/schemas/source/formats/canonical/sponsored_placement.json index 7d4129ee14..7193fb33a7 100644 --- a/static/schemas/source/formats/canonical/sponsored_placement.json +++ b/static/schemas/source/formats/canonical/sponsored_placement.json @@ -5,6 +5,13 @@ "description": "Catalog-driven retail-media format. Slot: `source_catalog` (catalog asset — product/SKU/ASIN/GTIN catalog reference), 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. Distinct from `responsive_creative` (algorithmic combinator from buyer pool) and `agent_placement` (text/audio AI-surface composition).", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { + "slots": { + "default": [ + { "asset_group_id": "source_catalog", "required": true, "asset_type": "catalog" }, + { "asset_group_id": "hero_asset", "required": false, "asset_type": "image" }, + { "asset_group_id": "landing_page_url", "required": false, "asset_type": "url" } + ] + }, "supported_catalog_types": { "type": "array", "items": { From c5de39c44dccf9ac582627820cd3de2baa24b361 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 28 Apr 2026 20:12:53 -0400 Subject: [PATCH 17/41] docs(creative): server-side implementation considerations for v2 sales agents Reviewer flagged three implementation-side hooks v2 introduces that existing server implementations (e.g., the salesagent reference impl) don't have today. Added a "Server-side implementation considerations" subsection to the v2-migration.mdx sales-agent migration path: - sync_creatives provenance verification when format.params.provenance_ required: true. Natural extension of existing AI-provenance tracking (EU AI Act Article 50); the new piece is a validation hook that gates submission of unsigned synthesized assets. - get_products response gathers extension definitions when products carry platform_extensions. Trivial when no v2 declarations; only kicks in for opt-in tenants. - production_window_business_days on host-read / agent-produced products. Most server impls don't model production turnaround today; v2 makes it declarable. Pure docs change; no schema impact. 13 fixtures still pass strict validation. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/creative/v2-migration.mdx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/creative/v2-migration.mdx b/docs/creative/v2-migration.mdx index 83c1074eaf..38a952a16f 100644 --- a/docs/creative/v2-migration.mdx +++ b/docs/creative/v2-migration.mdx @@ -239,6 +239,14 @@ Override fields take precedence over `brand.json` for that creative. 5. **Flatten wrapper**: implement a server-side wrapper that derives the v1 `list_creative_formats` shape from v2 product declarations. Lets v1-era dashboards and tooling keep working. 6. **Deprecate timing**: at 5.0, remove v1 `format_ids` references on your products. Until then, both paths coexist. +#### Server-side implementation considerations + +Three concrete hooks v2 introduces that existing seller implementations don't have today: + +- **`sync_creatives` provenance verification when `provenance_required: true`.** When a v2 product's format declaration carries `provenance_required: true` (and the buyer's manifest contains synthesized assets — typically video/image from generative platforms), `sync_creatives` MUST verify a C2PA-compatible provenance manifest is attached and reject unsigned synthesized assets. This is a natural extension of existing AI-provenance tracking on Creative (EU AI Act Article 50 work) — the new piece is a validation hook that gates submission. Sellers without existing provenance plumbing only need this once they ship a v2 product with the flag set; until then it's no-op. +- **`get_products` response gathers extension definitions.** When products carry v2 `format.params.platform_extensions` references, the response SHOULD include the referenced extension definitions in the `extensions` map keyed by `@sha256:`. Implementations gather extensions referenced by any product in the response, dedupe by digest, and emit. Buyers cache by URI@digest; subsequent responses MAY omit definitions the buyer already has cached. Trivial when no products use v2 declarations; only kicks in when tenants opt in. +- **`production_window_business_days` on host-read / agent-produced products.** Today most server implementations don't model production turnaround on Products — the field is a v2 addition. Only matters once a tenant ships a v2 host-read or generative-video product (audio_hosted with `audio_source: 'publisher_host_recorded'`, or any product with `synthesis_nondeterministic: true`). Today many of these flows route through hand-trafficked sponsorships and don't surface turnaround over the protocol; v2 makes it declarable. + ### Creative agents (transformation services like AudioStack, generative platforms) 1. Add `creative.supported_formats` to your `get_adcp_capabilities` response. Each entry is a `ProductFormatDeclaration` describing what canonical format you can produce, with parameter narrowing and `slots` declaring the assets you accept as production input. From 19e6a301394cae5b49b06de971f925c5b774087e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 29 Apr 2026 21:13:43 -0400 Subject: [PATCH 18/41] =?UTF-8?q?feat(creative):=20v2=20review-feedback=20?= =?UTF-8?q?round=20=E2=80=94=20format=5Foptions=20array,=20canonical=20sta?= =?UTF-8?q?tus,=20hosting=20paragraph,=20third-party=20creative-agent=20wo?= =?UTF-8?q?rked=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema: - Rename product.format → product.format_options (array of ProductFormatDeclaration). Restores v1 format_ids cardinality on the v2 path. The 90% case is single-element; multi-element declares "accepts any of" (e.g., Flashtalking-served html5 OR internal display_tag; hosted video OR VAST tag). Mutually exclusive with format_ids. - Add status: stable | preview | deprecated to canonical _base.json. Default stable. Mark agent_placement and responsive_creative as preview in 3.1, with a note that schemas may break in 3.2 once 2-3 adopters land. Other 9 canonicals stay stable (anchored in IAB / platform standards). Docs: - New worked example in v2-overview.mdx: third-party creative agent path (Flashtalking + NYTimes display). Multi-actor walkthrough alongside the existing single-actor host-read. Documents that the seller validates against the canonical, not against the creative agent's narrowing — that's the creative agent's contract with the buyer. - Platform extension hosting paragraph added to v2-overview.mdx: publisher subdomain hosts canonical artifact; immutable caching enabled by digest pinning; ≥99.9% / 30-day availability target; 404 degrades gracefully (extension unavailable, don't fail the buy); AAO mirror is best-effort fallback. - Adoption-driven format_ids removal trigger documented in v2-migration.mdx: AAO computes format_options adoption ratio from cached get_products responses; 5.0 cut sequence opens when the ratio crosses 80% for 30 consecutive days. Replaces calendar trigger. Schema housekeeping: - validate-input-response.json description documents the intent behind the 3-schema split (Result reused by planned async-validation surfaces — build_creative async paths, sync_creatives async validation). - 12 v2 product fixtures + 1 get_products response fixture migrated to format_options array. All 13 still validate via npm run test:v2-fixtures. - tests/schema-validation.test.cjs core-required-fields rule updated to assert format_options on the v2 oneOf branch. Tracks #3305 (RFC) and #3307 (preview branch). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../v2-review-feedback-format-options.md | 28 ++ docs/creative/v2-migration.mdx | 46 +-- docs/creative/v2-overview.mdx | 318 ++++++++++++------ .../v2/meta_with_bundled_extensions.json | 158 ++++----- .../v2/amazon_sponsored_products.json | 46 +-- .../products/v2/chatgpt_brand_mention.json | 74 ++-- .../products/v2/gam_3p_display_tag.json | 89 +++-- .../products/v2/google_performance_max.json | 90 ++--- .../examples/products/v2/meta_carousel.json | 101 ++++-- .../examples/products/v2/meta_reels_us.json | 90 ++--- .../products/v2/nytimes_homepage_html5.json | 40 +-- .../products/v2/nytimes_homepage_mrec.json | 56 +-- .../products/v2/the_daily_30s_host_read.json | 78 ++--- .../products/v2/triton_daast_audio_30s.json | 73 ++-- .../products/v2/veo_generative_video_15s.json | 111 ++++-- .../products/v2/youtube_vast_preroll.json | 95 ++++-- static/schemas/source/core/product.json | 18 +- .../creative/validate-input-response.json | 2 +- .../source/formats/canonical/_base.json | 6 + .../formats/canonical/agent_placement.json | 5 +- .../canonical/responsive_creative.json | 5 +- tests/schema-validation.test.cjs | 6 +- 22 files changed, 954 insertions(+), 581 deletions(-) create mode 100644 .changeset/v2-review-feedback-format-options.md diff --git a/.changeset/v2-review-feedback-format-options.md b/.changeset/v2-review-feedback-format-options.md new file mode 100644 index 0000000000..49e06d9982 --- /dev/null +++ b/.changeset/v2-review-feedback-format-options.md @@ -0,0 +1,28 @@ +--- +"adcontextprotocol": minor +--- + +feat(creative): v2 review-feedback round — `format_options` array, canonical `status`, hosting paragraph, third-party creative-agent worked example + +Addresses external review feedback on RFC #3305 / PR #3307 before the 3.1.0 beta cycle opens. + +**Schema changes:** + +- **`product.format` → `product.format_options: [ProductFormatDeclaration]` (array).** Restores v1 `format_ids` cardinality on the v2 path. The 90% case is a single-element array (one canonical narrowed for the product); multi-element arrays declare that the product accepts any of the listed format options (e.g., a placement that takes EITHER Flashtalking-served `html5` OR an internal `display_tag`; a video product that accepts a hosted upload OR a VAST tag). Buyers pick which option they're shipping at `sync_creatives` time by aligning their manifest to the matching declaration's `format_kind`. Mutually exclusive with `format_ids` via the existing `oneOf`. +- **`status: "stable" | "preview" | "deprecated"` field on canonical format `_base.json`.** Default `stable`. Lets the spec ship not-yet-fully-settled canonicals (`agent_placement` and `responsive_creative` in 3.1) with explicit notice that their parameter shape and tracking model MAY break in 3.2 once 2-3 adopters have built against them. The other 9 canonicals are anchored in stable IAB / platform standards and stay `stable`. + +**Doc changes:** + +- **Worked example: third-party creative agent path (Flashtalking + NYTimes display).** Adds a multi-actor walkthrough alongside the existing single-actor host-read example: buyer reads NYTimes capabilities → sees declared `creative_agents` and the resolved `supported_formats` projection → calls Flashtalking's `build_creative` → ships the manifest to NYTimes via `sync_creatives`. The seller validates against the canonical, NOT against Flashtalking's narrowing — that's the creative agent's contract with the buyer. Closes a gap where the r4 collapse of `build_capability` into format slots wasn't documented for the third-party-creative-agent flow. +- **Platform extension hosting expectations.** Adds a paragraph to the "Platform extensions — distribution" section documenting hosting role (publisher's subdomain hosts the canonical artifact), caching expectations (`Cache-Control: public, max-age=31536000, immutable` enabled by digest pinning), availability targets (≥99.9% / 30 days), and graceful-degradation semantics on 404 (treat extension as unavailable; don't fail the buy). AAO mirror is best-effort fallback, not normative. +- **Adoption-driven `format_ids` removal trigger.** v1 `format_ids` is removed in 5.0 — but the trigger is adoption-driven, not date-driven. AAO computes the ratio of registered sales agents declaring `format_options` from cached `get_products` capabilities responses. When `format_options` adoption crosses 80% and stays there for 30 consecutive days, the 5.0 cut sequence opens. Until then, both shapes remain valid. + +**Schema housekeeping:** + +- Added a description note on `validate-input-response.json` documenting the intent behind the 3-schema split (`request` / `response` / `result`): the `Result` type is split for planned reuse by adjacent async-validation surfaces (per-batch result envelopes on `build_creative` async paths, asynchronous canonical-against-product validation in `sync_creatives`). Producers that only need the synchronous batch shape today MAY treat the split as YAGNI; the schema reuse anchors the violation/retry shape so downstream surfaces don't drift. +- Updated all 12 v2 reference fixtures (`static/examples/products/v2/*.json`) plus the `meta_with_bundled_extensions.json` get_products response fixture to use the new `format_options` array shape. All 13 fixtures still validate via `npm run test:v2-fixtures`. +- Updated `tests/schema-validation.test.cjs` core-required-fields rule to assert `format_options` (not `format`) on the v2 oneOf branch. + +**Why minor:** structural rename of `product.format` → `product.format_options` is technically breaking for anyone who built against the v2 path during the preview window, but the v2 path was only landed in this PR (#3307) and is not yet released — no published 3.x version carries `format`. The shipping shape is `format_options`. Anyone building against the preview branch should re-pull. The other changes are additive. + +Tracks #3305 (v2 RFC) and #3307 (preview branch). diff --git a/docs/creative/v2-migration.mdx b/docs/creative/v2-migration.mdx index 38a952a16f..13377d9a7f 100644 --- a/docs/creative/v2-migration.mdx +++ b/docs/creative/v2-migration.mdx @@ -64,36 +64,38 @@ Most of AdCP doesn't change. v2 builds on the existing primitives: } ``` -### v2 — inline format declaration on the product +### v2 — inline format declarations on the product ```json test=false { "product_id": "meta_reels_us", "name": "Meta Reels — United States", - "format": { - "format_kind": "video_hosted", - "params": { - "orientation": "vertical", - "aspect_ratio": "9:16", - "duration_ms_range": [3000, 90000], - "min_width": 1080, - "min_height": 1920, - "video_codecs": ["h264"], - "containers": ["mp4"], - "headline_max_chars": 25, - "primary_text_max_chars": 72, - "cta_values": ["LEARN_MORE", "SHOP_NOW", "DOWNLOAD", "SIGN_UP"], - "composition_model": "deterministic", - "platform_extensions": [ - { "uri": "https://meta.adcp/extensions/meta_pixel", "digest": "sha256:..." } - ] + "format_options": [ + { + "format_kind": "video_hosted", + "params": { + "orientation": "vertical", + "aspect_ratio": "9:16", + "duration_ms_range": [3000, 90000], + "min_width": 1080, + "min_height": 1920, + "video_codecs": ["h264"], + "containers": ["mp4"], + "headline_max_chars": 25, + "primary_text_max_chars": 72, + "cta_values": ["LEARN_MORE", "SHOP_NOW", "DOWNLOAD", "SIGN_UP"], + "composition_model": "deterministic", + "platform_extensions": [ + { "uri": "https://meta.adcp/extensions/meta_pixel", "digest": "sha256:..." } + ] + } } - }, + ], // ... pricing, targeting, etc. } ``` -The v2 `format` is a discriminated union: `format_kind` names the canonical format; `params` carries that canonical's parameter schema. SDKs codegen clean tagged unions in TypeScript and Pydantic. A v2 product is **either** v1 (uses `format_ids`) **or** v2 (uses `format`) — not both. The product schema's `oneOf` enforces this. +`format_options` is an array. The 90% case is one element — one canonical narrowed for the product. Multi-element arrays declare that the product accepts any of the listed format options, picked by the buyer at `sync_creatives` time. Common multi-element use cases: a placement that accepts EITHER a third-party-hosted creative (e.g., Flashtalking-served `html5`) OR an internal `display_tag`; a video product that accepts a hosted upload (`video_hosted`) OR a tag (`video_vast`). Each entry is a discriminated union: `format_kind` names the canonical format; `params` carries that canonical's parameter schema. SDKs codegen clean tagged unions in TypeScript and Pydantic. A v2 product is **either** v1 (uses `format_ids`) **or** v2 (uses `format_options`) — not both. The product schema's `oneOf` enforces this. For 12 fully-validated worked examples spanning all 11 canonical formats (Meta Reels, IAB MREC, NYTimes HTML5, podcast host-read, Amazon Sponsored Products, Google PMax, ChatGPT brand mention, Meta Carousel, YouTube VAST pre-roll, Triton DAAST audio, Veo generative video, GAM 3P display tag), see `static/examples/products/v2/`. The Veo fixture exercises `synthesis_nondeterministic: true` and `provenance_required: true`. Each fixture passes `npm run test:v2-fixtures`. @@ -276,6 +278,10 @@ Three concrete hooks v2 introduces that existing seller implementations don't ha | Creative agents (AudioStack-shaped) | Low, high motivation | 3.1-3.2 | | Publisher direct (GAM/prebid path) | Medium | Blocked on native canonical pre-audit | +### When does v1 `format_ids` get removed? + +The `oneOf(format_ids, format_options)` shape on `Product` persists through 4.x — every validator, codegen, and adopter has to handle both shapes. The 5.0 cut is **adoption-driven, not date-driven**. AAO computes the ratio of registered sales agents declaring `format_options` (or `format_ids`) from cached `get_products` capabilities responses. When `format_options` adoption crosses 80% and stays there for 30 consecutive days, the 5.0 cut sequence opens (deprecation warnings escalate; the next major drops `format_ids`). Until that signal trips, both shapes remain valid and supported. This protects adopters whose org reality (legacy ad-server integrations, walled-garden translation gaps) makes immediate migration impractical, while giving the spec a measurable signal that the migration is broadly complete. + ## `product_card` and `product_card_detailed` are typed inline The `product_card` and `product_card_detailed` fields on the `Product` object are typed inline structures in v2 — no `format_id` indirection, no manifest. They describe the **UI rendering of the product itself** (what catalog browsers, dashboards, and admin interfaces display so humans and agents can see what a product is). Distinct from `format` (which describes the ad creative the product accepts). diff --git a/docs/creative/v2-overview.mdx b/docs/creative/v2-overview.mdx index fb1b8fde06..70190bbc9d 100644 --- a/docs/creative/v2-overview.mdx +++ b/docs/creative/v2-overview.mdx @@ -26,19 +26,21 @@ v2 collapses today's separate format registry into product-bound declarations. A Each canonical lives at `/schemas/formats/canonical/.json`. Tracking model is **format-specific** (split by tracking model is why we have 11 instead of, say, 5). -| Canonical | What it is | Tracking | -|---|---|---| -| `image` | Static image, file or hosted URL redirect | Impression pixel + click URL via `universal_macros` | -| `html5` | Interactive HTML5 banner (zip asset) | MRAID + OM-SDK + click-tag macro + backup image | -| `display_tag` | Third-party JS/iframe tag URL | Opaque to seller | -| `image_carousel` | Multi-card swipe (polymorphic image/video items) | Per-card pixels + carousel engagement | -| `video_hosted` | Direct video file, orientation parameter | OM-SDK + external impression/click/quartile trackers | -| `video_vast` | VAST tag (URL or inline XML), VAST 2-4.x | Inherent VAST events | -| `audio_hosted` | Direct audio file (or host-read produced via build_creative) | Standard audio impression/completion | -| `audio_daast` | DAAST tag | Inherent DAAST events | -| `sponsored_placement` | Retail-media catalog-driven (Amazon SP, Criteo SP, CitrusAd SP) | Per-item catalog-keyed events | -| `responsive_creative` | Buyer asset pool, surface composes combinations (Google Responsive Display/Search Ads, Performance Max, Demand Gen; Meta Advantage+ creative) | Per-asset performance breakdown | -| `agent_placement` | Sponsored placement composed by an AI surface in response to a user query (ChatGPT, Perplexity, voice assistants, sponsored search snippets). Distinct from `si_chat` (brand-owned conversation; user → brand's agent). | Mention-level impression + attribution | +| Canonical | Status | What it is | Tracking | +|---|---|---|---| +| `image` | stable | Static image, file or hosted URL redirect | Impression pixel + click URL via `universal_macros` | +| `html5` | stable | Interactive HTML5 banner (zip asset) | MRAID + OM-SDK + click-tag macro + backup image | +| `display_tag` | stable | Third-party JS/iframe tag URL | Opaque to seller | +| `image_carousel` | stable | Multi-card swipe (polymorphic image/video items) | Per-card pixels + carousel engagement | +| `video_hosted` | stable | Direct video file, orientation parameter | OM-SDK + external impression/click/quartile trackers | +| `video_vast` | stable | VAST tag (URL or inline XML), VAST 2-4.x | Inherent VAST events | +| `audio_hosted` | stable | Direct audio file (or host-read produced via build_creative) | Standard audio impression/completion | +| `audio_daast` | stable | DAAST tag | Inherent DAAST events | +| `sponsored_placement` | stable | Retail-media catalog-driven (Amazon SP, Criteo SP, CitrusAd SP) | Per-item catalog-keyed events | +| `responsive_creative` | **preview** | Buyer asset pool, surface composes combinations (Google Responsive Display/Search Ads, Performance Max, Demand Gen; Meta Advantage+ creative) | Per-asset performance breakdown | +| `agent_placement` | **preview** | Sponsored placement composed by an AI surface in response to a user query (ChatGPT, Perplexity, voice assistants, sponsored search snippets). Distinct from `si_chat` (brand-owned conversation; user → brand's agent). | Mention-level impression + attribution | + +The two `preview` canonicals (`responsive_creative`, `agent_placement`) carry surfaces whose composition models are still settling — Google PMax / Meta Advantage+ for responsive; ChatGPT / Perplexity / voice assistants for agent_placement. Their parameter shape and tracking model MAY break in 3.2 once 2-3 adopters have built against them. Buyers SHOULD plan for migration; sellers SHOULD treat preview-canonical narrowing as experimental contract surface, not a long-term commitment. The other 9 canonicals are anchored in stable IAB / platform standards (IAB display dimensions, IAB VAST 4.2, IAB DAAST 1.1, retail-media catalog conventions) and are committed. ## Worked example — Meta Reels @@ -52,34 +54,36 @@ Meta Reels narrows `video_hosted` (vertical orientation) with Meta-specific para { "publisher_domain": "meta.com", "selection_type": "all" } ], "channels": ["social"], - "format": { - "format_kind": "video_hosted", - "params": { - "orientation": "vertical", - "aspect_ratio": "9:16", - "duration_ms_range": [3000, 90000], - "min_width": 1080, - "min_height": 1920, - "max_file_size_mb": 200, - "video_codecs": ["h264"], - "audio_codecs": ["aac"], - "headline_max_chars": 25, - "primary_text_max_chars": 72, - "captions": "recommended", - "cta_values": ["LEARN_MORE", "SHOP_NOW", "DOWNLOAD", "SIGN_UP"], - "composition_model": "deterministic", - "platform_extensions": [ - { - "uri": "https://meta.adcp/extensions/meta_pixel", - "digest": "sha256:a3f5b7c9d8e2f1a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2" - }, - { - "uri": "https://meta.adcp/extensions/meta_placements_reels", - "digest": "sha256:b8e1d4f6a9c2b5e8d1f4a7c0b3e6d9f2a5c8b1e4d7f0a3c6b9e2d5f8a1c4b7e0" - } - ] + "format_options": [ + { + "format_kind": "video_hosted", + "params": { + "orientation": "vertical", + "aspect_ratio": "9:16", + "duration_ms_range": [3000, 90000], + "min_width": 1080, + "min_height": 1920, + "max_file_size_mb": 200, + "video_codecs": ["h264"], + "audio_codecs": ["aac"], + "headline_max_chars": 25, + "primary_text_max_chars": 72, + "captions": "recommended", + "cta_values": ["LEARN_MORE", "SHOP_NOW", "DOWNLOAD", "SIGN_UP"], + "composition_model": "deterministic", + "platform_extensions": [ + { + "uri": "https://meta.adcp/extensions/meta_pixel", + "digest": "sha256:a3f5b7c9d8e2f1a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2" + }, + { + "uri": "https://meta.adcp/extensions/meta_placements_reels", + "digest": "sha256:b8e1d4f6a9c2b5e8d1f4a7c0b3e6d9f2a5c8b1e4d7f0a3c6b9e2d5f8a1c4b7e0" + } + ] + } } - }, + ], "pricing_options": [ { "pricing_option_id": "cpm_floor", "pricing_model": "cpm", "currency": "USD", "fixed_price": 5.50 } ] @@ -100,24 +104,26 @@ The canonical-as-contract value is clearest for IAB-standard formats. NYTimes an { "publisher_domain": "nytimes.com", "selection_type": "all" } ], "channels": ["display"], - "format": { - "format_kind": "image", - "params": { - "width": 300, - "height": 250, - "max_file_size_kb": 200, - "image_formats": ["jpg", "png", "gif"], - "ssl_required": true, - "cta_values": ["LEARN_MORE", "SHOP_NOW", "GET_OFFER"], - "composition_model": "deterministic", - "platform_extensions": [ - { - "uri": "https://nytimes.adcp/extensions/nytimes_om_strict", - "digest": "sha256:c9d2f5b8e1a4c7b0e3d6f9a2c5b8e1d4f7a0c3b6e9d2f5a8c1b4e7d0f3a6c9b2" - } - ] + "format_options": [ + { + "format_kind": "image", + "params": { + "width": 300, + "height": 250, + "max_file_size_kb": 200, + "image_formats": ["jpg", "png", "gif"], + "ssl_required": true, + "cta_values": ["LEARN_MORE", "SHOP_NOW", "GET_OFFER"], + "composition_model": "deterministic", + "platform_extensions": [ + { + "uri": "https://nytimes.adcp/extensions/nytimes_om_strict", + "digest": "sha256:c9d2f5b8e1a4c7b0e3d6f9a2c5b8e1d4f7a0c3b6e9d2f5a8c1b4e7d0f3a6c9b2" + } + ] + } } - }, + ], "pricing_options": [ { "pricing_option_id": "cpm_homepage", "pricing_model": "cpm", "currency": "USD", "fixed_price": 22.00 } ] @@ -134,24 +140,26 @@ For HTML5 banners on the same placement, NYTimes publishes a *separate* product { "publisher_domain": "nytimes.com", "selection_type": "all" } ], "channels": ["display"], - "format": { - "format_kind": "html5", - "params": { - "width": 300, - "height": 250, - "max_initial_load_kb": 200, - "max_polite_load_kb": 500, - "host_initiated_subload": true, - "max_animation_duration_ms": 30000, - "max_cpu_load_percent": 30, - "om_sdk_required": true, - "clicktag_macro": "clickTag", - "backup_image_required": true, - "backup_image_max_size_kb": 50, - "ssl_required": true, - "composition_model": "deterministic" + "format_options": [ + { + "format_kind": "html5", + "params": { + "width": 300, + "height": 250, + "max_initial_load_kb": 200, + "max_polite_load_kb": 500, + "host_initiated_subload": true, + "max_animation_duration_ms": 30000, + "max_cpu_load_percent": 30, + "om_sdk_required": true, + "clicktag_macro": "clickTag", + "backup_image_required": true, + "backup_image_max_size_kb": 50, + "ssl_required": true, + "composition_model": "deterministic" + } } - }, + ], "pricing_options": [ { "pricing_option_id": "cpm_homepage_html5", "pricing_model": "cpm", "currency": "USD", "fixed_price": 28.00 } ] @@ -172,34 +180,36 @@ Host-reads are the host-recorded-from-buyer-script pattern. The product declares { "publisher_domain": "thedailypod.example", "selection_type": "all" } ], "channels": ["podcast"], - "format": { - "format_kind": "audio_hosted", - "params": { - "duration_ms_exact": 30000, - "audio_codecs": ["mp3", "aac"], - "audio_sample_rates": [44100, 48000], - "audio_channels": ["stereo"], - "loudness_lufs": -16, - "audio_source": "publisher_host_recorded", - "buyer_audio_acceptance": "rejected", - "composition_model": "deterministic", - "slots": [ - { - "asset_group_id": "script", - "required": true, - "asset_type": "text", - "max_chars": 800, - "description": "Verbatim script the host reads." - }, - { - "asset_group_id": "offering_ref", - "required": false, - "asset_type": "text" - } - ], - "production_window_business_days": 7 + "format_options": [ + { + "format_kind": "audio_hosted", + "params": { + "duration_ms_exact": 30000, + "audio_codecs": ["mp3", "aac"], + "audio_sample_rates": [44100, 48000], + "audio_channels": ["stereo"], + "loudness_lufs": -16, + "audio_source": "publisher_host_recorded", + "buyer_audio_acceptance": "rejected", + "composition_model": "deterministic", + "slots": [ + { + "asset_group_id": "script", + "required": true, + "asset_type": "text", + "max_chars": 800, + "description": "Verbatim script the host reads." + }, + { + "asset_group_id": "offering_ref", + "required": false, + "asset_type": "text" + } + ], + "production_window_business_days": 7 + } } - }, + ], "pricing_options": [ { "pricing_option_id": "cpm_host_read", "pricing_model": "cpm", "currency": "USD", "fixed_price": 35.00 } ] @@ -230,6 +240,110 @@ The format's `audio_source: "publisher_host_recorded"` + `buyer_audio_acceptance For brief-driven (talking-points-style) host-reads, the same shape applies with a `creative_brief` slot (asset_type `brief`) in place of the `script` slot. Same target format (`audio_hosted`); different slot declaration. +## Worked example — third-party creative agent (Flashtalking + NYTimes display) + +The host-read example above is single-actor by necessity: the publisher's host has to be the producer. The opposite case is the multi-actor display path, where the buyer chooses a third-party creative agent that the seller has declared in its capabilities. The seller does **not** compose creatives — it just accepts canonical-conformant manifests. + +Three actors: + +- **Buyer** (Acme DSP) — discovers products, picks a creative agent, submits manifests +- **Sales agent** (NYTimes) — sells the placement, validates manifests against canonical, doesn't compose creatives +- **Creative agent** (Flashtalking) — produces creatives via `build_creative`, declares its capabilities via `creative.supported_formats` on `get_adcp_capabilities` + +### 1. Buyer reads NYTimes capabilities + +Buyer calls `get_adcp_capabilities` on NYTimes. NYTimes declares which creative agents it accepts under `creative.creative_agents` and surfaces the union of their producible formats under `creative.supported_formats`: + +```json test=false +{ + "creative": { + "supports_transformation": false, + "creative_agents": [ + { "agent_url": "https://flashtalking.adcp" }, + { "agent_url": "https://creative.nytimes.adcp" } + ], + "supported_formats": [ + { + "capability_id": "flashtalking_image_300x250", + "format": { + "format_kind": "image", + "params": { "width": 300, "height": 250, "max_file_size_kb": 200, "ssl_required": true } + } + }, + { + "capability_id": "flashtalking_video_vast", + "format": { + "format_kind": "video_vast", + "params": { "vast_version": "4.2", "duration_ms_range": [6000, 30000], "linear_required": true } + } + } + ] + } +} +``` + +`supported_formats` is the buyer-facing flat catalog; `creative_agents` is the source-of-truth list. Buyers SHOULD treat `supported_formats` as authoritative for "what can I ship?" — the SDK derives it by fetching each `creative_agents[].agent_url` and unioning. (Sellers MAY override the auto-projection to narrow what they accept, e.g., "I trust Flashtalking but only for hosted html5, not VAST"; that override happens server-side and the catalog reflects the narrowed view.) + +### 2. Buyer reads NYTimes products + +Buyer calls `get_products` on NYTimes. The MREC product narrows canonical `image`: + +```json test=false +{ + "product_id": "nytimes_homepage_mrec", + "format_options": [ + { + "format_kind": "image", + "params": { "width": 300, "height": 250, "max_file_size_kb": 200, "ssl_required": true } + } + ] +} +``` + +The product narrows the canonical; the canonical is what NYTimes commits to validating against. NYTimes does NOT validate against Flashtalking's narrowing — buyers don't need to know which creative agent produced the manifest, and Flashtalking-specific parameters (e.g., a Flashtalking placement ID) live in Flashtalking's platform extensions if at all. + +### 3. Buyer calls Flashtalking's `build_creative` + +```json test=false +// POST https://flashtalking.adcp/build_creative +{ + "format": { + "format_kind": "image", + "params": { "width": 300, "height": 250 } + }, + "brand": { "domain": "acme.example" }, + "assets": { + "creative_brief": { "asset_type": "brief", "content": "Spring sale, 50% off, blue background, urgent CTA." }, + "landing_page_url": { "asset_type": "url", "url": "https://acme.example/spring" } + } +} +``` + +Flashtalking renders an MREC PNG, returns a manifest with the produced asset: + +```json test=false +{ + "creative_id": "ft_mrec_88299", + "manifest": { + "format_id": { "agent_url": "https://flashtalking.adcp", "id": "image_300x250" }, + "assets": { + "image": { "asset_type": "image", "url": "https://cdn.flashtalking.com/ft_mrec_88299.png", "width": 300, "height": 250 } + } + } +} +``` + +### 4. Buyer ships to NYTimes + +Buyer calls `sync_creatives` on NYTimes with the manifest from Flashtalking. NYTimes: + +1. Validates the manifest against canonical `image` (300×250, ≤200KB, SSL). +2. Validates against the product's narrowing (matches — same params). +3. Does NOT validate against Flashtalking's narrowing — that's the creative agent's contract with the buyer, not the seller's contract. +4. If valid → creative registered. If not → returns canonical violations (`width` mismatch, `max_file_size_kb` exceeded). + +The seller's validation contract is the canonical, not the creative agent. This is what makes the third-party path additive rather than coupled: the buyer can swap creative agents without changing the seller-facing flow. + ## Validation flow — `validate_input` Buyers can dry-run a manifest against canonicals and/or specific products without committing to a render: @@ -325,6 +439,8 @@ https://nytimes.adcp/extensions/nytimes_om_strict Each extension's response carries the schema, the canonical pattern or slot it extends, a version, and a content digest. +**Hosting expectation.** The publisher whose subdomain owns the URI hosts the canonical artifact. Because URIs are digest-pinned (`uri@sha256:…`), responses are immutable per digest — sellers SHOULD serve them with `Cache-Control: public, max-age=31536000, immutable` and target ≥99.9% / 30-day availability. SDKs cache aggressively by `uri@digest`; an extension at a given digest never changes content, so a cache hit is always correct. If the canonical URI returns 404 or fails to resolve, buyers MUST degrade gracefully — treat the extension as unavailable and skip platform-specific narrowing rather than fail the buy. Sellers that don't want to operate an artifact CDN MAY rely on the AAO mirror as a fallback host (best-effort, not normative); the mirror does not relieve the publisher of the primary hosting role. + **Distribution path: bundled in `get_products`.** The sales agent's response includes definitions for every extension referenced by any product in the response, keyed by `uri@digest`: ```json test=false diff --git a/static/examples/get_products_responses/v2/meta_with_bundled_extensions.json b/static/examples/get_products_responses/v2/meta_with_bundled_extensions.json index b17a4f0a4b..28f1034feb 100644 --- a/static/examples/get_products_responses/v2/meta_with_bundled_extensions.json +++ b/static/examples/get_products_responses/v2/meta_with_bundled_extensions.json @@ -14,49 +14,6 @@ "channels": [ "social" ], - "format": { - "format_kind": "video_hosted", - "params": { - "orientation": "vertical", - "aspect_ratio": "9:16", - "duration_ms_range": [ - 3000, - 90000 - ], - "min_width": 1080, - "min_height": 1920, - "max_file_size_mb": 200, - "video_codecs": [ - "h264" - ], - "audio_codecs": [ - "aac" - ], - "containers": [ - "mp4" - ], - "headline_max_chars": 25, - "primary_text_max_chars": 72, - "captions": "recommended", - "cta_values": [ - "LEARN_MORE", - "SHOP_NOW", - "DOWNLOAD", - "SIGN_UP" - ], - "composition_model": "deterministic", - "platform_extensions": [ - { - "uri": "https://meta.adcp/extensions/meta_pixel", - "digest": "sha256:a3f5b7c9d8e2f1a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2" - }, - { - "uri": "https://meta.adcp/extensions/meta_placements_reels", - "digest": "sha256:b8e1d4f6a9c2b5e8d1f4a7c0b3e6d9f2a5c8b1e4d7f0a3c6b9e2d5f8a1c4b7e0" - } - ] - } - }, "delivery_type": "non_guaranteed", "pricing_options": [ { @@ -82,7 +39,52 @@ "viewability" ], "date_range_support": "date_range" - } + }, + "format_options": [ + { + "format_kind": "video_hosted", + "params": { + "orientation": "vertical", + "aspect_ratio": "9:16", + "duration_ms_range": [ + 3000, + 90000 + ], + "min_width": 1080, + "min_height": 1920, + "max_file_size_mb": 200, + "video_codecs": [ + "h264" + ], + "audio_codecs": [ + "aac" + ], + "containers": [ + "mp4" + ], + "headline_max_chars": 25, + "primary_text_max_chars": 72, + "captions": "recommended", + "cta_values": [ + "LEARN_MORE", + "SHOP_NOW", + "DOWNLOAD", + "SIGN_UP" + ], + "composition_model": "deterministic", + "platform_extensions": [ + { + "uri": "https://meta.adcp/extensions/meta_pixel", + "digest": "sha256:a3f5b7c9d8e2f1a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2" + }, + { + "uri": "https://meta.adcp/extensions/meta_placements_reels", + "digest": "sha256:b8e1d4f6a9c2b5e8d1f4a7c0b3e6d9f2a5c8b1e4d7f0a3c6b9e2d5f8a1c4b7e0" + } + ] + } + } + ] }, { "product_id": "meta_feed_image_us", @@ -97,38 +99,6 @@ "channels": [ "social" ], - "format": { - "format_kind": "image", - "params": { - "width": 1080, - "height": 1080, - "max_file_size_kb": 30000, - "image_formats": [ - "jpg", - "png" - ], - "ssl_required": true, - "headline_max_chars": 40, - "body_text_max_chars": 125, - "cta_values": [ - "LEARN_MORE", - "SHOP_NOW", - "DOWNLOAD", - "SIGN_UP" - ], - "composition_model": "deterministic", - "platform_extensions": [ - { - "uri": "https://meta.adcp/extensions/meta_pixel", - "digest": "sha256:a3f5b7c9d8e2f1a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2" - }, - { - "uri": "https://meta.adcp/extensions/meta_placements_feed", - "digest": "sha256:c2d4e6f8a0b2c4d6e8f0a2c4d6e8f0a2c4d6e8f0a2c4d6e8f0a2c4d6e8f0a2c4" - } - ] - } - }, "delivery_type": "non_guaranteed", "pricing_options": [ { @@ -154,7 +124,41 @@ "viewability" ], "date_range_support": "date_range" - } + }, + "format_options": [ + { + "format_kind": "image", + "params": { + "width": 1080, + "height": 1080, + "max_file_size_kb": 30000, + "image_formats": [ + "jpg", + "png" + ], + "ssl_required": true, + "headline_max_chars": 40, + "body_text_max_chars": 125, + "cta_values": [ + "LEARN_MORE", + "SHOP_NOW", + "DOWNLOAD", + "SIGN_UP" + ], + "composition_model": "deterministic", + "platform_extensions": [ + { + "uri": "https://meta.adcp/extensions/meta_pixel", + "digest": "sha256:a3f5b7c9d8e2f1a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2" + }, + { + "uri": "https://meta.adcp/extensions/meta_placements_feed", + "digest": "sha256:c2d4e6f8a0b2c4d6e8f0a2c4d6e8f0a2c4d6e8f0a2c4d6e8f0a2c4d6e8f0a2c4" + } + ] + } + } + ] } ], "extensions": { diff --git a/static/examples/products/v2/amazon_sponsored_products.json b/static/examples/products/v2/amazon_sponsored_products.json index b78fe4275f..0aade51ac0 100644 --- a/static/examples/products/v2/amazon_sponsored_products.json +++ b/static/examples/products/v2/amazon_sponsored_products.json @@ -12,27 +12,6 @@ "channels": [ "retail_media" ], - "format": { - "format_kind": "sponsored_placement", - "params": { - "supported_catalog_types": [ - "product" - ], - "min_items": 1, - "max_items": 50, - "fanout_mode": "per_item", - "required_catalog_fields": [ - "title", - "image_url", - "price" - ], - "supported_id_types": [ - "asin" - ], - "hero_asset_supported": false, - "composition_model": "deterministic" - } - }, "delivery_type": "non_guaranteed", "pricing_options": [ { @@ -61,5 +40,28 @@ "new_to_brand_rate" ], "date_range_support": "date_range" - } + }, + "format_options": [ + { + "format_kind": "sponsored_placement", + "params": { + "supported_catalog_types": [ + "product" + ], + "min_items": 1, + "max_items": 50, + "fanout_mode": "per_item", + "required_catalog_fields": [ + "title", + "image_url", + "price" + ], + "supported_id_types": [ + "asin" + ], + "hero_asset_supported": false, + "composition_model": "deterministic" + } + } + ] } diff --git a/static/examples/products/v2/chatgpt_brand_mention.json b/static/examples/products/v2/chatgpt_brand_mention.json index 0c8d226c18..41e941b242 100644 --- a/static/examples/products/v2/chatgpt_brand_mention.json +++ b/static/examples/products/v2/chatgpt_brand_mention.json @@ -12,41 +12,6 @@ "channels": [ "sponsored_intelligence" ], - "format": { - "format_kind": "agent_placement", - "params": { - "output_modality": "text", - "max_mention_length_chars": 280, - "supports_offering_reference": true, - "supports_landing_page_url": true, - "tone_constraints": [ - "factual", - "no_superlatives" - ], - "disclosure_required": true, - "composition_model": "algorithmic", - "slots": [ - { - "asset_group_id": "offering_ref", - "required": false, - "asset_type": "text", - "description": "Optional offering identifier to focus the mention on a specific product, service, or campaign within the brand." - }, - { - "asset_group_id": "landing_page_url", - "required": false, - "asset_type": "url", - "description": "Optional URL the surface MAY attach to mentions as a citation or learn-more link." - } - ], - "platform_extensions": [ - { - "uri": "https://openai.adcp/extensions/chatgpt_response_card", - "digest": "sha256:f3a6c9b2e5d8f1a4c7b0e3d6f9a2c5b8e1d4f7a0c3b6e9d2f5a8c1b4e7d0f3a6" - } - ] - } - }, "delivery_type": "non_guaranteed", "pricing_options": [ { @@ -71,5 +36,42 @@ "engagement_rate" ], "date_range_support": "date_range" - } + }, + "format_options": [ + { + "format_kind": "agent_placement", + "params": { + "output_modality": "text", + "max_mention_length_chars": 280, + "supports_offering_reference": true, + "supports_landing_page_url": true, + "tone_constraints": [ + "factual", + "no_superlatives" + ], + "disclosure_required": true, + "composition_model": "algorithmic", + "slots": [ + { + "asset_group_id": "offering_ref", + "required": false, + "asset_type": "text", + "description": "Optional offering identifier to focus the mention on a specific product, service, or campaign within the brand." + }, + { + "asset_group_id": "landing_page_url", + "required": false, + "asset_type": "url", + "description": "Optional URL the surface MAY attach to mentions as a citation or learn-more link." + } + ], + "platform_extensions": [ + { + "uri": "https://openai.adcp/extensions/chatgpt_response_card", + "digest": "sha256:f3a6c9b2e5d8f1a4c7b0e3d6f9a2c5b8e1d4f7a0c3b6e9d2f5a8c1b4e7d0f3a6" + } + ] + } + } + ] } diff --git a/static/examples/products/v2/gam_3p_display_tag.json b/static/examples/products/v2/gam_3p_display_tag.json index 6d21d9e9a1..42c80ea179 100644 --- a/static/examples/products/v2/gam_3p_display_tag.json +++ b/static/examples/products/v2/gam_3p_display_tag.json @@ -4,39 +4,76 @@ "name": "GAM Publisher — 3P Display Tag (300×250)", "description": "Third-party-served display tag (JS or iframe) on a GAM-managed publisher placement. Buyer's adserver hosts the creative; the publisher calls the tag URL at impression time. 200KB max-snippet-size and a runtime allowlist (no eval, no document.write, no setTimeout, no javascript: / data: URLs in click trackers) apply at the GAM level — these are publisher-policy constraints, not protocol-level.", "publisher_properties": [ - { "publisher_domain": "examplepublisher.example", "selection_type": "all" } - ], - "channels": ["display"], - "format": { - "format_kind": "display_tag", - "params": { - "width": 300, - "height": 250, - "supported_tag_types": ["iframe", "javascript", "1x1_redirect"], - "ssl_required": true, - "max_redirect_depth": 4, - "max_response_time_ms": 1500, - "backup_image_required": true, - "backup_image_max_size_kb": 50, - "om_sdk_required": false, - "composition_model": "deterministic", - "slots": [ - { "asset_group_id": "tag_url", "asset_type": "url", "required": true }, - { "asset_group_id": "backup_image", "asset_type": "image", "required": true, "max_size_kb": 50 }, - { "asset_group_id": "landing_page_url", "asset_type": "url", "required": false } - ] + { + "publisher_domain": "examplepublisher.example", + "selection_type": "all" } - }, + ], + "channels": [ + "display" + ], "delivery_type": "non_guaranteed", "pricing_options": [ - { "pricing_option_id": "cpm_floor", "pricing_model": "cpm", "currency": "USD", "floor_price": 1.50 } + { + "pricing_option_id": "cpm_floor", + "pricing_model": "cpm", + "currency": "USD", + "floor_price": 1.5 + } ], "reporting_capabilities": { - "available_reporting_frequencies": ["daily"], + "available_reporting_frequencies": [ + "daily" + ], "expected_delay_minutes": 240, "timezone": "UTC", "supports_webhooks": false, - "available_metrics": ["impressions", "clicks", "spend", "ctr", "viewability"], + "available_metrics": [ + "impressions", + "clicks", + "spend", + "ctr", + "viewability" + ], "date_range_support": "date_range" - } + }, + "format_options": [ + { + "format_kind": "display_tag", + "params": { + "width": 300, + "height": 250, + "supported_tag_types": [ + "iframe", + "javascript", + "1x1_redirect" + ], + "ssl_required": true, + "max_redirect_depth": 4, + "max_response_time_ms": 1500, + "backup_image_required": true, + "backup_image_max_size_kb": 50, + "om_sdk_required": false, + "composition_model": "deterministic", + "slots": [ + { + "asset_group_id": "tag_url", + "asset_type": "url", + "required": true + }, + { + "asset_group_id": "backup_image", + "asset_type": "image", + "required": true, + "max_size_kb": 50 + }, + { + "asset_group_id": "landing_page_url", + "asset_type": "url", + "required": false + } + ] + } + } + ] } diff --git a/static/examples/products/v2/google_performance_max.json b/static/examples/products/v2/google_performance_max.json index 72188d8d1c..e7b216dd6b 100644 --- a/static/examples/products/v2/google_performance_max.json +++ b/static/examples/products/v2/google_performance_max.json @@ -15,49 +15,6 @@ "ctv", "olv" ], - "format": { - "format_kind": "responsive_creative", - "params": { - "headlines_min": 3, - "headlines_max": 15, - "headline_max_chars": 30, - "long_headlines_min": 1, - "long_headlines_max": 5, - "long_headline_max_chars": 90, - "descriptions_min": 2, - "descriptions_max": 5, - "description_max_chars": 90, - "images_landscape_min": 1, - "images_landscape_max": 20, - "images_landscape_aspect_ratio": "1.91:1", - "images_square_min": 1, - "images_square_max": 20, - "videos_min": 0, - "videos_max": 5, - "video_min_duration_ms": 10000, - "video_max_duration_ms": 600000, - "logo_min": 1, - "logo_max": 5, - "logo_aspect_ratios": [ - "1:1", - "4:1" - ], - "business_name_max_chars": 25, - "asset_image_max_file_size_kb": 5120, - "supports_catalog_input": true, - "composition_model": "algorithmic", - "platform_extensions": [ - { - "uri": "https://google.adcp/extensions/google_conversion_actions", - "digest": "sha256:d8f1a4c7b0e3d6f9a2c5b8e1d4f7a0c3b6e9d2f5a8c1b4e7d0f3a6c9b2e5d8f1" - }, - { - "uri": "https://google.adcp/extensions/google_audience_signals", - "digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - } - ] - } - }, "delivery_type": "non_guaranteed", "pricing_options": [ { @@ -87,5 +44,50 @@ "viewability" ], "date_range_support": "date_range" - } + }, + "format_options": [ + { + "format_kind": "responsive_creative", + "params": { + "headlines_min": 3, + "headlines_max": 15, + "headline_max_chars": 30, + "long_headlines_min": 1, + "long_headlines_max": 5, + "long_headline_max_chars": 90, + "descriptions_min": 2, + "descriptions_max": 5, + "description_max_chars": 90, + "images_landscape_min": 1, + "images_landscape_max": 20, + "images_landscape_aspect_ratio": "1.91:1", + "images_square_min": 1, + "images_square_max": 20, + "videos_min": 0, + "videos_max": 5, + "video_min_duration_ms": 10000, + "video_max_duration_ms": 600000, + "logo_min": 1, + "logo_max": 5, + "logo_aspect_ratios": [ + "1:1", + "4:1" + ], + "business_name_max_chars": 25, + "asset_image_max_file_size_kb": 5120, + "supports_catalog_input": true, + "composition_model": "algorithmic", + "platform_extensions": [ + { + "uri": "https://google.adcp/extensions/google_conversion_actions", + "digest": "sha256:d8f1a4c7b0e3d6f9a2c5b8e1d4f7a0c3b6e9d2f5a8c1b4e7d0f3a6c9b2e5d8f1" + }, + { + "uri": "https://google.adcp/extensions/google_audience_signals", + "digest": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + ] + } + } + ] } diff --git a/static/examples/products/v2/meta_carousel.json b/static/examples/products/v2/meta_carousel.json index b8c6d65eee..e0116914a0 100644 --- a/static/examples/products/v2/meta_carousel.json +++ b/static/examples/products/v2/meta_carousel.json @@ -4,42 +4,85 @@ "name": "Meta Carousel — United States", "description": "Multi-card swipeable carousel on Meta feed (Facebook + Instagram). 2-10 cards, square aspect, polymorphic per-card asset (image OR video). Each card carries its own headline + click URL. Surface composes the swipeable presentation; buyer ships the cards array.", "publisher_properties": [ - { "publisher_domain": "meta.com", "selection_type": "all" } - ], - "channels": ["social"], - "format": { - "format_kind": "image_carousel", - "params": { - "card_aspect_ratio": "1:1", - "min_cards": 2, - "max_cards": 10, - "allowed_card_asset_types": ["image", "video"], - "card_image_max_file_size_kb": 30000, - "card_video_max_duration_ms": 240000, - "primary_text_max_chars": 125, - "card_headline_max_chars": 40, - "ssl_required": true, - "composition_model": "deterministic", - "slots": [ - { "asset_group_id": "cards", "asset_type": "object", "required": true, "min": 2, "max": 10 }, - { "asset_group_id": "primary_text", "asset_type": "text", "required": false, "max_chars": 125 }, - { "asset_group_id": "landing_page_url", "asset_type": "url", "required": true } - ], - "platform_extensions": [ - { "uri": "https://meta.adcp/extensions/meta_pixel", "digest": "sha256:a3f5b7c9d8e2f1a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2" } - ] + { + "publisher_domain": "meta.com", + "selection_type": "all" } - }, + ], + "channels": [ + "social" + ], "delivery_type": "non_guaranteed", "pricing_options": [ - { "pricing_option_id": "cpm_floor", "pricing_model": "cpm", "currency": "USD", "floor_price": 4.50 } + { + "pricing_option_id": "cpm_floor", + "pricing_model": "cpm", + "currency": "USD", + "floor_price": 4.5 + } ], "reporting_capabilities": { - "available_reporting_frequencies": ["hourly", "daily"], + "available_reporting_frequencies": [ + "hourly", + "daily" + ], "expected_delay_minutes": 60, "timezone": "America/Los_Angeles", "supports_webhooks": true, - "available_metrics": ["impressions", "clicks", "spend", "ctr", "engagement_rate", "viewability"], + "available_metrics": [ + "impressions", + "clicks", + "spend", + "ctr", + "engagement_rate", + "viewability" + ], "date_range_support": "date_range" - } + }, + "format_options": [ + { + "format_kind": "image_carousel", + "params": { + "card_aspect_ratio": "1:1", + "min_cards": 2, + "max_cards": 10, + "allowed_card_asset_types": [ + "image", + "video" + ], + "card_image_max_file_size_kb": 30000, + "card_video_max_duration_ms": 240000, + "primary_text_max_chars": 125, + "card_headline_max_chars": 40, + "ssl_required": true, + "composition_model": "deterministic", + "slots": [ + { + "asset_group_id": "cards", + "asset_type": "object", + "required": true, + "min": 2, + "max": 10 + }, + { + "asset_group_id": "primary_text", + "asset_type": "text", + "required": false, + "max_chars": 125 + }, + { + "asset_group_id": "landing_page_url", + "asset_type": "url", + "required": true + } + ], + "platform_extensions": [ + { + "uri": "https://meta.adcp/extensions/meta_pixel", + "digest": "sha256:a3f5b7c9d8e2f1a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2" + } + ] + } + } + ] } diff --git a/static/examples/products/v2/meta_reels_us.json b/static/examples/products/v2/meta_reels_us.json index 59f361aeb6..5ca79667e5 100644 --- a/static/examples/products/v2/meta_reels_us.json +++ b/static/examples/products/v2/meta_reels_us.json @@ -12,49 +12,6 @@ "channels": [ "social" ], - "format": { - "format_kind": "video_hosted", - "params": { - "orientation": "vertical", - "aspect_ratio": "9:16", - "duration_ms_range": [ - 3000, - 90000 - ], - "min_width": 1080, - "min_height": 1920, - "max_file_size_mb": 200, - "video_codecs": [ - "h264" - ], - "audio_codecs": [ - "aac" - ], - "containers": [ - "mp4" - ], - "headline_max_chars": 25, - "primary_text_max_chars": 72, - "captions": "recommended", - "cta_values": [ - "LEARN_MORE", - "SHOP_NOW", - "DOWNLOAD", - "SIGN_UP" - ], - "composition_model": "deterministic", - "platform_extensions": [ - { - "uri": "https://meta.adcp/extensions/meta_pixel", - "digest": "sha256:a3f5b7c9d8e2f1a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2" - }, - { - "uri": "https://meta.adcp/extensions/meta_placements_reels", - "digest": "sha256:b8e1d4f6a9c2b5e8d1f4a7c0b3e6d9f2a5c8b1e4d7f0a3c6b9e2d5f8a1c4b7e0" - } - ] - } - }, "delivery_type": "non_guaranteed", "pricing_options": [ { @@ -80,5 +37,50 @@ "viewability" ], "date_range_support": "date_range" - } + }, + "format_options": [ + { + "format_kind": "video_hosted", + "params": { + "orientation": "vertical", + "aspect_ratio": "9:16", + "duration_ms_range": [ + 3000, + 90000 + ], + "min_width": 1080, + "min_height": 1920, + "max_file_size_mb": 200, + "video_codecs": [ + "h264" + ], + "audio_codecs": [ + "aac" + ], + "containers": [ + "mp4" + ], + "headline_max_chars": 25, + "primary_text_max_chars": 72, + "captions": "recommended", + "cta_values": [ + "LEARN_MORE", + "SHOP_NOW", + "DOWNLOAD", + "SIGN_UP" + ], + "composition_model": "deterministic", + "platform_extensions": [ + { + "uri": "https://meta.adcp/extensions/meta_pixel", + "digest": "sha256:a3f5b7c9d8e2f1a4b6c8d0e2f4a6b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2" + }, + { + "uri": "https://meta.adcp/extensions/meta_placements_reels", + "digest": "sha256:b8e1d4f6a9c2b5e8d1f4a7c0b3e6d9f2a5c8b1e4d7f0a3c6b9e2d5f8a1c4b7e0" + } + ] + } + } + ] } diff --git a/static/examples/products/v2/nytimes_homepage_html5.json b/static/examples/products/v2/nytimes_homepage_html5.json index 1a4cef1854..586a308396 100644 --- a/static/examples/products/v2/nytimes_homepage_html5.json +++ b/static/examples/products/v2/nytimes_homepage_html5.json @@ -15,24 +15,6 @@ "channels": [ "display" ], - "format": { - "format_kind": "html5", - "params": { - "width": 300, - "height": 250, - "max_initial_load_kb": 200, - "max_polite_load_kb": 500, - "host_initiated_subload": true, - "max_animation_duration_ms": 30000, - "max_cpu_load_percent": 30, - "om_sdk_required": true, - "clicktag_macro": "clickTag", - "backup_image_required": true, - "backup_image_max_size_kb": 50, - "ssl_required": true, - "composition_model": "deterministic" - } - }, "delivery_type": "guaranteed", "pricing_options": [ { @@ -58,5 +40,25 @@ "engagement_rate" ], "date_range_support": "date_range" - } + }, + "format_options": [ + { + "format_kind": "html5", + "params": { + "width": 300, + "height": 250, + "max_initial_load_kb": 200, + "max_polite_load_kb": 500, + "host_initiated_subload": true, + "max_animation_duration_ms": 30000, + "max_cpu_load_percent": 30, + "om_sdk_required": true, + "clicktag_macro": "clickTag", + "backup_image_required": true, + "backup_image_max_size_kb": 50, + "ssl_required": true, + "composition_model": "deterministic" + } + } + ] } diff --git a/static/examples/products/v2/nytimes_homepage_mrec.json b/static/examples/products/v2/nytimes_homepage_mrec.json index bf608d2b50..447885bc15 100644 --- a/static/examples/products/v2/nytimes_homepage_mrec.json +++ b/static/examples/products/v2/nytimes_homepage_mrec.json @@ -15,32 +15,6 @@ "channels": [ "display" ], - "format": { - "format_kind": "image", - "params": { - "width": 300, - "height": 250, - "max_file_size_kb": 200, - "image_formats": [ - "jpg", - "png", - "gif" - ], - "ssl_required": true, - "cta_values": [ - "LEARN_MORE", - "SHOP_NOW", - "GET_OFFER" - ], - "composition_model": "deterministic", - "platform_extensions": [ - { - "uri": "https://nytimes.adcp/extensions/nytimes_om_strict", - "digest": "sha256:c9d2f5b8e1a4c7b0e3d6f9a2c5b8e1d4f7a0c3b6e9d2f5a8c1b4e7d0f3a6c9b2" - } - ] - } - }, "delivery_type": "guaranteed", "pricing_options": [ { @@ -65,5 +39,33 @@ "viewability" ], "date_range_support": "date_range" - } + }, + "format_options": [ + { + "format_kind": "image", + "params": { + "width": 300, + "height": 250, + "max_file_size_kb": 200, + "image_formats": [ + "jpg", + "png", + "gif" + ], + "ssl_required": true, + "cta_values": [ + "LEARN_MORE", + "SHOP_NOW", + "GET_OFFER" + ], + "composition_model": "deterministic", + "platform_extensions": [ + { + "uri": "https://nytimes.adcp/extensions/nytimes_om_strict", + "digest": "sha256:c9d2f5b8e1a4c7b0e3d6f9a2c5b8e1d4f7a0c3b6e9d2f5a8c1b4e7d0f3a6c9b2" + } + ] + } + } + ] } diff --git a/static/examples/products/v2/the_daily_30s_host_read.json b/static/examples/products/v2/the_daily_30s_host_read.json index a4aeddbe36..4e13d06ea0 100644 --- a/static/examples/products/v2/the_daily_30s_host_read.json +++ b/static/examples/products/v2/the_daily_30s_host_read.json @@ -12,43 +12,6 @@ "channels": [ "podcast" ], - "format": { - "format_kind": "audio_hosted", - "params": { - "duration_ms_exact": 30000, - "audio_codecs": [ - "mp3", - "aac" - ], - "audio_sample_rates": [ - 44100, - 48000 - ], - "audio_channels": [ - "stereo" - ], - "loudness_lufs": -16, - "audio_source": "publisher_host_recorded", - "buyer_audio_acceptance": "rejected", - "composition_model": "deterministic", - "slots": [ - { - "asset_group_id": "script", - "required": true, - "asset_type": "text", - "max_chars": 800, - "description": "Verbatim script the host reads — exact wording; no improvisation; legal pre-cleared." - }, - { - "asset_group_id": "offering_ref", - "required": false, - "asset_type": "text", - "description": "Optional offering identifier from the buyer's catalog to focus the host-read." - } - ], - "production_window_business_days": 7 - } - }, "delivery_type": "guaranteed", "pricing_options": [ { @@ -72,5 +35,44 @@ "completed_views" ], "date_range_support": "date_range" - } + }, + "format_options": [ + { + "format_kind": "audio_hosted", + "params": { + "duration_ms_exact": 30000, + "audio_codecs": [ + "mp3", + "aac" + ], + "audio_sample_rates": [ + 44100, + 48000 + ], + "audio_channels": [ + "stereo" + ], + "loudness_lufs": -16, + "audio_source": "publisher_host_recorded", + "buyer_audio_acceptance": "rejected", + "composition_model": "deterministic", + "slots": [ + { + "asset_group_id": "script", + "required": true, + "asset_type": "text", + "max_chars": 800, + "description": "Verbatim script the host reads — exact wording; no improvisation; legal pre-cleared." + }, + { + "asset_group_id": "offering_ref", + "required": false, + "asset_type": "text", + "description": "Optional offering identifier from the buyer's catalog to focus the host-read." + } + ], + "production_window_business_days": 7 + } + } + ] } diff --git a/static/examples/products/v2/triton_daast_audio_30s.json b/static/examples/products/v2/triton_daast_audio_30s.json index 544deb1017..4481476ec9 100644 --- a/static/examples/products/v2/triton_daast_audio_30s.json +++ b/static/examples/products/v2/triton_daast_audio_30s.json @@ -4,35 +4,64 @@ "name": "Triton Audio DAAST (30s)", "description": "DAAST 1.1 audio tag on Triton-managed streaming radio inventory. Buyer ships a DAAST tag (URL or inline XML); the streaming server fires DAAST events (impression / quartiles / click / complete / error) inherent to the spec. Audio analog of VAST.", "publisher_properties": [ - { "publisher_domain": "triton.example", "selection_type": "all" } - ], - "channels": ["streaming_audio", "radio"], - "format": { - "format_kind": "audio_daast", - "params": { - "daast_version": "1.1", - "duration_ms_exact": 30000, - "linear_required": true, - "max_wrapper_depth": 3, - "ssl_required": true, - "companion_image_required": false, - "composition_model": "deterministic", - "slots": [ - { "asset_group_id": "daast_tag", "asset_type": "daast", "required": true }, - { "asset_group_id": "landing_page_url", "asset_type": "url", "required": false } - ] + { + "publisher_domain": "triton.example", + "selection_type": "all" } - }, + ], + "channels": [ + "streaming_audio", + "radio" + ], "delivery_type": "non_guaranteed", "pricing_options": [ - { "pricing_option_id": "cpm_floor", "pricing_model": "cpm", "currency": "USD", "floor_price": 12.00 } + { + "pricing_option_id": "cpm_floor", + "pricing_model": "cpm", + "currency": "USD", + "floor_price": 12 + } ], "reporting_capabilities": { - "available_reporting_frequencies": ["daily"], + "available_reporting_frequencies": [ + "daily" + ], "expected_delay_minutes": 1440, "timezone": "UTC", "supports_webhooks": false, - "available_metrics": ["impressions", "spend", "completion_rate", "completed_views", "quartile_data"], + "available_metrics": [ + "impressions", + "spend", + "completion_rate", + "completed_views", + "quartile_data" + ], "date_range_support": "date_range" - } + }, + "format_options": [ + { + "format_kind": "audio_daast", + "params": { + "daast_version": "1.1", + "duration_ms_exact": 30000, + "linear_required": true, + "max_wrapper_depth": 3, + "ssl_required": true, + "companion_image_required": false, + "composition_model": "deterministic", + "slots": [ + { + "asset_group_id": "daast_tag", + "asset_type": "daast", + "required": true + }, + { + "asset_group_id": "landing_page_url", + "asset_type": "url", + "required": false + } + ] + } + } + ] } diff --git a/static/examples/products/v2/veo_generative_video_15s.json b/static/examples/products/v2/veo_generative_video_15s.json index 1ab4d6a41f..b0f9a7b759 100644 --- a/static/examples/products/v2/veo_generative_video_15s.json +++ b/static/examples/products/v2/veo_generative_video_15s.json @@ -4,45 +4,92 @@ "name": "Veo — 15s Generative Vertical Video", "description": "Generative video synthesis from a creative_brief plus structured scenes. Buyer ships a brief (≤500 chars) and a scenes plan (3 scenes summing to 15s); Veo synthesizes the video. Genuinely nondeterministic — synthesis from in-spec inputs may produce out-of-spec frames; the platform's post-synthesis QA loop validates and reseeds up to N attempts before surfacing output. If the QA loop exhausts, build_creative returns task_failed with synthesis_failed reason. provenance_required: true means every produced asset carries a C2PA provenance manifest attributing synthesis to Veo (not the seller).", "publisher_properties": [ - { "publisher_domain": "veo.example", "selection_type": "all" } - ], - "channels": ["social", "olv"], - "format": { - "format_kind": "video_hosted", - "params": { - "orientation": "vertical", - "aspect_ratio": "9:16", - "duration_ms_exact": 15000, - "min_width": 1080, - "min_height": 1920, - "video_codecs": ["h264"], - "containers": ["mp4"], - "frame_rates": [24, 30], - "audio_source": "agent_synthesized", - "buyer_audio_acceptance": "rejected", - "captions": "recommended", - "composition_model": "deterministic", - "synthesis_nondeterministic": true, - "provenance_required": true, - "production_window_business_days": 0, - "slots": [ - { "asset_group_id": "creative_brief", "asset_type": "brief", "required": true, "max_chars": 500 }, - { "asset_group_id": "scenes", "asset_type": "object", "required": true, "min": 1, "max": 5 }, - { "asset_group_id": "style_reference", "asset_type": "image", "required": false }, - { "asset_group_id": "landing_page_url", "asset_type": "url", "required": false } - ] + { + "publisher_domain": "veo.example", + "selection_type": "all" } - }, + ], + "channels": [ + "social", + "olv" + ], "delivery_type": "non_guaranteed", "pricing_options": [ - { "pricing_option_id": "cpm_floor", "pricing_model": "cpm", "currency": "USD", "floor_price": 18.00 } + { + "pricing_option_id": "cpm_floor", + "pricing_model": "cpm", + "currency": "USD", + "floor_price": 18 + } ], "reporting_capabilities": { - "available_reporting_frequencies": ["daily"], + "available_reporting_frequencies": [ + "daily" + ], "expected_delay_minutes": 240, "timezone": "UTC", "supports_webhooks": false, - "available_metrics": ["impressions", "clicks", "spend", "completion_rate", "viewability"], + "available_metrics": [ + "impressions", + "clicks", + "spend", + "completion_rate", + "viewability" + ], "date_range_support": "date_range" - } + }, + "format_options": [ + { + "format_kind": "video_hosted", + "params": { + "orientation": "vertical", + "aspect_ratio": "9:16", + "duration_ms_exact": 15000, + "min_width": 1080, + "min_height": 1920, + "video_codecs": [ + "h264" + ], + "containers": [ + "mp4" + ], + "frame_rates": [ + 24, + 30 + ], + "audio_source": "agent_synthesized", + "buyer_audio_acceptance": "rejected", + "captions": "recommended", + "composition_model": "deterministic", + "synthesis_nondeterministic": true, + "provenance_required": true, + "production_window_business_days": 0, + "slots": [ + { + "asset_group_id": "creative_brief", + "asset_type": "brief", + "required": true, + "max_chars": 500 + }, + { + "asset_group_id": "scenes", + "asset_type": "object", + "required": true, + "min": 1, + "max": 5 + }, + { + "asset_group_id": "style_reference", + "asset_type": "image", + "required": false + }, + { + "asset_group_id": "landing_page_url", + "asset_type": "url", + "required": false + } + ] + } + } + ] } diff --git a/static/examples/products/v2/youtube_vast_preroll.json b/static/examples/products/v2/youtube_vast_preroll.json index d7c17b1922..cf83ef5476 100644 --- a/static/examples/products/v2/youtube_vast_preroll.json +++ b/static/examples/products/v2/youtube_vast_preroll.json @@ -4,34 +4,15 @@ "name": "YouTube VAST Pre-roll (15s skippable, In-Stream)", "description": "VAST tag pre-roll on YouTube In-Stream inventory, 16:9 horizontal, 5-second skippable threshold. Buyer ships a VAST 4.x tag (URL or inline XML); YouTube fires VAST events (impression / quartiles / click / complete / error / skip) inherent to the spec. VPAID 2.0 supported but discouraged — Google deprecates VPAID in 2026.", "publisher_properties": [ - { "publisher_domain": "youtube.com", "selection_type": "all" } - ], - "channels": ["olv", "ctv"], - "format": { - "format_kind": "video_vast", - "params": { - "orientation": "horizontal", - "aspect_ratio": "16:9", - "vast_version": "4.2", - "vpaid_enabled": false, - "simid_supported": false, - "duration_ms_range": [6000, 30000], - "min_width": 1280, - "min_height": 720, - "linear_required": true, - "skippable_after_ms": 5000, - "max_wrapper_depth": 5, - "ssl_required": true, - "composition_model": "deterministic", - "slots": [ - { "asset_group_id": "vast_tag", "asset_type": "vast", "required": true }, - { "asset_group_id": "landing_page_url", "asset_type": "url", "required": false } - ], - "platform_extensions": [ - { "uri": "https://google.adcp/extensions/google_universal_ad_id", "digest": "sha256:b3c5e7f9a1c3e5b7d9f1a3c5e7b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5" } - ] + { + "publisher_domain": "youtube.com", + "selection_type": "all" } - }, + ], + "channels": [ + "olv", + "ctv" + ], "delivery_type": "non_guaranteed", "pricing_options": [ { @@ -40,16 +21,68 @@ "currency": "USD", "floor_price": 0.05, "parameters": { - "view_threshold": { "duration_seconds": 30 } + "view_threshold": { + "duration_seconds": 30 + } } } ], "reporting_capabilities": { - "available_reporting_frequencies": ["daily"], + "available_reporting_frequencies": [ + "daily" + ], "expected_delay_minutes": 240, "timezone": "America/Los_Angeles", "supports_webhooks": false, - "available_metrics": ["impressions", "completed_views", "spend", "completion_rate", "viewability", "quartile_data"], + "available_metrics": [ + "impressions", + "completed_views", + "spend", + "completion_rate", + "viewability", + "quartile_data" + ], "date_range_support": "date_range" - } + }, + "format_options": [ + { + "format_kind": "video_vast", + "params": { + "orientation": "horizontal", + "aspect_ratio": "16:9", + "vast_version": "4.2", + "vpaid_enabled": false, + "simid_supported": false, + "duration_ms_range": [ + 6000, + 30000 + ], + "min_width": 1280, + "min_height": 720, + "linear_required": true, + "skippable_after_ms": 5000, + "max_wrapper_depth": 5, + "ssl_required": true, + "composition_model": "deterministic", + "slots": [ + { + "asset_group_id": "vast_tag", + "asset_type": "vast", + "required": true + }, + { + "asset_group_id": "landing_page_url", + "asset_type": "url", + "required": false + } + ], + "platform_extensions": [ + { + "uri": "https://google.adcp/extensions/google_universal_ad_id", + "digest": "sha256:b3c5e7f9a1c3e5b7d9f1a3c5e7b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5" + } + ] + } + } + ] } diff --git a/static/schemas/source/core/product.json b/static/schemas/source/core/product.json index 09c8fdd73f..49a17b1b61 100644 --- a/static/schemas/source/core/product.json +++ b/static/schemas/source/core/product.json @@ -36,14 +36,18 @@ }, "format_ids": { "type": "array", - "description": "v1 path: array of supported creative format IDs (structured format_id objects with agent_url and id). Products MAY use either `format_ids` (v1) or `format` (v2 inline declaration) — not both. v1 named formats remain supported through the deprecation cycle.", + "description": "v1 path: array of supported creative format IDs (structured format_id objects with agent_url and id). Products MAY use either `format_ids` (v1) or `format_options` (v2 inline declarations) — not both. v1 named formats remain supported through the deprecation cycle.", "items": { "$ref": "/schemas/core/format-id.json" } }, - "format": { - "$ref": "/schemas/core/product-format-declaration.json", - "description": "v2 path: inline format declaration. Discriminated by `format_kind` (the canonical format name) with `params` carrying that canonical's parameter schema (slots, dimensions, durations, codec ranges, character limits, platform_extensions, tracking_extensions). The format's `slots` array enumerates everything the buyer ships in the manifest's `assets` map. Mutually exclusive with `format_ids` — a product is either v1 (references named formats) or v2 (carries inline declaration), not both." + "format_options": { + "type": "array", + "minItems": 1, + "description": "v2 path: one or more inline format declarations the product accepts. Each element narrows a canonical format with parameters, slots, and platform_extensions. The 90% case is a single-element array (one canonical narrowed for the product). Multi-element use cases: a product that accepts EITHER a third-party-hosted creative (e.g., Flashtalking-served `html5`) OR an internal `display_tag`; a video product that accepts a hosted `video_hosted` upload OR a `video_vast` tag. Buyers pick which option they're shipping at `sync_creatives` time by aligning their manifest to the matching declaration's `format_kind` and slots. Mutually exclusive with `format_ids`.", + "items": { + "$ref": "/schemas/core/product-format-declaration.json" + } }, "placements": { "type": "array", @@ -500,9 +504,9 @@ "required": ["format_ids"] }, { - "title": "v2 Product (inline format declaration)", - "description": "Product carries an inline ProductFormatDeclaration narrowing exactly one canonical format. The v2 path introduced by RFC #3305.", - "required": ["format"] + "title": "v2 Product (inline format declarations)", + "description": "Product carries one or more inline ProductFormatDeclarations, each narrowing a canonical format. The v2 path introduced by RFC #3305. A single-element `format_options` array is the 90% case; multi-element arrays declare that the product accepts any of the listed format options.", + "required": ["format_options"] } ], "additionalProperties": true diff --git a/static/schemas/source/creative/validate-input-response.json b/static/schemas/source/creative/validate-input-response.json index 3ce966b549..f88cce43d4 100644 --- a/static/schemas/source/creative/validate-input-response.json +++ b/static/schemas/source/creative/validate-input-response.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/creative/validate-input-response.json", "title": "Validate Input Response", - "description": "Response payload for the validate_input task. Returns per-target validation results — one entry per format_id or product_id requested. `predicted` carries the platform's pre-flight estimate (e.g., predicted audio duration from text-length analysis), NOT the actual output — there is no protocol state for orphaned out-of-spec artifacts. For nondeterministic generative platforms, the QA-loop obligation means out-of-spec output never reaches this surface; instead, build_creative returns task_failed with synthesis_failed reason.", + "description": "Response payload for the validate_input task. Returns per-target validation results — one entry per format_id or product_id requested. `predicted` carries the platform's pre-flight estimate (e.g., predicted audio duration from text-length analysis), NOT the actual output — there is no protocol state for orphaned out-of-spec artifacts. For nondeterministic generative platforms, the QA-loop obligation means out-of-spec output never reaches this surface; instead, build_creative returns task_failed with synthesis_failed reason.\n\nThe `ValidateInputResult` type is split into its own schema (`/schemas/creative/validate-input-result.json`) rather than inlined here because the same per-target shape is intended for reuse by adjacent async-validation surfaces (planned: per-batch result envelopes on `build_creative` async paths, and asynchronous canonical-against-product validation in `sync_creatives`). Producers that only need the synchronous batch shape today MAY treat the split as YAGNI, but the schema reuse anchors the violation/retry shape so downstream surfaces don't drift.", "type": "object", "required": ["results"], "properties": { diff --git a/static/schemas/source/formats/canonical/_base.json b/static/schemas/source/formats/canonical/_base.json index e5aec5775d..852f46345b 100644 --- a/static/schemas/source/formats/canonical/_base.json +++ b/static/schemas/source/formats/canonical/_base.json @@ -5,6 +5,12 @@ "description": "Shared parameter fields that apply across canonical formats. Each canonical format extends this base with format-specific parameters (dimensions, durations, codecs, slot constraints).", "type": "object", "properties": { + "status": { + "type": "string", + "enum": ["stable", "preview", "deprecated"], + "default": "stable", + "description": "Stability tier for this canonical format. `stable` (default): the schema and tracking model are committed and any breaking changes go through normal major-version deprecation. `preview`: shipped for early adoption but the parameter shape and tracking model MAY break in a subsequent minor release once 2-3 adopters have built against it; buyers should treat preview canonicals as experimental and plan for migration. `deprecated`: replacement is available; existing adopters supported through the deprecation cycle but new adoption is discouraged. Producers SHOULD include this field on `preview` and `deprecated` canonicals; absence is interpreted as `stable`." + }, "composition_model": { "type": "string", "enum": ["deterministic", "algorithmic"], diff --git a/static/schemas/source/formats/canonical/agent_placement.json b/static/schemas/source/formats/canonical/agent_placement.json index e0697791b1..4d3ada6fcf 100644 --- a/static/schemas/source/formats/canonical/agent_placement.json +++ b/static/schemas/source/formats/canonical/agent_placement.json @@ -2,9 +2,12 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/formats/canonical/agent_placement.json", "title": "Canonical Format: Agent Placement (AI-surface sponsored placement)", - "description": "Sponsored placement integrated into an AI-surface's response to a user. Buyer supplies a `BrandRef` (resolving brand.json for context), an optional `offering_ref` to focus the mention on a specific offering, and an optional `landing_page_url` the surface MAY attach as a citation. The surface (LLM, voice assistant, sponsored-search ranker) composes a natural-language mention, sponsored card, or audio snippet within its response to a user query. **Composition is algorithmic** — the agent chooses phrasing and presentation. Output asset_type varies by surface: `text` for chat UIs and sponsored search snippets; `audio` (synthesized) for voice assistants; `card` for structured AI-surface result cards. Tracking model: mention-level impression + attribution events; per-mention id keys back to brand and offering. Distinct from `si_chat` (which is the user-converses-with-brand's-agent pattern — brand owns the conversational surface) and from `sponsored_placement` (retail-media catalog-driven). Parallels `sponsored_placement` structurally: both are surface-composed placements; agent_placement is for AI/agentic surfaces, sponsored_placement is for retail media.", + "description": "Sponsored placement integrated into an AI-surface's response to a user. Buyer supplies a `BrandRef` (resolving brand.json for context), an optional `offering_ref` to focus the mention on a specific offering, and an optional `landing_page_url` the surface MAY attach as a citation. The surface (LLM, voice assistant, sponsored-search ranker) composes a natural-language mention, sponsored card, or audio snippet within its response to a user query. **Composition is algorithmic** — the agent chooses phrasing and presentation. Output asset_type varies by surface: `text` for chat UIs and sponsored search snippets; `audio` (synthesized) for voice assistants; `card` for structured AI-surface result cards. Tracking model: mention-level impression + attribution events; per-mention id keys back to brand and offering. Distinct from `si_chat` (which is the user-converses-with-brand's-agent pattern — brand owns the conversational surface) and from `sponsored_placement` (retail-media catalog-driven). Parallels `sponsored_placement` structurally: both are surface-composed placements; agent_placement is for AI/agentic surfaces, sponsored_placement is for retail media.\n\n**Stability:** preview. The parameter shape and tracking model are still settling as ChatGPT, Perplexity, Gemini, and voice-assistant surfaces ship the first agent_placement integrations. Expect schema breakage in 3.2 once 2-3 adopters have built against this canonical and the surface composition model converges.", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { + "status": { + "default": "preview" + }, "slots": { "default": [ { "asset_group_id": "offering_ref", "asset_type": "text", "required": false }, diff --git a/static/schemas/source/formats/canonical/responsive_creative.json b/static/schemas/source/formats/canonical/responsive_creative.json index 6204e8c2af..8c1f29a2ea 100644 --- a/static/schemas/source/formats/canonical/responsive_creative.json +++ b/static/schemas/source/formats/canonical/responsive_creative.json @@ -2,9 +2,12 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/formats/canonical/responsive_creative.json", "title": "Canonical Format: Responsive Creative", - "description": "Buyer supplies a pool of typed assets (multiple headlines, descriptions, images, videos, logos); the surface algorithmically composes combinations per placement. **Composition is algorithmic** — surface picks combinations and reports per-asset performance breakdowns. Covers Google Responsive Display Ads (RDA), Responsive Search Ads (RSA), Performance Max (PMax), Demand Gen, and Meta Advantage+ creative. Industry term: \"Responsive\" (Google) / \"Advantage+ creative\" (Meta) / \"Dynamic Creative\" (older Meta term). Distinct from `sponsored_placement` (catalog-driven, deterministic) and `agent_placement` (AI-surface composition). The structured `slots` field below enumerates expected canonical asset_group_id slots; per-slot count/length narrowing lives in flat parameters (`headlines_min`, `headline_max_chars`, etc.).", + "description": "Buyer supplies a pool of typed assets (multiple headlines, descriptions, images, videos, logos); the surface algorithmically composes combinations per placement. **Composition is algorithmic** — surface picks combinations and reports per-asset performance breakdowns. Covers Google Responsive Display Ads (RDA), Responsive Search Ads (RSA), Performance Max (PMax), Demand Gen, and Meta Advantage+ creative. Industry term: \"Responsive\" (Google) / \"Advantage+ creative\" (Meta) / \"Dynamic Creative\" (older Meta term). Distinct from `sponsored_placement` (catalog-driven, deterministic) and `agent_placement` (AI-surface composition). The structured `slots` field below enumerates expected canonical asset_group_id slots; per-slot count/length narrowing lives in flat parameters (`headlines_min`, `headline_max_chars`, etc.).\n\n**Stability:** preview. Slot vocabulary and per-slot count limits track Google PMax and Meta Advantage+ surfaces, both of which still ship slot/count/policy changes regularly. Expect schema breakage in 3.2 once 2-3 adopters have built against this canonical and the slot vocabulary stabilizes across surfaces.", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { + "status": { + "default": "preview" + }, "slots": { "default": [ { "asset_group_id": "headlines", "asset_type": "text", "required": true, "min": 3, "max": 15 }, diff --git a/tests/schema-validation.test.cjs b/tests/schema-validation.test.cjs index 01314f615d..5ad824c00b 100644 --- a/tests/schema-validation.test.cjs +++ b/tests/schema-validation.test.cjs @@ -301,15 +301,15 @@ async function runTests() { } } - // product.json: assert v1 (format_ids) OR v2 (format) is required via oneOf + // product.json: assert v1 (format_ids) OR v2 (format_options) is required via oneOf const productEntry = coreSchemas.find(([p]) => path.basename(p) === 'product.json'); if (productEntry) { const [, productSchema] = productEntry; const oneOf = productSchema.oneOf || []; const hasV1Branch = oneOf.some((branch) => (branch.required || []).includes('format_ids')); - const hasV2Branch = oneOf.some((branch) => (branch.required || []).includes('format')); + const hasV2Branch = oneOf.some((branch) => (branch.required || []).includes('format_options')); if (!hasV1Branch || !hasV2Branch) { - return `product.json: must have a oneOf with v1 branch (required: ["format_ids"]) and v2 branch (required: ["format"]); found v1=${hasV1Branch}, v2=${hasV2Branch}`; + return `product.json: must have a oneOf with v1 branch (required: ["format_ids"]) and v2 branch (required: ["format_options"]); found v1=${hasV1Branch}, v2=${hasV2Branch}`; } } From 23c8bea303c5fc433f80122c9541f8f132e312b1 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 29 Apr 2026 21:25:31 -0400 Subject: [PATCH 19/41] fix(creative): drop invented creative_agents field from v2 third-party-creative-agent worked example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Flow 1 worked example I added in 19e6a3013 fabricated a `creative_agents` field on the sales agent's `get_adcp_capabilities.creative` block. That field does not exist in v2: - `creative_agents[]` is a v1 field on `list_creative_formats` (recursive- discovery hint, not a list of "approved creative agents"). It's part of the deprecated v1 surface. - `creative.supported_formats` lives on the *creative agent's* capabilities response, declaring what that agent can produce. It's not a sales-agent- side list. - The v2 sales agent's authoritative declaration of accepted formats is the product catalog (`format_options` on each product). - Buyers choose creative agents independently — through brand-side relationships, AAO registry, or direct knowledge. Rewrites the worked example accordingly: buyer reads NYTimes products, picks Flashtalking out-of-band, calls Flashtalking's build_creative, ships the manifest to NYTimes. NYTimes validates against the canonical its product narrows; it knows nothing about Flashtalking and maintains no list of approved creative agents. Drops the bogus auto-projection prose ("SDK derives supported_formats by fetching each creative_agents[].agent_url and unioning") — that projection has no basis in the v2 schema. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/creative/v2-overview.mdx | 48 ++++++----------------------------- 1 file changed, 8 insertions(+), 40 deletions(-) diff --git a/docs/creative/v2-overview.mdx b/docs/creative/v2-overview.mdx index 70190bbc9d..8a77904ccb 100644 --- a/docs/creative/v2-overview.mdx +++ b/docs/creative/v2-overview.mdx @@ -242,49 +242,17 @@ For brief-driven (talking-points-style) host-reads, the same shape applies with ## Worked example — third-party creative agent (Flashtalking + NYTimes display) -The host-read example above is single-actor by necessity: the publisher's host has to be the producer. The opposite case is the multi-actor display path, where the buyer chooses a third-party creative agent that the seller has declared in its capabilities. The seller does **not** compose creatives — it just accepts canonical-conformant manifests. +The host-read example above is single-actor by necessity: the publisher's host has to be the producer. The opposite case is the multi-actor display path, where the buyer chooses a third-party creative agent independently and ships the produced manifest to the seller. The seller does **not** compose creatives — it just accepts canonical-conformant manifests. Three actors: -- **Buyer** (Acme DSP) — discovers products, picks a creative agent, submits manifests -- **Sales agent** (NYTimes) — sells the placement, validates manifests against canonical, doesn't compose creatives -- **Creative agent** (Flashtalking) — produces creatives via `build_creative`, declares its capabilities via `creative.supported_formats` on `get_adcp_capabilities` +- **Buyer** (Acme DSP) — discovers products, picks a creative agent (out-of-band: brand-side relationships, AAO registry, direct knowledge), submits manifests +- **Sales agent** (NYTimes) — sells the placement, validates manifests against the canonical its product narrows, doesn't compose creatives, doesn't maintain a list of "approved creative agents" in v2 +- **Creative agent** (Flashtalking) — produces creatives via `build_creative`, declares its own producible catalog via `creative.supported_formats` on its OWN `get_adcp_capabilities` -### 1. Buyer reads NYTimes capabilities +The buyer chooses the creative agent independently of the seller. Sellers do not declare a list of creative agents in v2 — the v1 `creative_agents[]` recursive-discovery hint on `list_creative_formats` is part of the deprecated v1 surface. Buyers reason about creative-agent ↔ seller-product compatibility client-side: "Flashtalking can produce `image` 300×250 ≤200KB; NYTimes accepts `image` 300×250 ≤200KB; they're compatible." -Buyer calls `get_adcp_capabilities` on NYTimes. NYTimes declares which creative agents it accepts under `creative.creative_agents` and surfaces the union of their producible formats under `creative.supported_formats`: - -```json test=false -{ - "creative": { - "supports_transformation": false, - "creative_agents": [ - { "agent_url": "https://flashtalking.adcp" }, - { "agent_url": "https://creative.nytimes.adcp" } - ], - "supported_formats": [ - { - "capability_id": "flashtalking_image_300x250", - "format": { - "format_kind": "image", - "params": { "width": 300, "height": 250, "max_file_size_kb": 200, "ssl_required": true } - } - }, - { - "capability_id": "flashtalking_video_vast", - "format": { - "format_kind": "video_vast", - "params": { "vast_version": "4.2", "duration_ms_range": [6000, 30000], "linear_required": true } - } - } - ] - } -} -``` - -`supported_formats` is the buyer-facing flat catalog; `creative_agents` is the source-of-truth list. Buyers SHOULD treat `supported_formats` as authoritative for "what can I ship?" — the SDK derives it by fetching each `creative_agents[].agent_url` and unioning. (Sellers MAY override the auto-projection to narrow what they accept, e.g., "I trust Flashtalking but only for hosted html5, not VAST"; that override happens server-side and the catalog reflects the narrowed view.) - -### 2. Buyer reads NYTimes products +### 1. Buyer reads NYTimes products Buyer calls `get_products` on NYTimes. The MREC product narrows canonical `image`: @@ -302,7 +270,7 @@ Buyer calls `get_products` on NYTimes. The MREC product narrows canonical `image The product narrows the canonical; the canonical is what NYTimes commits to validating against. NYTimes does NOT validate against Flashtalking's narrowing — buyers don't need to know which creative agent produced the manifest, and Flashtalking-specific parameters (e.g., a Flashtalking placement ID) live in Flashtalking's platform extensions if at all. -### 3. Buyer calls Flashtalking's `build_creative` +### 2. Buyer calls Flashtalking's `build_creative` ```json test=false // POST https://flashtalking.adcp/build_creative @@ -333,7 +301,7 @@ Flashtalking renders an MREC PNG, returns a manifest with the produced asset: } ``` -### 4. Buyer ships to NYTimes +### 3. Buyer ships to NYTimes Buyer calls `sync_creatives` on NYTimes with the manifest from Flashtalking. NYTimes: From 6882b0cf20b3f48de73911664c666d2a0f96b5dc Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 29 Apr 2026 21:34:59 -0400 Subject: [PATCH 20/41] feat(creative): add production-source axis (image_source / video_source / item_production_model) for generative-DSP and multi-output patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the asymmetry where audio_hosted handled "who renders" via audio_source but image and video_hosted had no analogous parameter. That left generative-DSP-shaped adopters (universalads, Pencil, AdCreative.ai-shaped tools, GenStudio-shaped tools) without a clean expression — they had to fudge composition_model or invent platform extensions for what's actually a common pattern. Added: - image_source on image canonical: buyer_uploaded | seller_pre_rendered_from_brief | seller_human_designed | agent_synthesized (default buyer_uploaded). Plus buyer_image_acceptance: accepted | rejected. - video_source on video_hosted canonical: same enum and pattern mirroring image_source. Plus buyer_video_acceptance. - item_production_model on sponsored_placement: same enum applied per catalog item. Captures the multi-output generative pattern (1 brief × N catalog items → N rendered creatives — universalads_generated_offerings shape) under sponsored_placement without requiring a 12th canonical. These are informational, not the binding contract. The format's slots declaration is what binds; *_source describes how the product produces the rendered creative so buyers can pick products whose production model fits their workflow. v2-overview.mdx now explicitly differentiates the two orthogonal axes: - composition_model — how the surface composes per-impression (deterministic vs algorithmic per-impression). - production source — who renders, and when (per-canonical *_source parameters). Conflating them was the gap. A generative DSP that produces ONE rendered image from a brief is composition_model:deterministic + image_source:seller_pre_rendered_from_brief — not a new composition pattern, just a different production source. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../v2-review-feedback-format-options.md | 14 ++++++++++++++ docs/creative/v2-overview.mdx | 18 ++++++++++++++++++ .../source/formats/canonical/image.json | 12 ++++++++++++ .../formats/canonical/sponsored_placement.json | 6 ++++++ .../source/formats/canonical/video_hosted.json | 12 ++++++++++++ 5 files changed, 62 insertions(+) diff --git a/.changeset/v2-review-feedback-format-options.md b/.changeset/v2-review-feedback-format-options.md index 49e06d9982..26df07275e 100644 --- a/.changeset/v2-review-feedback-format-options.md +++ b/.changeset/v2-review-feedback-format-options.md @@ -25,4 +25,18 @@ Addresses external review feedback on RFC #3305 / PR #3307 before the 3.1.0 beta **Why minor:** structural rename of `product.format` → `product.format_options` is technically breaking for anyone who built against the v2 path during the preview window, but the v2 path was only landed in this PR (#3307) and is not yet released — no published 3.x version carries `format`. The shipping shape is `format_options`. Anyone building against the preview branch should re-pull. The other changes are additive. +**Production-source taxonomy (universalads / generative-DSP gap):** + +The audio_hosted canonical handles "who renders" via `audio_source` (`buyer_uploaded` / `publisher_host_recorded` / `agent_synthesized`) plus `buyer_audio_acceptance`. The image and video_hosted canonicals had no analogous parameter, which forced generative-DSP-shaped adopters (universalads, Pencil, AdCreative.ai-shaped tools, GenStudio-shaped tools) to either fudge `composition_model` or invent platform extensions to express what's actually a common pattern. + +This change adds: + +- `image_source` on `image` — `buyer_uploaded | seller_pre_rendered_from_brief | seller_human_designed | agent_synthesized` (default `buyer_uploaded`). Plus `buyer_image_acceptance: accepted | rejected`. +- `video_source` on `video_hosted` — same enum and pattern as `image_source`. Plus `buyer_video_acceptance: accepted | rejected`. +- `item_production_model` on `sponsored_placement` — same enum, applied per catalog item. Captures the multi-output generative pattern (1 brief × N catalog items → N rendered creatives) under the existing `sponsored_placement` canonical without requiring a 12th canonical. + +These are informational fields, not the binding contract — the format's `slots` declaration is the contract. The `*_source` fields let buyers pick products whose production model fits their workflow (in-house pre-rendered vs upstream creative agent vs seller-driven generative). + +The v2-overview.mdx narrative now explicitly differentiates the two orthogonal axes — `composition_model` (how the surface composes per-impression: deterministic vs algorithmic) and per-canonical production source (who renders, and when). Conflating them was the gap that left generative DSPs without a clean expression in v2. + Tracks #3305 (v2 RFC) and #3307 (preview branch). diff --git a/docs/creative/v2-overview.mdx b/docs/creative/v2-overview.mdx index 8a77904ccb..9af341463a 100644 --- a/docs/creative/v2-overview.mdx +++ b/docs/creative/v2-overview.mdx @@ -42,6 +42,24 @@ Each canonical lives at `/schemas/formats/canonical/.json`. Tracking model The two `preview` canonicals (`responsive_creative`, `agent_placement`) carry surfaces whose composition models are still settling — Google PMax / Meta Advantage+ for responsive; ChatGPT / Perplexity / voice assistants for agent_placement. Their parameter shape and tracking model MAY break in 3.2 once 2-3 adopters have built against them. Buyers SHOULD plan for migration; sellers SHOULD treat preview-canonical narrowing as experimental contract surface, not a long-term commitment. The other 9 canonicals are anchored in stable IAB / platform standards (IAB display dimensions, IAB VAST 4.2, IAB DAAST 1.1, retail-media catalog conventions) and are committed. +## Two axes: composition (per-impression) vs production (who renders) + +Two orthogonal patterns govern how a creative is produced and how it serves. Conflating them is the most common authoring mistake. + +**Composition model** — `composition_model: deterministic | algorithmic` on the format declaration. Describes how the **surface composes per-impression**: +- `deterministic` — buyer can predict per-slot rendering. The surface serves what it received. (`image`, `video_hosted`, `audio_hosted`, `video_vast`, `audio_daast`, `sponsored_placement`.) +- `algorithmic` — surface picks combinations from a buyer-supplied asset pool per-impression. The buyer ships a pool; the surface composes. (`responsive_creative` for Google PMax / Meta Advantage+; `agent_placement` for AI-surface composition.) + +**Production source** — per-canonical `*_source` parameters. Describes **who renders the rendered asset, and when**: +- `audio_source` on `audio_hosted` — `buyer_uploaded | publisher_host_recorded | agent_synthesized` +- `image_source` on `image` — `buyer_uploaded | seller_pre_rendered_from_brief | seller_human_designed | agent_synthesized` +- `video_source` on `video_hosted` — same enum as `image_source` +- `item_production_model` on `sponsored_placement` — same enum, applied per catalog item (the multi-output generative case: 1 brief × N catalog items → N rendered creatives) + +The two axes don't collapse. A generative DSP that produces ONE rendered image from a brief is `composition_model: deterministic` (the surface serves what it received) + `image_source: seller_pre_rendered_from_brief` (seller produced it from inputs at sync_creatives time). A retail-media surface that runs an AI synthesis pipeline per catalog item is `composition_model: deterministic` + `item_production_model: agent_synthesized`. Google PMax is `composition_model: algorithmic` + (production-source unspecified — buyer ships a pool of pre-rendered assets so the production-source question doesn't apply at the format level). + +The production-source enums are informational, not the binding contract. The format's `slots` declaration is the contract — what the buyer ships, in what shape. The `*_source` field tells the buyer "here's how this product produces the rendered creative" so they can pick products whose production model fits their workflow (in-house pre-rendered vs upstream creative agent vs seller-driven generative). + ## Worked example — Meta Reels Meta Reels narrows `video_hosted` (vertical orientation) with Meta-specific parameters and platform extensions: diff --git a/static/schemas/source/formats/canonical/image.json b/static/schemas/source/formats/canonical/image.json index f2211f0f26..69c7f42186 100644 --- a/static/schemas/source/formats/canonical/image.json +++ b/static/schemas/source/formats/canonical/image.json @@ -46,6 +46,18 @@ "type": "array", "items": { "type": "string" }, "description": "Permitted CTA values for this product (e.g., ['LEARN_MORE', 'SHOP_NOW'])." + }, + "image_source": { + "type": "string", + "enum": ["buyer_uploaded", "seller_pre_rendered_from_brief", "seller_human_designed", "agent_synthesized"], + "default": "buyer_uploaded", + "description": "Where the rendered image comes from. Parallels `audio_source` on `audio_hosted`. `buyer_uploaded` (default): buyer ships a pre-rendered image asset; product narrows file format / dimensions / size. `seller_pre_rendered_from_brief`: buyer ships a brief plus structured copy (headline, landing_page_url); seller renders ONE image at sync_creatives or build_creative time and serves it like any deterministic creative (generative-DSP pattern: universalads, Pencil, AdCreative.ai). `seller_human_designed`: seller's design team renders manually from a brief (human-in-the-loop services). `agent_synthesized`: AI synthesis pipeline; pair with `synthesis_nondeterministic: true` when the platform cannot guarantee in-spec output (Veo/Sora/Imagen-class). The `slots` declaration is the binding contract for what the buyer ships; `image_source` is informational and lets buyers understand the production model when picking products." + }, + "buyer_image_acceptance": { + "type": "string", + "enum": ["accepted", "rejected"], + "default": "accepted", + "description": "Whether the product accepts buyer-uploaded images. When `rejected`, the buyer cannot ship an image asset directly — they must use build_creative (or sync_creatives with brief inputs) so the seller produces the image. Combined with `image_source`, lets a product declare 'I produce images from briefs and refuse buyer uploads' (image_source=`seller_pre_rendered_from_brief`, buyer_image_acceptance=`rejected`)." } }, "additionalProperties": true diff --git a/static/schemas/source/formats/canonical/sponsored_placement.json b/static/schemas/source/formats/canonical/sponsored_placement.json index 7193fb33a7..889c521a7c 100644 --- a/static/schemas/source/formats/canonical/sponsored_placement.json +++ b/static/schemas/source/formats/canonical/sponsored_placement.json @@ -47,6 +47,12 @@ "hero_asset_supported": { "type": "boolean", "description": "Whether the buyer can supply a hero/banner asset alongside the catalog (Pinterest Collection pattern)." + }, + "item_production_model": { + "type": "string", + "enum": ["buyer_pre_rendered", "seller_pre_rendered_from_brief", "seller_human_designed", "agent_synthesized"], + "default": "buyer_pre_rendered", + "description": "How each per-item creative is produced. `buyer_pre_rendered` (default, current Amazon/Criteo/CitrusAd pattern): the buyer's catalog already contains rendered assets per item; the seller composes the placement using those assets. `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." } }, "additionalProperties": true diff --git a/static/schemas/source/formats/canonical/video_hosted.json b/static/schemas/source/formats/canonical/video_hosted.json index c7336adc95..b315613af6 100644 --- a/static/schemas/source/formats/canonical/video_hosted.json +++ b/static/schemas/source/formats/canonical/video_hosted.json @@ -72,6 +72,18 @@ "companion_banner_heights": { "type": "array", "items": { "type": "integer", "minimum": 1 } + }, + "video_source": { + "type": "string", + "enum": ["buyer_uploaded", "seller_pre_rendered_from_brief", "seller_human_designed", "agent_synthesized"], + "default": "buyer_uploaded", + "description": "Where the rendered video comes from. Parallels `audio_source` on `audio_hosted`. `buyer_uploaded` (default): buyer ships a pre-rendered video asset; product narrows codec / dimensions / duration. `seller_pre_rendered_from_brief`: buyer ships a brief plus structured copy; seller renders ONE video at sync_creatives or build_creative time and serves it like any deterministic creative (generative-DSP pattern). `seller_human_designed`: seller's editorial / design team renders manually from a brief. `agent_synthesized`: AI synthesis pipeline; pair with `synthesis_nondeterministic: true` for Veo / Sora / Runway-class flows where output dimensions or duration vary per run. The `slots` declaration is the binding contract for what the buyer ships; `video_source` is informational and lets buyers understand the production model when picking products." + }, + "buyer_video_acceptance": { + "type": "string", + "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 (or sync_creatives with brief inputs) so the seller produces the video." } }, "additionalProperties": true From e0749418f5391d72457f6892dd9f871d534cc29a Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 29 Apr 2026 22:19:07 -0400 Subject: [PATCH 21/41] =?UTF-8?q?feat(creative):=20v2=20red-team=20schema?= =?UTF-8?q?=20fixes=20=E2=80=94=20manifest=20v2=20path,=20carousel=20cards?= =?UTF-8?q?,=20audio=5Fsource=20widening,=20oneOf=20tightening,=20slots=20?= =?UTF-8?q?inline,=20status=20pathway,=20digest=20collision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes red-team Must-Fix items from the protocol-expert pass: - M1: Manifest v2 path. creative-manifest.json and creative-asset.json now carry oneOf(format_id v1 path | format_kind v2 path) with explicit not on each branch. Adds /schemas/core/canonical-format-kind.json enum to back the v2 path. Adds optional capability_id field to disambiguate when a product's format_options carries multiple declarations sharing the same format_kind. Without this, v2 products had no v2 manifest counterpart — every SDK author would invent a different bridge. - M2: format_options routing. ProductFormatDeclaration grows capability_id (stable identifier for routing) and applies_to_channels (subset of the product's channels this declaration applies to — covers S15 too). Lets a multi-channel product carry channel-specific format_options. - M3: Veo fixture used audio_source / buyer_audio_acceptance on a video_hosted format. Renamed to video_source / buyer_video_acceptance. - M4: audio_source enum was narrower than image_source / video_source. Widened to match (added seller_pre_rendered_from_brief and seller_human_designed). TTS-from-brief and studio-produced audio now expressible. - M5: product.json oneOf branches got explicit not: required: [other] so a payload carrying both format_ids AND format_options fails closed under any validator. - M6: get-adcp-capabilities-response.json supported_formats descriptions referenced the dropped 'inputs' concept (collapsed into slots in r4). Replaced with format_kind + params + slots framing. - M7: image_carousel slot model. Added a default slots declaration with cards slot (asset_type: object, min/max bounds), plus a normative card_shape parameter documenting the per-card object structure (media + headline + landing_page_url). assets.cards is now the unambiguous array-under-one-key contract; per-card key conventions (card_0_headline, cards.0.headline) are forbidden. - N: Slots inline default added to all 11 canonicals (previously only on 3). SDK codegen now produces typed slot lists for every canonical. - N: Synthesis_nondeterministic compatibility table added to _base.json description. seller_pre_rendered_from_brief / seller_human_designed / agent_synthesized may pair with synthesis_nondeterministic: true. buyer_uploaded and publisher_host_recorded MUST NOT. - N: platform-extension-ref digest collision behavior documented. Within a single response, divergent digests for the same uri MUST fail closed. Across responses, divergence is normal (extension version updates). - S16: status:preview deprecation pathway. _base.json status field gets since_version + migration_target_version siblings, plus a stabilization rubric ("preview → stable when 2 adopters ship + 90 days no breaking change"). Adopters get a schema-level signal of where each canonical is in the lifecycle. Test rule updated for the new oneOf shape on creative-asset.json. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../products/v2/veo_generative_video_15s.json | 4 +-- .../source/core/canonical-format-kind.json | 20 +++++++++++++ .../schemas/source/core/creative-asset.json | 29 ++++++++++++++++--- .../source/core/creative-manifest.json | 27 +++++++++++++++-- .../source/core/platform-extension-ref.json | 2 +- .../core/product-format-declaration.json | 14 ++++++++- static/schemas/source/core/product.json | 6 ++-- .../source/formats/canonical/_base.json | 12 ++++++-- .../source/formats/canonical/audio_daast.json | 7 +++++ .../formats/canonical/audio_hosted.json | 17 +++++++++-- .../source/formats/canonical/display_tag.json | 7 +++++ .../source/formats/canonical/html5.json | 8 +++++ .../source/formats/canonical/image.json | 9 ++++++ .../formats/canonical/image_carousel.json | 26 ++++++++++++++++- .../formats/canonical/video_hosted.json | 10 +++++++ .../source/formats/canonical/video_vast.json | 7 +++++ .../get-adcp-capabilities-response.json | 4 +-- tests/schema-validation.test.cjs | 17 +++++++++-- 18 files changed, 203 insertions(+), 23 deletions(-) create mode 100644 static/schemas/source/core/canonical-format-kind.json diff --git a/static/examples/products/v2/veo_generative_video_15s.json b/static/examples/products/v2/veo_generative_video_15s.json index b0f9a7b759..8112d764bc 100644 --- a/static/examples/products/v2/veo_generative_video_15s.json +++ b/static/examples/products/v2/veo_generative_video_15s.json @@ -57,8 +57,8 @@ 24, 30 ], - "audio_source": "agent_synthesized", - "buyer_audio_acceptance": "rejected", + "video_source": "agent_synthesized", + "buyer_video_acceptance": "rejected", "captions": "recommended", "composition_model": "deterministic", "synthesis_nondeterministic": true, diff --git a/static/schemas/source/core/canonical-format-kind.json b/static/schemas/source/core/canonical-format-kind.json new file mode 100644 index 0000000000..489557dd14 --- /dev/null +++ b/static/schemas/source/core/canonical-format-kind.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/canonical-format-kind.json", + "title": "Canonical Format Kind", + "description": "Discriminator value naming one of the 11 canonical creative formats. Used by `product-format-declaration.json` (the product's inline format declaration), `creative-manifest.json` (the buyer's v2 manifest path), and any other surface that needs to identify which canonical a payload targets. The enum mirrors the `oneOf` branches in `product-format-declaration.json`; keep them in sync.", + "type": "string", + "enum": [ + "image", + "html5", + "display_tag", + "image_carousel", + "video_hosted", + "video_vast", + "audio_hosted", + "audio_daast", + "sponsored_placement", + "responsive_creative", + "agent_placement" + ] +} diff --git a/static/schemas/source/core/creative-asset.json b/static/schemas/source/core/creative-asset.json index 59b6eb8841..1b15cf6115 100644 --- a/static/schemas/source/core/creative-asset.json +++ b/static/schemas/source/core/creative-asset.json @@ -2,12 +2,12 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/creative-asset.json", "title": "Creative Asset", - "description": "Creative asset for upload to library - supports static assets, generative formats, and third-party snippets", + "description": "Creative asset for upload to library — supports static assets, generative formats, and third-party snippets. Identifies which format this creative conforms to via EITHER a v1 `format_id` (structured `{agent_url, id}`) OR v2 `format_kind` (canonical format name). Mutually exclusive — see the `oneOf` at the schema root.", "type": "object", "properties": { "creative_id": { "type": "string", - "description": "Unique identifier for the creative", + "description": "Unique identifier for the creative. Stable across v1 and v2 paths — a creative registered against v1 `format_id` retains the same `creative_id` when later viewed via v2 flatten.", "x-entity": "creative" }, "name": { @@ -16,7 +16,15 @@ }, "format_id": { "$ref": "/schemas/core/format-id.json", - "description": "Always a structured object {agent_url, id} — never a plain string. Format identifier specifying which format this creative conforms to. Can be: (1) concrete format_id referencing a format with fixed dimensions, (2) template format_id referencing a template format, or (3) parameterized format_id with dimensions/duration parameters for template formats." + "description": "v1 path. Always a structured object {agent_url, id} — never a plain string. Format identifier specifying which format this creative conforms to. Can be: (1) concrete format_id referencing a format with fixed dimensions, (2) template format_id referencing a template format, or (3) parameterized format_id with dimensions/duration parameters for template formats. Mutually exclusive with `format_kind`." + }, + "format_kind": { + "$ref": "/schemas/core/canonical-format-kind.json", + "description": "v2 path. The canonical format name this creative targets (e.g., `image`, `video_hosted`). Mutually exclusive with `format_id`." + }, + "capability_id": { + "type": "string", + "description": "v2 path, optional. Stable identifier matching one of the seller's product `format_options[i].capability_id` values. REQUIRED only when the target product has multiple `format_options` entries sharing the same `format_kind`." }, "assets": { "type": "object", @@ -116,8 +124,21 @@ "required": [ "creative_id", "name", - "format_id", "assets" ], + "oneOf": [ + { + "title": "v1 creative (named-format reference)", + "description": "Creative references a named format via the structured `format_id` object. The v1 path; remains supported through 4.x.", + "required": ["format_id"], + "not": { "required": ["format_kind"] } + }, + { + "title": "v2 creative (canonical format kind)", + "description": "Creative declares which canonical format it targets via `format_kind` (e.g., `image`). The v2 path introduced by RFC #3305.", + "required": ["format_kind"], + "not": { "required": ["format_id"] } + } + ], "additionalProperties": true } diff --git a/static/schemas/source/core/creative-manifest.json b/static/schemas/source/core/creative-manifest.json index eb0d2c77c8..11821eb43f 100644 --- a/static/schemas/source/core/creative-manifest.json +++ b/static/schemas/source/core/creative-manifest.json @@ -2,12 +2,20 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/creative-manifest.json", "title": "Creative Manifest", - "description": "Complete specification of a creative: format_id + assets. Everything the creative needs — images, text, briefs, catalogs — lives in the assets map, declared by the format. Each asset is typed according to its asset_type from the format specification.", + "description": "Complete specification of a creative: format identification + assets. A manifest carries EITHER a v1 `format_id` (structured `{agent_url, id}` reference to a named format) OR v2 `format_kind` (the canonical format name the manifest targets, paired with optional `capability_id` for routing when a product's `format_options` contains multiple declarations sharing the same `format_kind`). Mutually exclusive — see the `oneOf` at the schema root. Everything the creative needs — images, text, briefs, catalogs — lives in the assets map, declared by the matching format declaration. Each asset is typed according to its asset_type from the format specification.", "type": "object", "properties": { "format_id": { "$ref": "/schemas/core/format-id.json", - "description": "Always a structured object {agent_url, id} — never a plain string. Format identifier this manifest is for. Can be a template format (id only) or a deterministic format (id + dimensions/duration). For dimension-specific creatives, include width/height in the format_id to create a unique identifier (e.g., {id: 'display_static', width: 300, height: 250})." + "description": "v1 path. Always a structured object {agent_url, id} — never a plain string. Format identifier this manifest is for. Can be a template format (id only) or a deterministic format (id + dimensions/duration). For dimension-specific creatives, include width/height in the format_id to create a unique identifier (e.g., {id: 'display_static', width: 300, height: 250}). Mutually exclusive with `format_kind`." + }, + "format_kind": { + "$ref": "/schemas/core/canonical-format-kind.json", + "description": "v2 path. The canonical format name this manifest targets (e.g., `image`, `video_hosted`, `audio_daast`, `sponsored_placement`). Selects which canonical the seller validates the manifest's assets against. Mutually exclusive with `format_id`." + }, + "capability_id": { + "type": "string", + "description": "v2 path, optional. Stable identifier matching one of the seller's product `format_options[i].capability_id` values. REQUIRED when the target product carries multiple `format_options` entries sharing the same `format_kind` (the buyer must disambiguate which option this manifest matches). When the product's `format_options` has a single entry — or multiple entries with distinct `format_kind` values — `capability_id` is OPTIONAL because `format_kind` alone routes the manifest to the right declaration." }, "assets": { "type": "object", @@ -94,8 +102,21 @@ } }, "required": [ - "format_id", "assets" ], + "oneOf": [ + { + "title": "v1 manifest (named-format reference)", + "description": "Manifest references a named format via the structured `format_id` object. The v1 path; remains supported through 4.x.", + "required": ["format_id"], + "not": { "required": ["format_kind"] } + }, + { + "title": "v2 manifest (canonical format kind)", + "description": "Manifest declares which canonical format it targets via `format_kind` (e.g., `image`). The v2 path introduced by RFC #3305.", + "required": ["format_kind"], + "not": { "required": ["format_id"] } + } + ], "additionalProperties": true } diff --git a/static/schemas/source/core/platform-extension-ref.json b/static/schemas/source/core/platform-extension-ref.json index 70b06376c1..8d9f5eb591 100644 --- a/static/schemas/source/core/platform-extension-ref.json +++ b/static/schemas/source/core/platform-extension-ref.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/platform-extension-ref.json", "title": "Platform Extension Reference", - "description": "Reference to a platform extension definition. The agent that owns the URI is authoritative for the extension's schema. Buyers fetch the definition once per content digest and cache it. Platform extensions are typically bundled in `get_products` responses under an `extensions` map keyed by `uri@digest`, eliminating the need for a separate fetch.", + "description": "Reference to a platform extension definition. The agent that owns the URI is authoritative for the extension's schema. Buyers fetch the definition once per content digest and cache it. Platform extensions are typically bundled in `get_products` responses under an `extensions` map keyed by `uri@digest`, eliminating the need for a separate fetch.\n\n**Within a single response**, multiple references to the same `uri` MUST carry the same `digest` — divergent digests in one response indicate producer-side error (e.g., concurrent extension revision mid-render). Buyers encountering divergent digests for the same URI MUST fail closed: treat all references to that URI as unresolved and surface a validation error rather than picking one branch silently. **Across responses**, digest divergence is normal — extension authors revise their schemas, the new digest differs, the cache key changes, and the buyer refetches. Cache by `uri@digest`, not by `uri` alone.", "type": "object", "required": ["uri", "digest"], "properties": { diff --git a/static/schemas/source/core/product-format-declaration.json b/static/schemas/source/core/product-format-declaration.json index 87c6bd33c7..246839d8aa 100644 --- a/static/schemas/source/core/product-format-declaration.json +++ b/static/schemas/source/core/product-format-declaration.json @@ -2,10 +2,22 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/product-format-declaration.json", "title": "Product Format Declaration", - "description": "Inline format declaration on a product. The `format_kind` discriminator names which canonical format the product narrows; `params` carries the canonical's parameter schema (slots, dimensions, durations, codecs, character limits, platform_extensions, tracking_extensions, etc.). Discriminated-union shape generates clean tagged unions in TypeScript and Pydantic codegen. Replaces v1's named-format pattern (where products referenced a separately-defined format file via compound `format_id`). v1 named formats remain supported through the deprecation cycle; v2 product-bound declarations are opt-in.", + "description": "Inline format declaration on a product. The `format_kind` discriminator names which canonical format the product narrows; `params` carries the canonical's parameter schema (slots, dimensions, durations, codecs, character limits, platform_extensions, tracking_extensions, etc.). Optional `capability_id` (stable identifier for routing when a product's `format_options` contains multiple declarations sharing the same `format_kind`) and `applies_to_channels` (subset of the product's declared channels this declaration applies to — lets a multi-channel product carry distinct format_options per channel). Discriminated-union shape generates clean tagged unions in TypeScript and Pydantic codegen. Replaces v1's named-format pattern (where products referenced a separately-defined format file via compound `format_id`). v1 named formats remain supported through the deprecation cycle; v2 product-bound declarations are opt-in.", "type": "object", "required": ["format_kind", "params"], "discriminator": { "propertyName": "format_kind" }, + "properties": { + "capability_id": { + "type": "string", + "description": "Optional stable identifier for this format declaration. REQUIRED when the parent product's `format_options` contains multiple declarations sharing the same `format_kind` (so buyers can disambiguate which option a manifest targets via `manifest.capability_id`). Recommended for any declaration that may be referenced by capability_id over time. Format-internal (not a URI). Examples: 'flashtalking_image_300x250', 'pmax_responsive_search'." + }, + "applies_to_channels": { + "type": "array", + "items": { "$ref": "/schemas/enums/channels.json" }, + "uniqueItems": true, + "description": "Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." + } + }, "oneOf": [ { "title": "Image Format Declaration", diff --git a/static/schemas/source/core/product.json b/static/schemas/source/core/product.json index 49a17b1b61..30b13fe2d7 100644 --- a/static/schemas/source/core/product.json +++ b/static/schemas/source/core/product.json @@ -501,12 +501,14 @@ { "title": "v1 Product (named-format reference)", "description": "Product references one or more named formats by structured format_id ({ agent_url, id }). The v1 path; remains supported through 4.x.", - "required": ["format_ids"] + "required": ["format_ids"], + "not": { "required": ["format_options"] } }, { "title": "v2 Product (inline format declarations)", "description": "Product carries one or more inline ProductFormatDeclarations, each narrowing a canonical format. The v2 path introduced by RFC #3305. A single-element `format_options` array is the 90% case; multi-element arrays declare that the product accepts any of the listed format options.", - "required": ["format_options"] + "required": ["format_options"], + "not": { "required": ["format_ids"] } } ], "additionalProperties": true diff --git a/static/schemas/source/formats/canonical/_base.json b/static/schemas/source/formats/canonical/_base.json index 852f46345b..359e69471d 100644 --- a/static/schemas/source/formats/canonical/_base.json +++ b/static/schemas/source/formats/canonical/_base.json @@ -9,7 +9,15 @@ "type": "string", "enum": ["stable", "preview", "deprecated"], "default": "stable", - "description": "Stability tier for this canonical format. `stable` (default): the schema and tracking model are committed and any breaking changes go through normal major-version deprecation. `preview`: shipped for early adoption but the parameter shape and tracking model MAY break in a subsequent minor release once 2-3 adopters have built against it; buyers should treat preview canonicals as experimental and plan for migration. `deprecated`: replacement is available; existing adopters supported through the deprecation cycle but new adoption is discouraged. Producers SHOULD include this field on `preview` and `deprecated` canonicals; absence is interpreted as `stable`." + "description": "Stability tier for this canonical format. `stable` (default): the schema and tracking model are committed and any breaking changes go through normal major-version deprecation. `preview`: shipped for early adoption but the parameter shape and tracking model MAY break in a subsequent minor release once 2-3 adopters have built against it; buyers should treat preview canonicals as experimental and plan for migration. **Stabilization rubric**: a preview canonical is promoted to `stable` once (a) at least 2 production adopters have built against it AND (b) 90 consecutive days have passed without a breaking change to its parameter shape. Each preview canonical also carries a `migration_target_version` indicating the version by which the working group expects to either stabilize it or surface a breaking revision. `deprecated`: replacement is available; existing adopters supported through the deprecation cycle but new adoption is discouraged. Producers SHOULD include this field on `preview` and `deprecated` canonicals; absence is interpreted as `stable`." + }, + "since_version": { + "type": "string", + "description": "AdCP release-precision version that introduced this canonical (e.g., '3.1', '3.2.0'). Lets adopters reason about minimum protocol version requirements when consuming a format declaration." + }, + "migration_target_version": { + "type": "string", + "description": "For `preview` canonicals: the AdCP release-precision version by which the working group expects to either promote the canonical to `stable` or surface a breaking revision. Lets adopters time their migration. Unset for `stable` canonicals (they migrate via the normal major-version deprecation cycle). For `deprecated` canonicals, indicates the release in which the canonical will be removed." }, "composition_model": { "type": "string", @@ -32,7 +40,7 @@ }, "synthesis_nondeterministic": { "type": "boolean", - "description": "When true, the format's production pipeline is genuinely nondeterministic — the platform cannot guarantee that synthesis from a given input set produces in-spec output. Veo / Sora / Runway-class generative video, and other AI-synthesis flows where output dimensions, duration, or quality vary per run. Implies a different validation contract: predictive `validate_input` is impossible; the platform's own post-synthesis QA loop applies; if the QA loop exhausts without producing a valid artifact, `build_creative` returns task_failed with a synthesis_failed reason. Distinct from `composition_model` (which describes how the surface composes per-slot rendering, not whether synthesis is deterministic). When false or absent, the format's production is predictable enough that `validate_input` can predict output properties from input properties.", + "description": "When true, the format's production pipeline is genuinely nondeterministic — the platform cannot guarantee that synthesis from a given input set produces in-spec output. Veo / Sora / Runway-class generative video, and other AI-synthesis flows where output dimensions, duration, or quality vary per run. Implies a different validation contract: predictive `validate_input` is impossible; the platform's own post-synthesis QA loop applies; if the QA loop exhausts without producing a valid artifact, `build_creative` returns task_failed with a synthesis_failed reason. Distinct from `composition_model` (which describes how the surface composes per-slot rendering, not whether synthesis is deterministic). When false or absent, the format's production is predictable enough that `validate_input` can predict output properties from input properties.\n\n**Compatibility with production-source enums** (`audio_source` / `image_source` / `video_source` / `item_production_model`): `synthesis_nondeterministic: true` MAY pair with any of `seller_pre_rendered_from_brief`, `seller_human_designed`, or `agent_synthesized` (the QA loop is concept-level, not source-specific — \"seller renders from brief but each retry differs\" is just as nondeterministic as Veo). It MUST NOT pair with `buyer_uploaded` (the buyer ships pre-rendered bytes; there's no synthesis step to be nondeterministic about). It MUST NOT pair with `publisher_host_recorded` (the publisher's host produces a deterministic-from-script output even if the human voice varies). When `synthesis_nondeterministic: true` is set with an incompatible source, validators SHOULD reject with a structured error.", "default": false }, "slots": { diff --git a/static/schemas/source/formats/canonical/audio_daast.json b/static/schemas/source/formats/canonical/audio_daast.json index 1f091e36aa..f0a50b7d28 100644 --- a/static/schemas/source/formats/canonical/audio_daast.json +++ b/static/schemas/source/formats/canonical/audio_daast.json @@ -5,6 +5,13 @@ "description": "DAAST-tag-delivered audio creative (audio analog of VAST). Slot: `daast_tag` (daast asset, URL or inline XML). Tracking model: DAAST events inherent to the spec — `impression`, `firstQuartile`, `midpoint`, `thirdQuartile`, `complete`, `start`, `pause`, `resume`, `mute`, `unmute`, `clickTracking`, `error`. Distinct from `audio_hosted` (direct file with external tracking).", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { + "slots": { + "default": [ + { "asset_group_id": "daast_tag", "asset_type": "daast", "required": true }, + { "asset_group_id": "landing_page_url", "asset_type": "url", "required": false } + ], + "description": "Default slots for audio_daast canonical. Buyer ships a DAAST tag (URL or inline XML, 1.0 or 1.1) plus an optional clickthrough URL. Tracking events are inherent to DAAST and don't require explicit slots." + }, "daast_version": { "type": "string", "enum": ["1.0", "1.1"] diff --git a/static/schemas/source/formats/canonical/audio_hosted.json b/static/schemas/source/formats/canonical/audio_hosted.json index 8029ea5604..0169668a1b 100644 --- a/static/schemas/source/formats/canonical/audio_hosted.json +++ b/static/schemas/source/formats/canonical/audio_hosted.json @@ -5,6 +5,15 @@ "description": "Direct audio creative — buyer ships an `audio` asset (mp3/aac/wav) for asset-driven products, or ships a `script` / `creative_brief` text asset for products where the seller produces audio internally (podcast host-reads, TTS synthesis). Optional companion slots: `companion_image`, `brand_name`, `landing_page_url`. Tracking model: standard impression + completion + companion-image-click pixels via universal_macros. Distinct from `audio_daast` (DAAST tag, inherent DAAST event tracking). For host-reads and synthesized audio, the format declares `audio_source: 'publisher_host_recorded'` or `'agent_synthesized'` plus `buyer_audio_acceptance: 'rejected'`; the format's `slots` declaration enumerates which assets the buyer ships (e.g., `script` text asset for host-reads). The seller decides how to consume each asset (render verbatim vs produce audio from text) — there is no separate manifest 'inputs' map; everything the buyer ships goes in `assets`.", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { + "slots": { + "default": [ + { "asset_group_id": "audio_main", "asset_type": "audio", "required": true }, + { "asset_group_id": "companion_image", "asset_type": "image", "required": false }, + { "asset_group_id": "brand_name", "asset_type": "text", "required": false }, + { "asset_group_id": "landing_page_url", "asset_type": "url", "required": false } + ], + "description": "Default slots for buyer-uploaded audio. Host-read products override with a `script` (asset_type: text) or `creative_brief` (asset_type: brief) slot in place of `audio_main`, plus `audio_source: 'publisher_host_recorded'` and `buyer_audio_acceptance: 'rejected'`. TTS-from-script products override similarly with `audio_source: 'seller_pre_rendered_from_brief'`." + }, "duration_ms_range": { "type": "array", "items": { "type": "integer", "minimum": 0 }, @@ -44,13 +53,15 @@ }, "audio_source": { "type": "string", - "enum": ["buyer_uploaded", "publisher_host_recorded", "agent_synthesized"], - "description": "Where the audio comes from. `publisher_host_recorded` indicates the publisher's host records the audio (typical for podcast host-reads); buyer must use the publisher's build_creative capability." + "enum": ["buyer_uploaded", "publisher_host_recorded", "seller_pre_rendered_from_brief", "seller_human_designed", "agent_synthesized"], + "default": "buyer_uploaded", + "description": "Where the rendered audio comes from. Parallels `image_source` on `image` and `video_source` on `video_hosted`. `buyer_uploaded` (default): buyer ships a pre-rendered audio asset. `publisher_host_recorded`: the publisher's host records the audio (podcast host-read pattern); buyer must use the publisher's build_creative capability. `seller_pre_rendered_from_brief`: buyer ships a brief plus structured copy; seller renders ONE audio file from those inputs at sync_creatives or build_creative time (TTS-from-brief, AI-narration-from-script). `seller_human_designed`: seller's studio team produces the audio manually. `agent_synthesized`: AI synthesis pipeline (TTS or generative audio); pair with `synthesis_nondeterministic: true` when the platform cannot guarantee in-spec output. The `slots` declaration is the binding contract for what the buyer ships; `audio_source` is informational." }, "buyer_audio_acceptance": { "type": "string", "enum": ["accepted", "rejected"], - "description": "Whether the product accepts buyer-uploaded audio. When `rejected`, audio must come from build_creative." + "default": "accepted", + "description": "Whether the product accepts buyer-uploaded audio. When `rejected`, the buyer cannot ship an audio asset directly — they must use build_creative (or sync_creatives with brief inputs) so the seller produces the audio. Combined with `audio_source`, lets a product declare 'I produce audio from briefs and refuse buyer uploads' (audio_source=`seller_pre_rendered_from_brief`, buyer_audio_acceptance=`rejected`)." }, "companion_image_required": { "type": "boolean" diff --git a/static/schemas/source/formats/canonical/display_tag.json b/static/schemas/source/formats/canonical/display_tag.json index 3e34f55388..3899a81cd9 100644 --- a/static/schemas/source/formats/canonical/display_tag.json +++ b/static/schemas/source/formats/canonical/display_tag.json @@ -5,6 +5,13 @@ "description": "Third-party-served display tag (JS, iframe, or 1×1 redirect). The buyer's adserver hosts the creative; the seller calls the tag URL at impression time. Slot: `tag_url` (url asset with appropriate `url_type`). Tracking model: opaque to seller — third party serves and measures. Click tracking via redirect URL substitution using universal_macros. Distinct from `image` (static asset hosted by seller) and `html5` (zip bundle hosted by seller).", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { + "slots": { + "default": [ + { "asset_group_id": "tag_url", "asset_type": "url", "required": true }, + { "asset_group_id": "backup_image", "asset_type": "image", "required": false } + ], + "description": "Default slots for display_tag canonical. Buyer ships a URL pointing at the third-party-served creative (JS, iframe, or 1×1 redirect) plus an optional backup image. Click and impression macros are substituted into the tag URL by the seller using `universal_macros`." + }, "width": { "type": "integer", "minimum": 1, diff --git a/static/schemas/source/formats/canonical/html5.json b/static/schemas/source/formats/canonical/html5.json index 4d9bc4e8ee..944c875b9c 100644 --- a/static/schemas/source/formats/canonical/html5.json +++ b/static/schemas/source/formats/canonical/html5.json @@ -5,6 +5,14 @@ "description": "Interactive HTML5 banner delivered as a zip archive. Slot: `html5_bundle` (zip asset). Tracking model: MRAID + IAB Open Measurement (OM-SDK) + click-tag macro substitution + backup image fallback. Receivers unpack the zip, validate internal structure, and serve from CDN. Distinct from `image` (static, non-interactive) and `display_tag` (third-party served). The zip's entry point is typically `index.html`; click handling uses `clickTag` (or `clickTAG`) macro substitution.", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { + "slots": { + "default": [ + { "asset_group_id": "html5_bundle", "asset_type": "zip", "required": true }, + { "asset_group_id": "backup_image", "asset_type": "image", "required": false }, + { "asset_group_id": "landing_page_url", "asset_type": "url", "required": false } + ], + "description": "Default slots for html5 canonical. Buyer ships a zip bundle plus optional backup image (required when `backup_image_required: true`) and clickthrough URL. The zip's entry point is typically `index.html`; click handling uses the `clickTag` (or `clickTAG`) macro substituted by the seller at serve time." + }, "width": { "type": "integer", "minimum": 1, diff --git a/static/schemas/source/formats/canonical/image.json b/static/schemas/source/formats/canonical/image.json index 69c7f42186..12ebf29ac5 100644 --- a/static/schemas/source/formats/canonical/image.json +++ b/static/schemas/source/formats/canonical/image.json @@ -5,6 +5,15 @@ "description": "Static image creative format. Slots: `image_main` (image asset, file or hosted URL), optional `headline` (text), `body_text` (text), `cta` (text/enum), `landing_page_url` (url). Tracking model: impression pixel + click URL via universal_macros, with optional viewability pixel. Distinct from `html5` (interactive bundles) and `display_tag` (third-party served). AR/dimensions narrow to specific sizes via product parameters — covers IAB display sizes (300x250, 728x90, 970x250, etc.) without a separate iab_size enum.", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { + "slots": { + "default": [ + { "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 } + ], + "description": "Default slots for image canonical. Buyer ships an image asset (file or hosted URL) plus optional headline, body text, and clickthrough URL. Products MAY override the default with narrowing (e.g., make `headline` required, or add a `cta` slot)." + }, "width": { "type": "integer", "minimum": 1, diff --git a/static/schemas/source/formats/canonical/image_carousel.json b/static/schemas/source/formats/canonical/image_carousel.json index 911ecbdd45..15e142faab 100644 --- a/static/schemas/source/formats/canonical/image_carousel.json +++ b/static/schemas/source/formats/canonical/image_carousel.json @@ -2,9 +2,33 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/formats/canonical/image_carousel.json", "title": "Canonical Format: Image Carousel", - "description": "Multi-card swipeable carousel. Slot: `cards[]` (per-card polymorphic — image OR video asset, plus per-card headline and link). Tracking model: per-card impression and engagement pixels + carousel-level engagement (swipe, view-time). Card aspect ratio is uniform across the carousel; mixed orientations not allowed within a single carousel. Allowed asset types per card: `image` and `video` (Meta-style mixed-media); platforms can narrow to image-only or video-only via parameters.", + "description": "Multi-card swipeable carousel. The buyer ships a `cards` slot whose value is an **array** of card-shaped objects (a single key with an array value — NOT one key per card, NOT dotted/bracketed paths). Each card carries: `media` (an image or video asset), optional `headline` (text), optional `landing_page_url` (url asset). Per-card structure is the same across all cards; mixed orientations not allowed within a single carousel. Tracking model: per-card impression and engagement pixels + carousel-level engagement (swipe, view-time). Allowed asset types per card: `image` and `video` (Meta-style mixed-media); platforms can narrow to image-only or video-only via `allowed_card_asset_types`.\n\nThe manifest's `assets.cards` value is an array of card objects matching `card_shape` below. Example: `\"cards\": [{\"media\": {\"asset_type\": \"image\", \"url\": \"...\"}, \"headline\": \"Buy now\", \"landing_page_url\": {\"asset_type\": \"url\", \"url\": \"...\"}}, ...]`. This is the normative shape — adopters MUST NOT invent per-card key conventions (`card_0_headline`, `cards.0.headline`, etc.).", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { + "slots": { + "default": [ + { "asset_group_id": "cards", "asset_type": "object", "required": true, "min": 2, "max": 10 }, + { "asset_group_id": "primary_text", "asset_type": "text", "required": false }, + { "asset_group_id": "landing_page_url", "asset_type": "url", "required": false } + ], + "description": "Default slots for image_carousel. The `cards` slot's value in the manifest is an array of card objects (see `card_shape`); `min` / `max` constrain card count." + }, + "card_shape": { + "type": "object", + "description": "Normative structure of each card object in the manifest's `assets.cards` array. Adopters MUST honor this shape; product narrowing MAY add per-card platform_extension fields but MUST NOT rename or restructure the listed fields. Constraints on per-card values (max chars, max file size, etc.) live on the format declaration's per-card parameters (`card_image_max_file_size_kb`, `card_headline_max_chars`, etc.).", + "properties": { + "media": { + "description": "The card's primary visual asset. Either an `image` or `video` asset matching `allowed_card_asset_types`." + }, + "headline": { + "type": "string", + "description": "Optional per-card headline. Length governed by `card_headline_max_chars` on the format declaration." + }, + "landing_page_url": { + "description": "Optional per-card click-through URL. `url` asset with `url_type: \"clickthrough\"`." + } + } + }, "card_aspect_ratio": { "type": "string", "pattern": "^[0-9]+(\\.[0-9]+)?:[0-9]+(\\.[0-9]+)?$", diff --git a/static/schemas/source/formats/canonical/video_hosted.json b/static/schemas/source/formats/canonical/video_hosted.json index b315613af6..0eb76971ef 100644 --- a/static/schemas/source/formats/canonical/video_hosted.json +++ b/static/schemas/source/formats/canonical/video_hosted.json @@ -5,6 +5,16 @@ "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" }], "properties": { + "slots": { + "default": [ + { "asset_group_id": "video_main", "asset_type": "video", "required": true }, + { "asset_group_id": "headline", "asset_type": "text", "required": false }, + { "asset_group_id": "brand_name", "asset_type": "text", "required": false }, + { "asset_group_id": "companion_banner", "asset_type": "image", "required": false }, + { "asset_group_id": "landing_page_url", "asset_type": "url", "required": false } + ], + "description": "Default slots for video_hosted canonical. Buyer ships a video asset (file or hosted URL); optional headline, brand_name (typical for vertical short-form), companion_banner (typical for horizontal instream), and clickthrough URL. Products MAY override or extend the default." + }, "orientation": { "type": "string", "enum": ["vertical", "horizontal", "square"], diff --git a/static/schemas/source/formats/canonical/video_vast.json b/static/schemas/source/formats/canonical/video_vast.json index fa24c10380..2e803d8118 100644 --- a/static/schemas/source/formats/canonical/video_vast.json +++ b/static/schemas/source/formats/canonical/video_vast.json @@ -5,6 +5,13 @@ "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": [ + { "asset_group_id": "vast_tag", "asset_type": "vast", "required": true }, + { "asset_group_id": "landing_page_url", "asset_type": "url", "required": false } + ], + "description": "Default slots for video_vast canonical. Buyer ships a VAST tag (URL or inline XML, VAST 2.x-4.x) plus an optional clickthrough URL (which falls back to the VAST `ClickThrough` element when omitted). Tracking events are inherent to VAST and don't require explicit slots." + }, "orientation": { "type": "string", "enum": ["vertical", "horizontal", "square"] diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index 0ddac6863c..3f553c4749 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -925,7 +925,7 @@ }, "supported_formats": { "type": "array", - "description": "v2 path: format declarations describing which canonical formats this creative agent can produce via `build_creative`. Each entry uses the same `ProductFormatDeclaration` shape as products' inline `format` field — keyed by canonical format name with parameter narrowing and `inputs` describing what the agent needs from the buyer. Replaces the v1 `list_creative_formats` discovery surface for creative agents. Each entry MAY include a `capability_id` for stable identification across versions; the format key already disambiguates which canonical the entry targets.", + "description": "v2 path: format declarations describing which canonical formats this creative agent can produce via `build_creative`. Each entry uses the same `ProductFormatDeclaration` shape as a product's inline `format_options[i]` — `format_kind` discriminator + `params` (canonical's parameter schema including `slots`, dimensions, durations, codecs, character limits, platform_extensions, tracking_extensions). Replaces the v1 `list_creative_formats` discovery surface for creative agents.", "items": { "type": "object", "properties": { @@ -935,7 +935,7 @@ }, "format": { "$ref": "/schemas/core/product-format-declaration.json", - "description": "Format declaration this agent can produce. Same shape as a product's inline `format`: keyed by canonical name with parameters and inputs." + "description": "Format declaration this agent can produce. Same shape as a product's inline `format_options[i]`: `format_kind` + `params` + `slots`." } }, "required": ["format"], diff --git a/tests/schema-validation.test.cjs b/tests/schema-validation.test.cjs index 5ad824c00b..dc3e01a635 100644 --- a/tests/schema-validation.test.cjs +++ b/tests/schema-validation.test.cjs @@ -279,11 +279,12 @@ async function runTests() { await test('Core schemas have appropriate required fields', () => { const coreSchemas = schemas.filter(([path]) => path.includes('/core/')); const requiredFieldChecks = { - // product.json: format_ids OR format is required (v1 OR v2 path) — checked separately below + // product.json: format_ids OR format_options is required (v1 OR v2 path) — checked separately below + // creative-asset.json: format_id OR format_kind is required (v1 OR v2 path) — checked separately below 'product.json': ['product_id', 'name', 'description', 'delivery_type'], 'media-buy.json': ['media_buy_id', 'status', 'total_budget', 'packages'], 'package.json': ['package_id'], - 'creative-asset.json': ['creative_id', 'name', 'format_id', 'assets'], + 'creative-asset.json': ['creative_id', 'name', 'assets'], 'error.json': ['code', 'message'] }; @@ -313,6 +314,18 @@ async function runTests() { } } + // creative-asset.json: assert v1 (format_id) OR v2 (format_kind) is required via oneOf + const creativeAssetEntry = coreSchemas.find(([p]) => path.basename(p) === 'creative-asset.json'); + if (creativeAssetEntry) { + const [, creativeAssetSchema] = creativeAssetEntry; + const oneOf = creativeAssetSchema.oneOf || []; + const hasV1Branch = oneOf.some((branch) => (branch.required || []).includes('format_id')); + const hasV2Branch = oneOf.some((branch) => (branch.required || []).includes('format_kind')); + if (!hasV1Branch || !hasV2Branch) { + return `creative-asset.json: must have a oneOf with v1 branch (required: ["format_id"]) and v2 branch (required: ["format_kind"]); found v1=${hasV1Branch}, v2=${hasV2Branch}`; + } + } + return true; }); From 55edddfd201357fab558d1df93d85c26813bb1bd Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 29 Apr 2026 22:29:19 -0400 Subject: [PATCH 22/41] =?UTF-8?q?docs(creative):=20v2=20red-team=20docs=20?= =?UTF-8?q?round=20=E2=80=94=20glossary,=20vocabulary=20table,=20hosting?= =?UTF-8?q?=20reframe,=20worked=20examples,=20OpenRTB=20differential,=20de?= =?UTF-8?q?cision=20rules,=20cross-doc=20banners?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes red-team Should-Fix and Nit items from the docs-expert and adtech-product-expert passes: - v2-overview.mdx: 25-term glossary at the top of the doc; asset_group_id vocabulary table (was only in JSON); refined "Two axes" section to show the unified 5-value production-source enum; tracker assembly under seller-rendered sources documented (macro-substituted vs sync-creatives tracker block); "Channels not yet canonicalized" section (native, linear TV, OOH, DAI, in-game, live). - v2-overview.mdx worked examples: generative DSP (universalads-class, image_source: seller_pre_rendered_from_brief), multi-format product (Flashtalking html5 OR internal display_tag), sponsored_placement with item_production_model (1 brief × N items → N creatives). Closes the gap where the new schema fields had no concrete worked-example coverage. - v2-overview.mdx hosting reframe: two normative paths. Open-ecosystem (publisher hosts the canonical artifact, immutable digest-pinned caching) vs closed-platform (AAO mirror translates walled-garden format docs into AdCP extension artifacts and hosts them under mirror.adcontextprotocol.org). Walled gardens were previously framed as a parenthetical fallback; reality is they ARE the AAO-mirror primary path. - v2-overview.mdx validate_input: "when to use" decision rule (pre-flight, multi-target dry-run, debug rejection) plus comparison table with build_creative and sync_creatives. Closes the gap where the doc showed an example without explaining when the primitive fits. Cross-link to /docs/creative/task-reference/build_creative. - v2-overview.mdx scaling: client-side filtering + multi-target validate_input as the high-product-count operational pattern. Addresses the per-product validate_input scaling concern. - v2-overview.mdx narrative tuning: generative-DSP fields (synthesis_nondeterministic, item_production_model: agent_synthesized) demoted to a forward-looking subsection. Universalads/Pencil are real adopters but small share of 2026 spend; the schema breadth must not read as AI-first. - v2-overview.mdx creative-agent business model: clarifies v2 disaggregation is conceptual — creative agents continue to host produced asset bytes and instrument tracking via platform extensions. - v2-overview.mdx preview canonicals stabilization rubric and Phase 4 SDK codegen blocker callout in the status banner. - v2-migration.mdx: v1 deprecation calendar floor (2027-Q4) and ceiling (2029-Q1) bounding the 80%/30-day adoption trigger; adoption-trigger metric defined with denominator + numerator + AAO publishing surface; creative_id stability invariant across v1 ↔ v2; "What v2 gives you that OpenRTB doesn't" subsection (canonical-as-contract decoupling, runtime discovery, declared production source, canonical tracking model). - v2-migration.mdx fixture count reconciled (12 product fixtures + 1 response fixture, all 11 canonicals covered). - Cross-doc v2 preview banners on formats.mdx, key-concepts.mdx, generative-creative.mdx, specification.mdx, implementing-creative-agents.mdx, asset-types.mdx so readers landing from search have a signpost back to v2. - asset-types.mdx updated for v2 with asset_group_id framing, full v2 asset_type table including brief, catalog, zip, markdown, webhook, object types. Validation: schema tests, example tests, v2 fixture tests all green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../v2-review-feedback-format-options.md | 44 ++- docs/creative/asset-types.mdx | 28 ++ docs/creative/formats.mdx | 1 + docs/creative/generative-creative.mdx | 2 + .../creative/implementing-creative-agents.mdx | 1 + docs/creative/key-concepts.mdx | 1 + docs/creative/specification.mdx | 2 + docs/creative/v2-migration.mdx | 25 +- docs/creative/v2-overview.mdx | 276 +++++++++++++++++- 9 files changed, 366 insertions(+), 14 deletions(-) diff --git a/.changeset/v2-review-feedback-format-options.md b/.changeset/v2-review-feedback-format-options.md index 26df07275e..0bb87c4a02 100644 --- a/.changeset/v2-review-feedback-format-options.md +++ b/.changeset/v2-review-feedback-format-options.md @@ -25,9 +25,51 @@ Addresses external review feedback on RFC #3305 / PR #3307 before the 3.1.0 beta **Why minor:** structural rename of `product.format` → `product.format_options` is technically breaking for anyone who built against the v2 path during the preview window, but the v2 path was only landed in this PR (#3307) and is not yet released — no published 3.x version carries `format`. The shipping shape is `format_options`. Anyone building against the preview branch should re-pull. The other changes are additive. +**Red-team round (must-fix + should-fix + nits)** — substantive cleanup against three parallel red teams (protocol-expert, adtech-product-expert, docs-expert): + +Schema fixes: +- Manifest v2 path. `creative-manifest.json` and `creative-asset.json` now carry `oneOf(format_id v1 path | format_kind v2 path)` with explicit `not` on each branch. New `/schemas/core/canonical-format-kind.json` enum backs the v2 path. Optional `capability_id` field disambiguates when a product's `format_options` carries multiple declarations sharing the same `format_kind`. Without this, v2 products had no v2 manifest counterpart. +- `ProductFormatDeclaration` grows `capability_id` (stable identifier for routing) and `applies_to_channels` (subset of the product's channels this declaration applies to — lets a multi-channel product carry channel-specific format_options). +- `audio_source` enum widened to match `image_source` / `video_source` (now 5-value: `buyer_uploaded | publisher_host_recorded | seller_pre_rendered_from_brief | seller_human_designed | agent_synthesized`). TTS-from-brief and studio-produced audio now expressible. +- `product.json` oneOf branches got explicit `not: required: [other]` to truly exclude both `format_ids` AND `format_options` being present. +- Stale "inputs" references in `get-adcp-capabilities-response.json supported_formats` descriptions replaced (the concept was dropped in r4 — collapsed into slots). +- `image_carousel` got a default slots declaration (`cards` slot, asset_type: object) plus a normative `card_shape` parameter documenting the per-card object structure (media + headline + landing_page_url). `assets.cards` is now the unambiguous array-under-one-key contract; per-card key conventions (card_0_headline, cards.0.headline) are forbidden. +- Slots inline default added to all 11 canonicals (previously only on 3). SDK codegen now produces typed slot lists for every canonical. +- `synthesis_nondeterministic` × `*_source` compatibility documented in `_base.json` (incompatible with `buyer_uploaded` and `publisher_host_recorded`). +- `platform-extension-ref` digest collision behavior documented (within a single response, divergent digests for the same uri MUST fail closed; across responses, divergence is normal). +- `status: preview` deprecation pathway: `since_version` + `migration_target_version` siblings on canonical `_base.json`, plus a stabilization rubric ("preview → stable when 2 adopters ship + 90 days no breaking change"). +- Veo fixture used `audio_source` / `buyer_audio_acceptance` on a `video_hosted` format. Renamed to `video_source` / `buyer_video_acceptance`. + +Doc additions: +- v2-overview.mdx glossary covering ~25 v2 terms. +- Asset group vocabulary table (was previously only in the JSON schema). +- "Two axes" section refined to show the unified 5-value source enum. +- Tracker assembly under seller-rendered sources documented (macro-substituted vs sync-creatives tracker block). +- "Channels not yet canonicalized" section (native, linear/addressable TV, OOH/DOOH, audio DAI, in-game, live streaming). +- Worked examples added for: generative DSP (universalads-class, `image_source: seller_pre_rendered_from_brief`), multi-format product (Flashtalking html5 OR internal display_tag), `sponsored_placement` with `item_production_model` (1 brief × N items → N creatives). +- Hosting reframed as two paths: open-ecosystem (publisher-hosted) vs closed-platform (AAO-mirror-translated, normative for walled gardens). +- `validate_input` "when to use" decision rule + comparison table with `build_creative` and `sync_creatives`. +- Discovery + validation scaling guidance (client-side filter + multi-target validate_input). +- Generative-DSP narrative weight tuned (demoted to forward-looking subsection — universalads/Pencil/AdCreative.ai are real but small share of 2026 spend). +- Creative-agent business-model paragraph clarifying that v2 disaggregation is conceptual; creative agents continue to host their produced creatives' bytes and instrument tracking via platform extensions. +- Preview canonicals stabilization rubric (`responsive_creative` and `agent_placement` re-evaluated for stable status by 3.3 if adopters land in 3.1-3.2). +- Phase 4 SDK codegen blocker callout in the status banner. +- Phase 3 fixture count reconciled (12 product fixtures + 1 response fixture). + +Migration doc additions: +- v1 deprecation calendar floor + ceiling (2027-Q4 floor, 2029-Q1 ceiling) bounding the adoption-driven trigger. +- Adoption-trigger metric definition (denominator + numerator + AAO publishing surface). +- `creative_id` stability invariant across v1 ↔ v2. +- "What v2 gives you that OpenRTB doesn't" subsection (canonical-as-contract decoupling, runtime discovery, declared production source, canonical tracking model). + +Cross-doc references: +- v2 preview banners on `formats.mdx`, `key-concepts.mdx`, `generative-creative.mdx`, `specification.mdx`, `implementing-creative-agents.mdx`, `asset-types.mdx` so readers landing from search have a signpost. + +`asset-types.mdx` updated for v2 with `asset_group_id` framing, full v2 asset_type table including `brief` / `catalog` / `zip` / `markdown` / `webhook` / `object`. + **Production-source taxonomy (universalads / generative-DSP gap):** -The audio_hosted canonical handles "who renders" via `audio_source` (`buyer_uploaded` / `publisher_host_recorded` / `agent_synthesized`) plus `buyer_audio_acceptance`. The image and video_hosted canonicals had no analogous parameter, which forced generative-DSP-shaped adopters (universalads, Pencil, AdCreative.ai-shaped tools, GenStudio-shaped tools) to either fudge `composition_model` or invent platform extensions to express what's actually a common pattern. +The audio_hosted canonical previously handled "who renders" via `audio_source` but with a narrower 3-value enum than image/video. The asymmetry forced generative-DSP-shaped adopters to either fudge `composition_model` or invent platform extensions to express what's actually a common pattern. This change adds: diff --git a/docs/creative/asset-types.mdx b/docs/creative/asset-types.mdx index bfb59fe20b..c8b3b25655 100644 --- a/docs/creative/asset-types.mdx +++ b/docs/creative/asset-types.mdx @@ -4,11 +4,39 @@ description: "AdCP asset types define standardized properties for images, video, "og:title": "AdCP — Asset Types" --- +> **v2 readers**: this page describes asset types and their payload shapes — the same in v1 and v2. For how assets map to v2 format slots via `asset_group_id` (canonical vocabulary), see [v2-overview](/docs/creative/v2-overview) and [v2-migration](/docs/creative/v2-migration). v1 uses `asset_id` + `asset_role`; v2 uses `asset_group_id` referencing the canonical vocabulary registry. Both paths use the same asset payload schemas — only the slot-key vocabulary differs. Creative formats in AdCP use standardized asset types with well-defined properties. Assets are the discrete, typed building blocks used by formats to define requirements and by manifests to supply concrete values. Standardizing asset types ensures consistency across formats and makes requirements easier for buyers and systems to understand. +## Asset types in v2 + +The full set of asset types valid in a v2 format `slots` declaration's `asset_type` field: + +| asset_type | What it carries | Where defined | +|---|---|---| +| `image` | Image file (jpg/png/gif/webp/svg) | `/schemas/core/assets/image-asset.json` | +| `video` | Video file (mp4/webm/mov) | `/schemas/core/assets/video-asset.json` | +| `audio` | Audio file (mp3/aac/wav) | `/schemas/core/assets/audio-asset.json` | +| `text` | Plain text (headline, body, script) | `/schemas/core/assets/text-asset.json` | +| `markdown` | Markdown text | `/schemas/core/assets/markdown-asset.json` | +| `url` | URL with `url_type` discriminator (clickthrough, tracker_pixel, third-party tag) | `/schemas/core/assets/url-asset.json` | +| `html` | Inline HTML | `/schemas/core/assets/html-asset.json` | +| `css` | CSS rules | `/schemas/core/assets/css-asset.json` | +| `javascript` | JavaScript | `/schemas/core/assets/javascript-asset.json` | +| `vast` | VAST tag (URL or inline XML), VAST 2.x-4.x | `/schemas/core/assets/vast-asset.json` | +| `daast` | DAAST tag (URL or inline XML), 1.0-1.1 | `/schemas/core/assets/daast-asset.json` | +| `webhook` | Webhook URL for async creative production | `/schemas/core/assets/webhook-asset.json` | +| `brief` | Free-text creative brief (input to generative production) | `/schemas/core/assets/brief-asset.json` | +| `catalog` | Reference to a synced catalog (sponsored_placement) | `/schemas/core/assets/catalog-asset.json` | +| `zip` | Zip archive (HTML5 banner bundle) | `/schemas/core/assets/zip-asset.json` | +| `vast_tracker` | Single VAST `Tracking` event URL (decomposed) | `/schemas/core/assets/vast-tracker-asset.json` | +| `daast_tracker` | Single DAAST `Tracking` event URL (decomposed) | `/schemas/core/assets/daast-tracker-asset.json` | +| `object` | Structured object (image_carousel `cards`, scenes for generative video) — sub-shape declared by the canonical | (no standalone schema; per-canonical sub-shape) | + +In a v2 manifest's `assets` map, the **slot key** is the canonical's `asset_group_id` (e.g., `image_main`, `video_main`, `script`, `cards`, `landing_page_url`) and the **value** carries the matching asset payload with its `asset_type` discriminator. The format declaration's `slots[].asset_type` tells the validator which payload schema applies. + ## Important: Payload vs Requirements For payload schemas (the structure of the actual asset data supplied in creative manifests), see: diff --git a/docs/creative/formats.mdx b/docs/creative/formats.mdx index 09612997f1..590f295d7c 100644 --- a/docs/creative/formats.mdx +++ b/docs/creative/formats.mdx @@ -4,6 +4,7 @@ description: "Creative formats in AdCP define asset requirements, technical cons "og:title": "AdCP — Creative Formats" --- +> **v2 preview**: starting in 3.1, formats are declared inline on products via `format_options` (an array of `ProductFormatDeclaration`s narrowing one of 11 canonical formats). This page describes the v1 format-registry model, which remains a first-class path through 4.x. For the v2 model, see [v2-overview](/docs/creative/v2-overview) and [v2-migration](/docs/creative/v2-migration). Creative formats define the structural and technical requirements used to instantiate advertising creatives. A format specifies: - The asset types required (video, image, text, audio, etc.) via the `assets` array diff --git a/docs/creative/generative-creative.mdx b/docs/creative/generative-creative.mdx index a2596e2914..6d2d728306 100644 --- a/docs/creative/generative-creative.mdx +++ b/docs/creative/generative-creative.mdx @@ -5,6 +5,8 @@ description: "Generative creative in AdCP uses AI to produce ad assets from a br "og:image": /images/walkthrough/diagram-generative-tiers.png --- +> **v2 preview**: in v2, the "generative" category dissolves at the protocol level — production mechanism (generative AI, host recording, transcoding, asset rendering) is invisible to the buyer. Production source is declared per-canonical via `*_source` enums (`audio_source`, `image_source`, `video_source`, `item_production_model`); `synthesis_nondeterministic: true` flags Veo / Sora / Runway-class flows that need post-synthesis QA-loop semantics. See [v2-overview](/docs/creative/v2-overview) §"Two axes" for the v2 model. + The Creative Protocol enables AI-powered creative generation and asset management for advertising campaigns. This guide will help you create your first creative in 5 minutes. Three tiers of generative creative: Tier 1 static (one creative, one variant), Tier 2 optimized (asset group combinations), Tier 3 generated (AI creates for each context) diff --git a/docs/creative/implementing-creative-agents.mdx b/docs/creative/implementing-creative-agents.mdx index 8fe90ac72f..7d7da5bf4e 100644 --- a/docs/creative/implementing-creative-agents.mdx +++ b/docs/creative/implementing-creative-agents.mdx @@ -4,6 +4,7 @@ description: "How to build an AdCP creative agent that defines formats, validate "og:title": "AdCP — Implementing Creative Agents" --- +> **v2 preview**: in v2, creative agents declare what they can produce via `creative.supported_formats` on `get_adcp_capabilities` (replacing the v1 `list_creative_formats` overload for creative agents). Each entry uses the same `ProductFormatDeclaration` shape as a product's inline `format_options[i]`. See [v2-overview](/docs/creative/v2-overview). This guide explains how to implement a creative agent that defines and manages creative formats. diff --git a/docs/creative/key-concepts.mdx b/docs/creative/key-concepts.mdx index 40c5071d6c..c9bf8df3c2 100644 --- a/docs/creative/key-concepts.mdx +++ b/docs/creative/key-concepts.mdx @@ -4,6 +4,7 @@ description: "Assets, formats, manifests, and creative agents are the four build "og:title": "AdCP — Creative key concepts" --- +> **v2 preview**: this page describes the v1 model. For the v2 architectural shift (canonical formats, inline `format_options` on products, production-source axis, `validate_input` primitive), see [v2-overview](/docs/creative/v2-overview). One upload, every format. This guide explains how creatives work in AdCP, from defining format requirements to assembling and delivering ads. diff --git a/docs/creative/specification.mdx b/docs/creative/specification.mdx index 0b3c09ecb5..cd8246e362 100644 --- a/docs/creative/specification.mdx +++ b/docs/creative/specification.mdx @@ -9,6 +9,8 @@ sidebarTitle: Specification **AdCP 3.0 Proposal** - This specification is under development for AdCP 3.0. Feedback welcome via [GitHub Discussions](https://github.com/adcontextprotocol/adcp/discussions). +> **v2 preview**: this page describes the v1 specification model. For the v2 model (canonical formats, inline `format_options` on products, `validate_input` primitive), see [v2-overview](/docs/creative/v2-overview) and [v2-migration](/docs/creative/v2-migration). + **Status**: Request for Comments **Last Updated**: March 2026 diff --git a/docs/creative/v2-migration.mdx b/docs/creative/v2-migration.mdx index 13377d9a7f..a707115052 100644 --- a/docs/creative/v2-migration.mdx +++ b/docs/creative/v2-migration.mdx @@ -97,7 +97,7 @@ Most of AdCP doesn't change. v2 builds on the existing primitives: `format_options` is an array. The 90% case is one element — one canonical narrowed for the product. Multi-element arrays declare that the product accepts any of the listed format options, picked by the buyer at `sync_creatives` time. Common multi-element use cases: a placement that accepts EITHER a third-party-hosted creative (e.g., Flashtalking-served `html5`) OR an internal `display_tag`; a video product that accepts a hosted upload (`video_hosted`) OR a tag (`video_vast`). Each entry is a discriminated union: `format_kind` names the canonical format; `params` carries that canonical's parameter schema. SDKs codegen clean tagged unions in TypeScript and Pydantic. A v2 product is **either** v1 (uses `format_ids`) **or** v2 (uses `format_options`) — not both. The product schema's `oneOf` enforces this. -For 12 fully-validated worked examples spanning all 11 canonical formats (Meta Reels, IAB MREC, NYTimes HTML5, podcast host-read, Amazon Sponsored Products, Google PMax, ChatGPT brand mention, Meta Carousel, YouTube VAST pre-roll, Triton DAAST audio, Veo generative video, GAM 3P display tag), see `static/examples/products/v2/`. The Veo fixture exercises `synthesis_nondeterministic: true` and `provenance_required: true`. Each fixture passes `npm run test:v2-fixtures`. +For 12 fully-validated reference Product fixtures spanning all 11 canonical formats — Meta Reels (`video_hosted` vertical), IAB MREC (`image` 300×250), NYTimes HTML5 (`html5`), GAM 3P display tag (`display_tag`), Meta Carousel (`image_carousel`), YouTube VAST pre-roll (`video_vast`), podcast 30s host-read (`audio_hosted`), Triton DAAST audio (`audio_daast`), Amazon Sponsored Products (`sponsored_placement`), Google PMax (`responsive_creative`), ChatGPT brand mention (`agent_placement`), Veo 15s generative video (`video_hosted` with `synthesis_nondeterministic` + `provenance_required`) — plus 1 `get_products` response fixture exercising bundled extensions, see `static/examples/products/v2/` and `static/examples/get_products_responses/v2/`. The Veo fixture exercises `synthesis_nondeterministic: true` and `provenance_required: true`. Each fixture passes `npm run test:v2-fixtures`. ## Slot name mapping (v1 → canonical) @@ -280,7 +280,17 @@ Three concrete hooks v2 introduces that existing seller implementations don't ha ### When does v1 `format_ids` get removed? -The `oneOf(format_ids, format_options)` shape on `Product` persists through 4.x — every validator, codegen, and adopter has to handle both shapes. The 5.0 cut is **adoption-driven, not date-driven**. AAO computes the ratio of registered sales agents declaring `format_options` (or `format_ids`) from cached `get_products` capabilities responses. When `format_options` adoption crosses 80% and stays there for 30 consecutive days, the 5.0 cut sequence opens (deprecation warnings escalate; the next major drops `format_ids`). Until that signal trips, both shapes remain valid and supported. This protects adopters whose org reality (legacy ad-server integrations, walled-garden translation gaps) makes immediate migration impractical, while giving the spec a measurable signal that the migration is broadly complete. +The `oneOf(format_ids, format_options)` shape on `Product` persists through 4.x — every validator, codegen, and adopter has to handle both shapes. The 5.0 cut is **adoption-driven, with calendar floor and ceiling**: + +- **Floor (minimum end-of-life)**: v1 `format_ids` is supported through at least **2027-Q4** regardless of adoption signal. Adopters whose org reality (legacy ad-server integrations, walled-garden translation gaps, slow procurement cycles) prevents immediate migration get an unconditional 18-month runway from 3.1 GA. +- **Ceiling (maximum end-of-life)**: v1 `format_ids` is removed no later than **2029-Q1** regardless of adoption signal. Caps the long-tail liability for SDK authors and validator maintainers; matches the v2-sunset-policy pattern from 3.0. +- **Adoption trigger (within the floor / ceiling window)**: AAO computes the ratio of registered sales agents declaring `format_options` (vs `format_ids`) from cached `get_products` capabilities responses. The trigger is denominator = sales agents declaring `creative` in `supported_protocols`; numerator = those whose latest `get_products` response carries `format_options` on every product. When the ratio crosses **80% and stays there for 30 consecutive days** within the floor/ceiling window, the 5.0 cut sequence opens (deprecation warnings escalate; the next major drops `format_ids`). Until that signal trips, both shapes remain valid. + +The trigger metric, denominator, refresh cadence, and certification path are published at `https://adcontextprotocol.org/registry/format-options-adoption.json` (concrete shape and refresh cadence to be committed in a follow-up issue before 3.1 GA). Adopters who want to influence the timing should migrate early and watch the public ratio. Walled gardens that don't migrate are absorbed by the ceiling. + +### `creative_id` stability across v1 ↔ v2 + +A creative registered against v1 `format_id` retains the same `creative_id` when later viewed via the v2 flatten path. `sync_creatives` request and response shapes are unchanged; the manifest envelope is unchanged. Migration is read-side: existing creatives keep working and resolve identically through both paths. SDK authors building flatten wrappers MUST honor this invariant. ## `product_card` and `product_card_detailed` are typed inline @@ -311,6 +321,17 @@ The `product_card` and `product_card_detailed` fields on the `Product` object ar Migration from v1 (where `product_card` was `{ format_id, manifest }` referencing a `product_card_standard` format file): drop the format reference; populate the typed fields directly. The image, title, and description flatten out of what was previously a manifest. v2-only adopters who don't render product cards can ignore both fields entirely. +## What v2 gives you that OpenRTB doesn't + +Adopters with existing OpenRTB Native / Display / Audio pipelines reasonably ask "why migrate to v2 when I already have a working creative-spec model?" The differential value: + +- **Buyer validates against the canonical, not the seller's narrowing.** OpenRTB has no canonical layer. Each SSP authors its own native asset spec (Native 1.2 left placement-specific assets to "see the impl"); each video player authors its own VAST extensions; each retail-media network publishes its own catalog field shape. Buyers must discover and validate against per-seller specs at runtime. v2's canonical-as-contract decouples buyer validation from per-seller schema discovery — the buyer ships a manifest that satisfies canonical `image` and knows it's structurally valid against any seller speaking that canonical, BEFORE knowing which seller wins the auction. +- **Discovery is operational, not implicit.** OpenRTB Display 1.x and Native 1.2 expect adopters to read the spec, write per-version handlers, and pre-bake support for what each seller might require. v2 carries the format declaration inline on the product (`format_options[i]` on `get_products`) and on the creative agent (`creative.supported_formats` on `get_adcp_capabilities`). Buyers fetch what's accepted at runtime; SDK codegen produces typed handlers for the canonicals; new sellers don't require buyer-side code changes. +- **Production source is first-class.** OpenRTB has no notion of "buyer ships a brief; seller renders." The closest expression is OpenRTB Native's `nobid_reason` after submission. v2 makes production source a declared parameter (`*_source` enums), so generative DSPs and host-read products are visible at discovery time, not only after rejection. +- **Tracking model is canonical-defined.** OpenRTB tracking (impression NURL, click NURL, third-party trackers) is consistent for VAST but fragmented for native and display. v2 bakes the tracking model into each canonical (impression pixel for image, MRAID + OM-SDK for html5, VAST events for video_vast, per-card pixels for image_carousel) — buyers know what tracking shape applies from the canonical alone. + +v2 doesn't replace OpenRTB at the auction layer (where OpenRTB Display, Video, Audio specs continue to drive the bid request / response shape). v2 is the creative-payload layer above the auction, and it adds what OpenRTB intentionally left implementation-specific. + ## Validating your migration Run the fixture validation against your translated products: diff --git a/docs/creative/v2-overview.mdx b/docs/creative/v2-overview.mdx index 9af341463a..e787ea2551 100644 --- a/docs/creative/v2-overview.mdx +++ b/docs/creative/v2-overview.mdx @@ -7,9 +7,37 @@ testable: true # Creative Formats v2 (preview) -> **Status:** Preview track. The v2 surface is being designed in flight against [RFC #3305](https://github.com/adcontextprotocol/adcp/issues/3305) and the [#3307 implementation branch](https://github.com/adcontextprotocol/adcp/pull/3307). v1 named formats (`format_id` as `{ agent_url, id }`) remain a first-class path through 4.x with a 5.0 sunset; v2 is opt-in and additive. - -v2 collapses today's separate format registry into product-bound declarations. AdCP defines a small set of **canonical formats** (universal building blocks); sellers' products carry inline `ProductFormatDeclaration` that narrows exactly one canonical with platform-specific parameters. Creative agents become transformation services declaring `build_creative` capabilities targeting canonical formats. Most existing concepts (CTAs, destinations, tracking, brand identity) are reused or stay in their current homes — v2 doesn't create a new vocabulary layer for those. +> **Status:** Preview track. The v2 surface is being designed in flight against [RFC #3305](https://github.com/adcontextprotocol/adcp/issues/3305) and the [#3307 implementation branch](https://github.com/adcontextprotocol/adcp/pull/3307). v1 named formats (`format_id` as `{ agent_url, id }`) remain a first-class path through 4.x with a 5.0 sunset; v2 is opt-in and additive at the schema layer. **The typed-tagged-union ergonomics v2's design earns require SDK codegen to deliver — Phase 4 (TypeScript and Python codegen) is the gating dependency for adopter consumption. Until Phase 4 ships, adopters can build against the schemas directly, but the buyer-side mental simplification v2 promises lands fully only with codegen.** + +v2 collapses today's separate format registry into product-bound declarations. AdCP defines a small set of **canonical formats** (universal building blocks); sellers' products carry inline `ProductFormatDeclaration`s that narrow canonicals with platform-specific parameters. Creative agents become transformation services declaring `build_creative` capabilities targeting canonical formats. Most existing concepts (CTAs, destinations, tracking, brand identity) are reused or stay in their current homes — v2 doesn't create a new vocabulary layer for those. + +## Glossary + +| Term | One-line definition | +|---|---| +| **Canonical format** | One of 11 AdCP-defined format archetypes that products narrow (e.g., `image`, `video_vast`, `audio_hosted`). The buyer's stable validation target. | +| **`format_kind`** | Discriminator value naming a canonical format (e.g., `"image"`). Selects which canonical's parameter schema applies. | +| **`format_options`** | Array of `ProductFormatDeclaration`s on a v2 product. The 90% case is single-element; multi-element declares "accepts any of." | +| **`ProductFormatDeclaration`** | Inline format declaration: `format_kind` + `params` + optional `capability_id` + optional `applies_to_channels`. | +| **`capability_id`** | Stable identifier for a format declaration, used to disambiguate when `format_options` carries multiple declarations sharing the same `format_kind`. | +| **`applies_to_channels`** | Subset of the product's declared channels this format declaration applies to. Lets multi-channel products carry per-channel format options. | +| **`slots`** | Programmatic declaration on a format of which `asset_group_id` slots a manifest must (or may) populate, each paired with an `asset_type`. | +| **`asset_group_id`** | Canonical slot-name vocabulary (e.g., `image_main`, `script`, `landing_page_url`). Replaces v1's free-text `asset_role`. | +| **`composition_model`** | How the surface composes per-impression: `deterministic` (buyer-predictable per-slot) vs `algorithmic` (surface picks combinations from a pool). | +| **`*_source`** | Per-canonical production-source declaration: `audio_source`, `image_source`, `video_source`, `item_production_model`. Describes who renders the asset and when. | +| **`synthesis_nondeterministic`** | When true, the production pipeline cannot guarantee in-spec output (Veo/Sora-class). Implies QA-loop + retry semantics. | +| **`provenance_required`** | When true, the product rejects unsigned synthesized assets. Builders attach C2PA-compatible provenance manifests. | +| **`platform_extensions`** | URI+digest references to platform-specific extensions narrowing the canonical (pixel ID shapes, conversion event taxonomies). | +| **`tracking_extensions`** | Subset of `platform_extensions` specifically scoped to tracking concerns (pixel IDs, viewability vendors, OM-SDK partners). | +| **`status: preview`** | Canonical is shipped for early adoption but parameter shape MAY break in 3.2 once 2-3 adopters land. | +| **`since_version` / `migration_target_version`** | Release-precision lifecycle metadata on canonicals — when introduced, when stabilization or breaking revision is expected. | +| **`validate_input`** | Spec-defined dry-run primitive — buyers verify a manifest against canonicals/products without committing to a render. | +| **`build_creative`** | Creative-agent surface that produces a manifest from inputs (brief, scenes, brand). Sales agents do NOT expose `build_creative`. | +| **`creative.supported_formats`** | Capabilities-response field on creative agents declaring which canonicals they can produce via `build_creative`. | +| **`BrandRef`** | `{domain, brand_id?}` reference. Resolves brand context (logos, colors, voice) from `brand.json` automatically. | +| **`brand_kit_override`** | Per-creative override for the case where `brand.json` is missing, stale, or inappropriate. | +| **`fanout_mode`** | On `sponsored_placement`: how items map to delivery — `per_item`, `multi_item_in_creative`, `single_item`. | +| **`item_production_model`** | On `sponsored_placement`: how each per-item creative is produced. Captures multi-output generative (1 brief × N items → N creatives). | ## Architectural shift @@ -42,6 +70,8 @@ Each canonical lives at `/schemas/formats/canonical/.json`. Tracking model The two `preview` canonicals (`responsive_creative`, `agent_placement`) carry surfaces whose composition models are still settling — Google PMax / Meta Advantage+ for responsive; ChatGPT / Perplexity / voice assistants for agent_placement. Their parameter shape and tracking model MAY break in 3.2 once 2-3 adopters have built against them. Buyers SHOULD plan for migration; sellers SHOULD treat preview-canonical narrowing as experimental contract surface, not a long-term commitment. The other 9 canonicals are anchored in stable IAB / platform standards (IAB display dimensions, IAB VAST 4.2, IAB DAAST 1.1, retail-media catalog conventions) and are committed. +**Stabilization rubric for preview canonicals.** A preview canonical is promoted to `stable` when (a) at least 2 production adopters have built against it AND (b) 90 consecutive days have passed without a breaking change to its parameter shape. The default escalation date is the next minor release after both conditions are met — i.e., `responsive_creative` and `agent_placement` re-evaluated for stable status by 3.3 if adopters land in 3.1-3.2. To avoid the coordination problem where `preview` reads as "don't build against this" and therefore never stabilizes, sellers shipping preview canonicals SHOULD also publish a `migration_target_version` (carried on the canonical's `_base.json` `migration_target_version` field) so adopters know when to expect either stabilization or a breaking revision. + ## Two axes: composition (per-impression) vs production (who renders) Two orthogonal patterns govern how a creative is produced and how it serves. Conflating them is the most common authoring mistake. @@ -50,16 +80,67 @@ Two orthogonal patterns govern how a creative is produced and how it serves. Con - `deterministic` — buyer can predict per-slot rendering. The surface serves what it received. (`image`, `video_hosted`, `audio_hosted`, `video_vast`, `audio_daast`, `sponsored_placement`.) - `algorithmic` — surface picks combinations from a buyer-supplied asset pool per-impression. The buyer ships a pool; the surface composes. (`responsive_creative` for Google PMax / Meta Advantage+; `agent_placement` for AI-surface composition.) -**Production source** — per-canonical `*_source` parameters. Describes **who renders the rendered asset, and when**: -- `audio_source` on `audio_hosted` — `buyer_uploaded | publisher_host_recorded | agent_synthesized` -- `image_source` on `image` — `buyer_uploaded | seller_pre_rendered_from_brief | seller_human_designed | agent_synthesized` -- `video_source` on `video_hosted` — same enum as `image_source` +**Production source** — per-canonical `*_source` parameters, all sharing a single 5-value enum: `buyer_uploaded | publisher_host_recorded | seller_pre_rendered_from_brief | seller_human_designed | agent_synthesized`. Describes **who renders the rendered asset, and when**: +- `audio_source` on `audio_hosted` +- `image_source` on `image` +- `video_source` on `video_hosted` - `item_production_model` on `sponsored_placement` — same enum, applied per catalog item (the multi-output generative case: 1 brief × N catalog items → N rendered creatives) The two axes don't collapse. A generative DSP that produces ONE rendered image from a brief is `composition_model: deterministic` (the surface serves what it received) + `image_source: seller_pre_rendered_from_brief` (seller produced it from inputs at sync_creatives time). A retail-media surface that runs an AI synthesis pipeline per catalog item is `composition_model: deterministic` + `item_production_model: agent_synthesized`. Google PMax is `composition_model: algorithmic` + (production-source unspecified — buyer ships a pool of pre-rendered assets so the production-source question doesn't apply at the format level). The production-source enums are informational, not the binding contract. The format's `slots` declaration is the contract — what the buyer ships, in what shape. The `*_source` field tells the buyer "here's how this product produces the rendered creative" so they can pick products whose production model fits their workflow (in-house pre-rendered vs upstream creative agent vs seller-driven generative). +### Tracker assembly under seller-rendered sources + +When `*_source` is `buyer_uploaded`, the buyer ships rendered assets and any tracker URLs attached to those assets are buyer-controlled (universal_macros for impression/click; `vast_tracker` / `daast_tracker` assets for decomposed VAST/DAAST trackers). When `*_source` is any of the seller-rendered values (`seller_pre_rendered_from_brief`, `seller_human_designed`, `agent_synthesized`) or `publisher_host_recorded`, the buyer never sees the rendered artifact directly. Two normative paths apply: + +- **Macro-substituted tracking (default).** The seller honors AdCP universal_macros at impression time — `{IMPRESSION_TRACKER}`, `{CLICK_TRACKER}`, etc. — and substitutes buyer-supplied tracker URLs (declared on the manifest's optional `landing_page_url` and the buyer's measurement-vendor pixels declared via `tracking_extensions` on the format) into the rendered creative's serving template. The buyer registers their measurement pixels client-side; the seller calls them at serve time. This is the dominant path for image / video / audio production where serving and tracking are decoupled. +- **Sync-creatives tracker block.** For products where the seller produces a serving artifact that embeds tracker URLs directly (e.g., a generated VAST tag or a stitched companion banner), the seller's `sync_creatives` response SHOULD include a `tracker_block` field listing the impression URL pattern and click URL pattern. Buyers register those with their measurement vendor at sync time. This path covers the generative-DSP pattern where the serving artifact and the tracking shape are produced together. + +`vast_tracker` and `daast_tracker` decomposed tracker assets work for both `buyer_uploaded` and seller-rendered sources — when the seller renders, those tracker assets are inputs to the rendered tag, attached to the appropriate VAST/DAAST `` block at production time. When the buyer ships a complete `vast` or `daast` tag, the trackers travel inside the tag. + +## Asset group vocabulary + +Format `slots` reference canonical `asset_group_id` values from the [vocabulary registry](https://adcontextprotocol.org/schemas/v3/core/asset-group-vocabulary.json). The current canonical entries: + +| asset_group_id | asset_type | Common aliases (v1 → v2) | +|---|---|---| +| `headlines` | text | headline, title, tagline, headline_text | +| `long_headlines` | text | long_headline_pool, extended_headlines | +| `descriptions` | text | description, body, body_text, text, content | +| `images_landscape` | image | image, hero_image, landscape_image, banner_image | +| `images_vertical` | image | vertical_image, story_image, portrait_image | +| `images_square` | image | square_image, feed_image | +| `image_main` | image | (per-canonical default for `image`) | +| `logo` | image | brand_logo, logo_image | +| `video` | video | video_file, hero_video, video_asset, video_main | +| `video_main` | video | (per-canonical default for `video_hosted`) | +| `video_vertical` / `video_horizontal` | video | — | +| `audio` / `audio_main` | audio | audio_file, hero_audio, audio_asset | +| `companion_image` / `companion_banner` | image | — | +| `brand_name` / `body_text` | text | — | +| `cards` | object | carousel_cards, slides, carousel_items, carousel_slides | +| `cta` | text | cta_text, call_to_action, action_text, button_text | +| `price` / `phone_number` / `promo_code` / `disclaimer` | text | (various) | +| `subtitle_file` | url | caption_file, captions, subtitles | +| `landing_page_url` | url | click_url, link, final_url, link_url, click_through_url | +| `privacy_policy_url` | url | — | +| `source_catalog` | catalog | (sponsored_placement) | +| `hero_asset` | image | hero_banner, collection_hero | +| `script` | text | script_text, host_script, voiceover_script | +| `creative_brief` | brief | brief, creative_direction, talking_points | +| `scenes` | object | storyboard | +| `voice_id` / `offering_ref` / `youtube_video_id` / `pin_id` | text | — | +| `style_reference` | image | reference_image, style_image, inspiration_image | +| `starter_assets` | object | — | +| `vast_tag` | vast | (video_vast default) | +| `daast_tag` | daast | (audio_daast default) | +| `tag_url` | url | (display_tag default) | +| `html5_bundle` | zip | (html5 default) | +| `backup_image` | image | (html5 / display_tag default) | + +Non-canonical `asset_group_id` values remain valid for platform-specific extensions; validators MAY emit soft warnings on non-canonical IDs to encourage convergence. Aliases are recognized one-way (v1 alias → v2 canonical) when migrating; new manifests SHOULD use the canonical IDs. + ## Worked example — Meta Reels Meta Reels narrows `video_hosted` (vertical orientation) with Meta-specific parameters and platform extensions: @@ -330,6 +411,116 @@ Buyer calls `sync_creatives` on NYTimes with the manifest from Flashtalking. NYT The seller's validation contract is the canonical, not the creative agent. This is what makes the third-party path additive rather than coupled: the buyer can swap creative agents without changing the seller-facing flow. +## Worked example — generative DSP (universalads-class, image_source: seller_pre_rendered_from_brief) + +A generative DSP (universalads, Pencil, AdCreative.ai-shaped tools) is a sales agent that ALSO renders creatives inline at `sync_creatives` time — it is NOT a creative agent the buyer calls separately. The buyer ships a brief plus structured copy; the seller renders ONE image and serves it like any deterministic creative. + +```json test=false +{ + "product_id": "universalads_brief_driven_display_300x250", + "name": "Universal Ads — Brief-Driven Display (300×250)", + "publisher_properties": [ + { "publisher_domain": "universalads.example", "selection_type": "all" } + ], + "channels": ["display"], + "format_options": [ + { + "format_kind": "image", + "params": { + "width": 300, + "height": 250, + "max_file_size_kb": 200, + "image_formats": ["jpg", "png"], + "ssl_required": true, + "composition_model": "deterministic", + "image_source": "seller_pre_rendered_from_brief", + "buyer_image_acceptance": "rejected", + "production_window_business_days": 0, + "slots": [ + { "asset_group_id": "creative_brief", "asset_type": "brief", "required": true, "max_chars": 500 }, + { "asset_group_id": "headline", "asset_type": "text", "required": true, "max_chars": 30 }, + { "asset_group_id": "landing_page_url", "asset_type": "url", "required": true } + ] + } + } + ], + "delivery_type": "non_guaranteed", + "pricing_options": [ + { "pricing_option_id": "cpm_brief", "pricing_model": "cpm", "currency": "USD", "floor_price": 8.00 } + ] +} +``` + +Buyer's manifest carries the brief, headline, and clickthrough URL — no rendered image asset. Seller's `sync_creatives` produces the rendered MREC PNG and registers it. Two axes: `composition_model: deterministic` (the surface serves what it received), `image_source: seller_pre_rendered_from_brief` (the seller renders from inputs at sync time). `buyer_image_acceptance: "rejected"` makes it explicit that the buyer cannot ship a pre-rendered image directly — the production model is brief-driven only. + +## Worked example — multi-format product (Flashtalking html5 OR internal display_tag) + +A placement that accepts EITHER a third-party-hosted creative OR an internal tag — buyer picks at sync_creatives time by aligning their manifest's `format_kind` (and `capability_id` if needed) to the matching declaration: + +```json test=false +{ + "product_id": "regional_news_homepage_300x250", + "channels": ["display"], + "format_options": [ + { + "capability_id": "html5_flashtalking_hosted", + "format_kind": "html5", + "params": { + "width": 300, + "height": 250, + "max_initial_load_kb": 200, + "ssl_required": true, + "composition_model": "deterministic" + } + }, + { + "capability_id": "display_tag_internal", + "format_kind": "display_tag", + "params": { + "width": 300, + "height": 250, + "ssl_required": true, + "composition_model": "deterministic" + } + } + ] +} +``` + +Buyer's manifest: `{ "format_kind": "html5", "capability_id": "html5_flashtalking_hosted", "assets": { "html5_bundle": {...}, "backup_image": {...} } }` — `format_kind` selects the canonical's slot vocabulary; `capability_id` disambiguates because both options share `format_kind: html5`/`display_tag`-shaped slots are different anyway, but `capability_id` removes any ambiguity for the seller's router. (When `format_options` carries one element per `format_kind`, `capability_id` is optional — `format_kind` alone routes the manifest.) + +## Worked example — sponsored_placement with item_production_model + +A retail-media product that accepts a catalog reference plus a brief, and renders one creative per catalog item at sync time: + +```json test=false +{ + "product_id": "regional_retailer_generative_offerings", + "channels": ["display"], + "catalog_types": ["product"], + "format_options": [ + { + "format_kind": "sponsored_placement", + "params": { + "supported_catalog_types": ["product"], + "min_items": 5, + "max_items": 200, + "fanout_mode": "per_item", + "supported_id_types": ["sku", "gtin"], + "item_production_model": "seller_pre_rendered_from_brief", + "composition_model": "deterministic", + "slots": [ + { "asset_group_id": "source_catalog", "asset_type": "catalog", "required": true }, + { "asset_group_id": "creative_brief", "asset_type": "brief", "required": true, "max_chars": 500 } + ] + } + } + ] +} +``` + +`item_production_model: seller_pre_rendered_from_brief` says: for each catalog item, the seller renders ONE creative using the brief plus the catalog item's structured fields (title, image, price). `fanout_mode: per_item` says each item gets its own ad in delivery. Together they capture the multi-output generative pattern (1 brief × N items → N ads) under the existing `sponsored_placement` canonical. + ## Validation flow — `validate_input` Buyers can dry-run a manifest against canonicals and/or specific products without committing to a render: @@ -381,6 +572,42 @@ Response carries per-target results: `validate_input` is the predictable-case primitive. For genuinely nondeterministic synthesis (Veo / Sora / Runway-class), predictive validation is impossible and the platform's own post-synthesis QA loop applies — submission returns `task_failed` with a `synthesis_failed` reason if the QA loop exhausts without producing a valid artifact. There is **no protocol state for orphaned out-of-spec artifacts**. +### When to use `validate_input` + +A decision rule, not a one-size primitive: + +- **Pre-flight before an expensive `build_creative` call.** If the manifest can't even narrow against canonical, the buyer saves the synthesis cost. Especially relevant for nondeterministic-synthesis products where each retry has real GPU cost. +- **Multi-target dry-run during product selection.** A buyer comparing 10 candidate products asks `validate_input` once with all 10 product_ids; gets back per-target results. Cheaper than 10 separate `sync_creatives` round-trips. +- **Debugging a rejected manifest.** When `sync_creatives` returns violations, calling `validate_input` against the canonical alone narrows the question to "is my manifest fundamentally broken vs is the product's narrowing the gating constraint." +- **Preview-render gating** (formats with `composition_model: algorithmic` or `synthesis_nondeterministic: true`). The platform's preview surface is a richer follow-on; `validate_input` is the cheap pre-flight that gates whether previewing is even worth attempting. + +When NOT to use `validate_input`: + +- For a manifest you intend to submit anyway. `sync_creatives` returns the same violations and registers on success — `validate_input` adds a round-trip without reducing total work. +- For products where the seller's narrowing is unknowable client-side without fetching extensions. `validate_input` pulls extensions same as `sync_creatives` does — there's no discovery shortcut. +- For high-volume per-impression decisions. `validate_input` is per-target, not per-impression. Operational scale (hundreds of products × N format_options) belongs to client-side filtering against the cached `get_products` response. + +### `validate_input` vs `build_creative` vs `sync_creatives` + +| Tool | Who calls | What it does | Side effects | +|---|---|---|---| +| `validate_input` | Buyer | Dry-run validation against canonicals and/or products. Returns per-target `ok` + violations. | None (no creative registered, no synthesis triggered). | +| `build_creative` | Buyer (calling a creative agent) | Produces a creative manifest from inputs (brief, scenes, brand). For deterministic flows: one round-trip. For nondeterministic flows: returns task with QA-loop semantics. | Synthesis happens; output manifest is returned. May register a creative on the creative agent's library if the agent supports `has_creative_library`. Does NOT register on the seller. | +| `sync_creatives` | Buyer (calling a seller) | Submits a manifest to the sales agent for the seller to register against a product. | Validates against canonical + product narrowing; registers creative on the seller's library on success; returns violations on failure. | + +For the third-party creative-agent flow: `validate_input` first (cheap pre-flight) → `build_creative` on the creative agent → `sync_creatives` on the sales agent. For the in-house pre-rendered flow: skip `build_creative`; `validate_input` then `sync_creatives`. For the seller-renders-from-brief flow (universalads-class): skip `build_creative` (the seller does the rendering at `sync_creatives` time); `validate_input` then `sync_creatives` directly. + +See [`build_creative` task reference](/docs/creative/task-reference/build_creative) for the full request/response shape. + +### Discovery + validation at scale + +A high-product-count buyer (TTD-class with ~100s of products per get_products response) cannot pre-flight every product via `validate_input` per round — N products × M format_options × per-target round-trips becomes operationally expensive. Two patterns address this: + +- **Client-side filtering against the cached `get_products` response.** Buyers who know their manifest's `format_kind` and parameter bucket (canonical, dimensions, duration) filter the product list client-side before validating. The format declarations are already inline on each product — buyers don't need a separate fetch to filter. This is the dominant pattern for "validate against the products that could possibly accept my creative" and reduces the validate_input set by an order of magnitude. +- **Multi-target `validate_input`.** When the filtered set is still wide (5-50 products), call `validate_input` once with all candidate product_ids in `targets[]`. The response carries per-target results in a single round-trip. Cheaper than per-product calls and structurally aligned with the schema (one request, many results). + +For genuinely high-volume scenarios (hundreds of candidate products, real-time bidding pre-flight), buyers should rely on cached `get_products` responses + client-side filtering as the primary path; `validate_input` is reserved for the narrowed candidate set or for debugging unexpected rejections. The `applies_to_channels` field on each format_options element narrows further when a product spans multiple channels. + ## Preview as the universal "what does this produce" surface Buyers ship assets per the format's `slots` declaration; `preview_creative` shows what the output renders as. The seller's response to a creative submission can also include a preview URL — the buyer doesn't need a separate preview call to verify that their submission produced the intended output. Same surface, two production paths: @@ -425,7 +652,13 @@ https://nytimes.adcp/extensions/nytimes_om_strict Each extension's response carries the schema, the canonical pattern or slot it extends, a version, and a content digest. -**Hosting expectation.** The publisher whose subdomain owns the URI hosts the canonical artifact. Because URIs are digest-pinned (`uri@sha256:…`), responses are immutable per digest — sellers SHOULD serve them with `Cache-Control: public, max-age=31536000, immutable` and target ≥99.9% / 30-day availability. SDKs cache aggressively by `uri@digest`; an extension at a given digest never changes content, so a cache hit is always correct. If the canonical URI returns 404 or fails to resolve, buyers MUST degrade gracefully — treat the extension as unavailable and skip platform-specific narrowing rather than fail the buy. Sellers that don't want to operate an artifact CDN MAY rely on the AAO mirror as a fallback host (best-effort, not normative); the mirror does not relieve the publisher of the primary hosting role. +**Hosting paths — two separate flows.** v2 supports two hosting models, depending on whether the canonical URI's owner participates in the open AdCP ecosystem or operates as a closed platform that AAO translates on its behalf. + +*Open-ecosystem path (publisher-hosted)*. Used when the publisher owning the URI subdomain participates directly in AdCP — independent publishers, SSPs, retail-media networks running their own canonical extensions. The publisher hosts the artifact at the canonical URI on their subdomain. Because URIs are digest-pinned (`uri@sha256:…`), responses are immutable per digest — publishers SHOULD serve them with `Cache-Control: public, max-age=31536000, immutable` and target ≥99.9% / 30-day availability. SDKs cache aggressively by `uri@digest`; a hit is always correct. On 404 or resolution failure, buyers MUST degrade gracefully (treat as unavailable, skip platform-specific narrowing, don't fail the buy). + +*Closed-platform path (AAO-translated)*. Used for walled gardens (Meta, Google, Amazon, TikTok, Snap, Pinterest). These platforms are unlikely to host AdCP-shaped extension artifacts on their own subdomains (they have native SDKs and APIs that protect their revenue model; serving an immutable extension CDN gives them no benefit). Instead, AAO runs a translator that maps closed-platform format documentation into AdCP extension artifacts and hosts them under an AAO mirror namespace (e.g., `https://mirror.adcontextprotocol.org/translated//@`). Worked-example fixtures in this repo that reference `https://meta.adcp/extensions/...` are illustrative — production usage of those extensions should resolve through the AAO mirror until/unless Meta participates directly. AAO commits to the same digest-pinning + immutability contract; refresh cadence and translation methodology are documented at `https://adcontextprotocol.org/registry/translated-extensions`. Buyers cache and resolve identically across both paths — `uri@digest` is the cache key, regardless of who hosts. + +The two paths share the digest-pinned cache and graceful-degradation semantics. They differ only in the resolution authority. The mirror is normative for closed-platform extensions (not "best effort") because there is no other path; for open-ecosystem extensions, the mirror is opt-in fallback. **Distribution path: bundled in `get_products`.** The sales agent's response includes definitions for every extension referenced by any product in the response, keyed by `uri@digest`: @@ -458,6 +691,27 @@ By design, v2 doesn't introduce new vocabulary for things AdCP already handles o - **`list_build_capabilities` as a separate tool** — folded into `get_adcp_capabilities` under `creative.supported_formats`. - **`build_capability`, `build_capability_ref`, and a separate `inputs` map** — collapsed into the canonical `slots` model on the format declaration. The format declares slots (canonical `asset_group_id` + `asset_type` + constraints); the manifest has a single `assets` map keyed by slot name; the seller dispatches per the format (render assets verbatim or consume them for production). The format itself tells the buyer what it requires; how production happens is implementation detail. +### Channels not yet canonicalized + +The 11 canonicals cover display, video, audio, retail-media, AI-surface, and responsive-creative archetypes. Several channels have no canonical home yet and stay on the v1 path until 3.2 or later: + +- **Native** — deferred to 3.2 pending TemplateCreative + OpenRTB Native 1.2 audit. The `image_carousel` canonical covers carousel-shaped native today; richer native templates are out of scope for 3.1. +- **Linear / addressable TV** — broadcast-shaped buys (national spot, addressable household targeting) need their own tracking + measurement model that doesn't reduce to `video_vast` or `video_hosted`. Likely a `video_linear_tv` or `video_addressable` canonical in a later release. +- **OOH / DOOH** — out-of-home and digital-out-of-home placements have impression models tied to physical-location-keyed measurement (Geopath, COMMB) that don't map to web/CTV impression pixels. Deferred until adopter demand surfaces. +- **Audio dynamic ad insertion (DAI)** — ad-stitched audio with mid-stream insertion has a different tracking shape than `audio_hosted` or `audio_daast`. Likely covered by a specialized canonical or by `audio_daast` extension parameters when the pattern stabilizes. +- **In-game** — playable / in-game ads have a SDK-specific composition model. Out of scope until cross-engine standards land. +- **Live streaming** — live linear video (Twitch / YouTube Live / sports streaming with mid-roll) needs concurrent-impression and stream-state tracking. The `video_vast` canonical handles VAST-tag-driven live insertion today; richer live patterns deferred. + +For these channels, sellers continue to use v1 `format_ids` and adopt v2 incrementally per canonical as each one stabilizes. + +### Generative-DSP and multi-output patterns are forward-looking + +The `*_source` enums (including `seller_pre_rendered_from_brief` and `agent_synthesized`) and `item_production_model` on `sponsored_placement` are designed for generative-DSP and AI-rendered retail-media patterns that are emerging but not yet a large share of programmatic spend in 2026. Universalads-shaped tools, Pencil, AdCreative.ai, GenStudio-shaped tools — these are real adopters, but the volume is small relative to the boring 90% (buyer ships an MREC PNG; surface serves it). Reading too much into the schema breadth is a mistake. The fields exist so generative-DSP adopters have a clean v2 home; the worked examples include them so adopters can map their adapter cleanly. They are not a signal that the v2 narrative is AI-first. The dominant flows for 3.1 are still buyer-uploaded assets going through deterministic surfaces. + +### Creative-agent business model + +The third-party-creative-agent worked example assumes Flashtalking-shaped tools serve buyers via `build_creative` and let the buyer ship the produced manifest to the seller. Operators reading this should not infer that v2 strips creative agents of their hosting / serving / tracking revenue. Production happens at `build_creative`; the produced manifest can include hosted asset URLs on the creative agent's CDN (Flashtalking-hosted asset URLs in the example), and platform extensions can attach creative-agent-specific tracking (Flashtalking pixel IDs, viewability vendor configurations) that the seller honors at serve time. The v2 disaggregation is conceptual (the spec separates production from serving from tracking) — the operational integration path lets creative agents continue to host and instrument their produced creatives. v2 doesn't dictate where the asset bytes live or whose tracking JS runs; it only formalizes the production-vs-serving boundary that already exists implicitly. + ## Migration | Adopter | Cost | Realistic timeline | @@ -475,9 +729,9 @@ By design, v2 doesn't introduce new vocabulary for things AdCP already handles o | Phase | Status | What's in it | |---|---|---| | Phase 1 | ✅ in #3307 | `asset_group_id` vocabulary registry (canonical entries + audit-grounded aliases), `scenes` schema, `zip` asset type, video/audio doc fixes | -| Phase 2 | ✅ in #3307 | 11 canonical format definitions with structured `slots` declaration, `ProductFormatDeclaration` (format_kind discriminator + params), `validate_input` primitive, `creative.supported_formats` on get_adcp_capabilities, `brand_kit_override`, `platform-extension-ref`, typed inline `product_card` / `product_card_detailed`, `format_ids` ⊕ `format` oneOf on Product | -| Phase 3 | ✅ in #3307 | v1↔v2 migration guide, 7 fully-validated reference Product fixtures + 1 get_products response fixture with bundled extensions, fixture-validation test suite (`npm run test:v2-fixtures`) | -| Phase 4 | TBD | Reference SDK codegen (TypeScript first, then Python), server-side flatten wrapper reference implementation | +| Phase 2 | ✅ in #3307 | 11 canonical format definitions with structured `slots` declaration, `ProductFormatDeclaration` (format_kind discriminator + params), `validate_input` primitive, `creative.supported_formats` on get_adcp_capabilities, `brand_kit_override`, `platform-extension-ref`, typed inline `product_card` / `product_card_detailed`, `format_ids` ⊕ `format_options` oneOf on Product | +| Phase 3 | ✅ in #3307 | v1↔v2 migration guide, 12 fully-validated reference Product fixtures + 1 get_products response fixture with bundled extensions, fixture-validation test suite (`npm run test:v2-fixtures`) | +| Phase 4 | ⚠️ blocking adoption | Reference SDK codegen (TypeScript first, then Python), server-side flatten wrapper reference implementation. Without Phase 4, adopters cannot consume v2 cleanly — the typed-tagged-union ergonomics this PR's design earns require codegen to deliver. v2 is opt-in and additive at the schema layer today; Phase 4 makes it usable. | | Native canonical | TBD | Deferred to 3.2 after TemplateCreative + OpenRTB Native 1.2 audit | ## Related From 601bd95a87d79b102fa85acaffbb7505123e6cfe Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 29 Apr 2026 22:40:46 -0400 Subject: [PATCH 23/41] docs(creative): expand v2 creative-agent migration walkthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration doc was sales-agent-centric — creative agents (Flashtalking, AudioStack, Pencil, AdCreative.ai-shaped tools) got 3 short bullets. v2 reshapes their path enough to warrant a real walkthrough. Adds: - A what-changes table covering format catalog publishing, authoring, discovery, build_creative contract, production-source declaration, tracking integration, and hosting of produced bytes. - Concrete worked example for an ad-server-shaped creative agent (Flashtalking) showing how 30+ named formats collapse to a smaller supported_formats set keyed by canonical format_kind + capability_id + Flashtalking-specific platform_extensions for pixel IDs and viewability. - Concrete worked example for a transformation-shaped creative agent (AudioStack) showing two distinct capabilities (brief-to-audio vs script-to-audio) declared as separate supported_formats entries sharing format_kind: audio_hosted but with different audio_source values (seller_pre_rendered_from_brief vs agent_synthesized). capability_id disambiguates which capability the buyer is invoking on build_creative. - Server-side hooks specific to creative agents: supported_formats as public contract, synthesis_nondeterministic implying a QA-loop obligation, provenance_required requiring C2PA attestation. - Migration timing table: keep v1 list_creative_formats through 4.x; add supported_formats anytime in 3.1+ (additive); stop publishing new v1 named formats when 80% of your buyers read supported_formats; drop list_creative_formats coordinated with the v1 deprecation calendar (2027-Q4 / 2029-Q1). Closes the gap surfaced by review of the third-party-creative-agent worked example in v2-overview — that example showed the BUYER flow but didn't tell creative agents what THEY do to migrate. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/creative/v2-migration.mdx | 156 ++++++++++++++++++++++++++++++++- 1 file changed, 152 insertions(+), 4 deletions(-) diff --git a/docs/creative/v2-migration.mdx b/docs/creative/v2-migration.mdx index a707115052..79989f5d1b 100644 --- a/docs/creative/v2-migration.mdx +++ b/docs/creative/v2-migration.mdx @@ -249,11 +249,159 @@ Three concrete hooks v2 introduces that existing seller implementations don't ha - **`get_products` response gathers extension definitions.** When products carry v2 `format.params.platform_extensions` references, the response SHOULD include the referenced extension definitions in the `extensions` map keyed by `@sha256:`. Implementations gather extensions referenced by any product in the response, dedupe by digest, and emit. Buyers cache by URI@digest; subsequent responses MAY omit definitions the buyer already has cached. Trivial when no products use v2 declarations; only kicks in when tenants opt in. - **`production_window_business_days` on host-read / agent-produced products.** Today most server implementations don't model production turnaround on Products — the field is a v2 addition. Only matters once a tenant ships a v2 host-read or generative-video product (audio_hosted with `audio_source: 'publisher_host_recorded'`, or any product with `synthesis_nondeterministic: true`). Today many of these flows route through hand-trafficked sponsorships and don't surface turnaround over the protocol; v2 makes it declarable. -### Creative agents (transformation services like AudioStack, generative platforms) +### Creative agents (Flashtalking, AudioStack, generative platforms, AI rendering services) -1. Add `creative.supported_formats` to your `get_adcp_capabilities` response. Each entry is a `ProductFormatDeclaration` describing what canonical format you can produce, with parameter narrowing and `slots` declaring the assets you accept as production input. -2. Continue to support `build_creative` per the existing v1 contract — it's the same tool with the same shape. -3. If you're also acting as an ad server (rare but valid), expose `sync_creatives` alongside `build_creative`. +The spec has historically read sales-agent-first. v2 reshapes the creative-agent path enough to warrant its own walkthrough — both for ad-server-shaped creative agents (Flashtalking, Innovid, Sizmek-class) and transformation-shaped creative agents (AudioStack, Pencil, AdCreative.ai-class). + +#### What changes for a creative agent in v2 + +| Concern | v1 | v2 | +|---|---|---| +| Format catalog publishing | `list_creative_formats` returned your producible catalog; sales agents could reference it via `creative_agents[]` recursive-discovery hint on their own `list_creative_formats` | `creative.supported_formats` on `get_adcp_capabilities` (each entry is a `ProductFormatDeclaration` — same shape as a sales agent's product `format_options[i]`). v1 `list_creative_formats` stays functional through 4.x. | +| Format authoring | You authored named formats keyed under `your-domain.adcp` and published them | You declare which AdCP-defined canonical formats you can produce (`format_kind` discriminator) with your platform-specific narrowing (`params`). No more publishing free-text named formats. | +| Discovery | Sales agents pointed buyers at you via `creative_agents[]` (recursive query); buyers fetched your `list_creative_formats` to learn what you produce | Buyers reach you directly — through brand-side relationships, AAO registry, direct knowledge. Sales agents in v2 do NOT carry a list of "approved creative agents." Each side is independent. | +| `build_creative` contract | Buyer shipped a manifest with `format_id` + `assets` + `inputs` (separate "production inputs" map) | Buyer ships the same envelope, but `inputs` is collapsed into `assets` — everything goes through one `assets` map keyed by canonical `asset_group_id`. The format's `slots` declaration tells you which assets to expect, each typed by `asset_type`. The seller (you) dispatches per slot — render verbatim for `image` / `video` / `audio` slots; consume for production for `text` / `brief` / `object` slots (e.g., `script` text → host-recorded audio; `creative_brief` brief → generated image). | +| Production-source declaration | Implicit (the named format's name implied the model — `*_generated_*` for AI-produced) | Explicit per-canonical: `audio_source` / `image_source` / `video_source` enums declare who renders and when (`buyer_uploaded` / `publisher_host_recorded` / `seller_pre_rendered_from_brief` / `seller_human_designed` / `agent_synthesized`). Plus `synthesis_nondeterministic: true` for Veo/Sora-class flows that need post-synthesis QA-loop semantics. | +| Tracking integration | Your platform's pixel IDs, viewability vendors, OM-SDK partners lived in your named format's `tracking_events` field — sellers and buyers parsed your free-text declarations | Declare via `platform_extensions: [{uri, digest}]` and `tracking_extensions` on each `supported_formats[].format`. Each extension is a URI you host (or the AAO mirror translates) describing the schema for your platform's tracking surface (pixel IDs, conversion event taxonomies). Sellers and buyers cache by `uri@digest`; SDK codegen produces typed extension handlers. | +| Hosting of produced bytes | Your CDN, your call | Your CDN, your call. v2 disaggregation is conceptual (the spec separates production from serving from tracking) — operationally, produced asset URLs in the manifest you return from `build_creative` continue to point at your CDN, your tracking JS continues to instrument, your platform extensions document the integration. | + +#### Concrete example: Flashtalking-shaped creative agent + +A creative agent that produces image / VAST / html5 creatives across multiple sizes and surfaces. Pre-v2, it published 30+ named formats (one per size × surface combination). v2 collapses to a smaller `supported_formats` set: + +```json test=false +// GET https://flashtalking.adcp/.well-known/agent.json +// → get_adcp_capabilities response (excerpt) +{ + "creative": { + "supports_generation": true, + "supports_transformation": true, + "has_creative_library": true, + "supported_formats": [ + { + "capability_id": "flashtalking_image_iab_standard", + "format": { + "format_kind": "image", + "params": { + "image_formats": ["jpg", "png", "gif"], + "max_file_size_kb": 200, + "ssl_required": true, + "image_source": "buyer_uploaded", + "platform_extensions": [ + { "uri": "https://flashtalking.adcp/extensions/flashtalking_pixel_v2", "digest": "sha256:..." } + ] + } + } + }, + { + "capability_id": "flashtalking_video_vast_42", + "format": { + "format_kind": "video_vast", + "params": { + "vast_version": "4.2", + "duration_ms_range": [6000, 60000], + "linear_required": true, + "ssl_required": true, + "platform_extensions": [ + { "uri": "https://flashtalking.adcp/extensions/flashtalking_vpaid_2_0", "digest": "sha256:..." } + ] + } + } + }, + { + "capability_id": "flashtalking_html5_iab_standard", + "format": { + "format_kind": "html5", + "params": { + "max_initial_load_kb": 200, + "om_sdk_required": true, + "backup_image_required": true, + "ssl_required": true + } + } + } + ] + } +} +``` + +What disappears: 30+ named-format files, each with its own `tracking_events` array, each with its own slot vocabulary. What replaces them: 3 canonical declarations narrowed by params + platform_extensions for Flashtalking-specific tracking. SDK codegen on the buyer side produces a typed handler per `format_kind` rather than a typed handler per Flashtalking-named-format — a 10× reduction in surface area for buyers integrating Flashtalking. + +The buyer flow is unchanged from a Flashtalking perspective: `build_creative` still receives a brief / assets / brand reference; you produce a manifest with rendered asset URLs on Flashtalking's CDN; the buyer submits that manifest to whatever sales agent they're shipping to. The sales agent validates against canonical `image` / `video_vast` / `html5` — NOT against your Flashtalking narrowing. Your platform_extensions remain attached to the manifest so the sales agent honors Flashtalking pixel IDs and viewability vendors at serve time. + +#### Concrete example: AudioStack-shaped transformation agent + +A transformation agent that takes a buyer's brief or script and produces a rendered audio file. Pre-v2, AudioStack published `audiostack_audio_30s_generated` and similar. v2 collapses to a per-canonical declaration with explicit production-source semantics: + +```json test=false +// GET https://audiostack.adcp/.well-known/agent.json +{ + "creative": { + "supports_generation": true, + "supports_transformation": true, + "supported_formats": [ + { + "capability_id": "audiostack_audio_brief_to_30s", + "format": { + "format_kind": "audio_hosted", + "params": { + "duration_ms_exact": 30000, + "audio_codecs": ["mp3", "aac"], + "audio_sample_rates": [44100, 48000], + "audio_channels": ["stereo"], + "loudness_lufs": -16, + "audio_source": "seller_pre_rendered_from_brief", + "buyer_audio_acceptance": "rejected", + "production_window_business_days": 1, + "slots": [ + { "asset_group_id": "creative_brief", "asset_type": "brief", "required": true, "max_chars": 500 }, + { "asset_group_id": "voice_id", "asset_type": "text", "required": false }, + { "asset_group_id": "landing_page_url", "asset_type": "url", "required": false } + ] + } + } + }, + { + "capability_id": "audiostack_audio_script_to_30s", + "format": { + "format_kind": "audio_hosted", + "params": { + "duration_ms_exact": 30000, + "audio_codecs": ["mp3"], + "audio_source": "agent_synthesized", + "buyer_audio_acceptance": "rejected", + "synthesis_nondeterministic": false, + "production_window_business_days": 0, + "slots": [ + { "asset_group_id": "script", "asset_type": "text", "required": true, "max_chars": 800 }, + { "asset_group_id": "voice_id", "asset_type": "text", "required": true } + ] + } + } + } + ] + } +} +``` + +Two distinct capabilities — brief-to-audio (creative direction → produced ad) and script-to-audio (deterministic TTS from verbatim script) — declared as two `supported_formats` entries sharing `format_kind: audio_hosted` but with different `audio_source` values. `capability_id` disambiguates which capability the buyer is invoking when they call `build_creative`. + +#### Server-side hooks for creative agents + +Three implementation considerations specific to creative agents in v2: + +- **`creative.supported_formats` is your public contract.** Buyers and sales agents read it to know what you produce. Keep `capability_id` values stable across releases — buyers reference them in their `build_creative` calls. +- **`synthesis_nondeterministic: true` implies a QA-loop obligation.** When you declare it, you commit to: validating each synthesis attempt against the format's parameter constraints; reseeding up to N times to produce in-spec output; returning `task_failed` with `synthesis_failed` reason if the loop exhausts. There is no protocol state for orphaned out-of-spec artifacts — the buyer never sees a partial result. +- **`provenance_required: true` requires C2PA attestation.** When a sales agent's product carries `provenance_required: true` and the buyer is shipping your produced asset, the manifest you return MUST include a C2PA-compatible provenance manifest attributing synthesis to your agent (not the buyer, not the seller). EU AI Act Article 50 alignment. + +#### Migration timing for creative agents + +| Item | Timing | +|---|---| +| Continue to expose `list_creative_formats` (v1) | Through 4.x. Sales agents and buyers may still call it. | +| Add `creative.supported_formats` to `get_adcp_capabilities` | Anytime in 3.1+. Additive — doesn't break v1 callers. | +| Stop publishing new v1 named formats | When 80% of your buyers are reading `supported_formats` (track via your own analytics — buyers calling `get_adcp_capabilities` vs `list_creative_formats`). | +| Drop `list_creative_formats` | Coordinated with the v1 deprecation calendar (2027-Q4 floor / 2029-Q1 ceiling) — same window as sales agents dropping `format_ids`. | ### Buyers / DSPs From b3c29bc17e05923c58a4c5bb0c25152759ac52b6 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 30 Apr 2026 06:02:49 -0400 Subject: [PATCH 24/41] feat(creative): runtime_status on ProductFormatDeclaration; align item_production_model.buyer_uploaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #3638 (runtime_status) and #3639 (production-source enum asymmetry) — both filed against #3307. #3639: rename item_production_model.buyer_pre_rendered → buyer_uploaded on sponsored_placement. Aligns the buyer-side default value name with image_source / video_source / audio_source so SDK codegen emits a single shared 4-value enum across all production-source fields. "Uploaded" reads slightly off for catalog-keyed items where the buyer didn't actively upload bytes (the catalog already supplied them), but the semantic is the same: rendered bytes are buyer-supplied, not seller-produced. Description updated with the framing nit called out explicitly so readers don't trip on it. #3638: add runtime_status: stable | preview | declared_only on ProductFormatDeclaration. Distinct from canonical-level status (which describes spec-maturity). runtime_status describes whether THIS seller's runtime actually honors what they declared on THIS product. Closes the gap surfaced by the universalads _generated_offerings audit (scope3data/agentic-adapters#202): the adapter's catalog declares a generative-offerings format that would map to sponsored_placement with item_production_model: seller_pre_rendered_from_brief, but the runtime is media-upload-only. With runtime_status: declared_only, the adapter can be honest about the forward-looking declaration vs the current runtime; buyers filter on it; compliance storyboards skip-gate gracefully. The two stability axes are documented as independent in v2-overview: - canonical params.status: spec maturity (stable/preview/deprecated) - declaration runtime_status: adopter runtime (stable/preview/ declared_only) A stable canonical can have declared_only adopters (common during v2 migration when adopters port v1 catalog declarations forward faster than they wire the runtime path); a preview canonical can have stable adopters (built against the preview shape and runtime honors it). Migration walkthrough updated: sales-agent migration step 3 calls out runtime_status as a first-class deliverable, not an afterthought. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/creative/v2-migration.mdx | 9 +++---- docs/creative/v2-overview.mdx | 24 ++++++++++++++++++- .../core/product-format-declaration.json | 6 +++++ .../canonical/sponsored_placement.json | 6 ++--- 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/docs/creative/v2-migration.mdx b/docs/creative/v2-migration.mdx index 79989f5d1b..6e6223d29c 100644 --- a/docs/creative/v2-migration.mdx +++ b/docs/creative/v2-migration.mdx @@ -236,10 +236,11 @@ Override fields take precedence over `brand.json` for that creative. 1. **Inventory**: enumerate your existing v1 named formats. Confirm each maps to one of the 11 v2 canonicals. 2. **Translate**: for each named format, write a v2 `ProductFormatDeclaration` narrowing the canonical with your platform's parameters. -3. **Test**: validate translated declarations against `/schemas/core/product.json` (use the `npm run test:v2-fixtures` pattern). -4. **Publish dual**: keep your v1 named formats and `list_creative_formats` working through 4.x. Add the v2 `format` field on products that have it. -5. **Flatten wrapper**: implement a server-side wrapper that derives the v1 `list_creative_formats` shape from v2 product declarations. Lets v1-era dashboards and tooling keep working. -6. **Deprecate timing**: at 5.0, remove v1 `format_ids` references on your products. Until then, both paths coexist. +3. **Be honest about runtime readiness**: set `runtime_status` on each declaration. `stable` (default) means your runtime fully honors the declared format and production source. `preview` means the basic path works but advanced axes (per-item fan-out under `item_production_model`, brief-driven overrides, advanced `platform_extensions`) may be partial. `declared_only` means the catalog declaration is forward-looking and your runtime does NOT yet implement the path — common during migration when you port v1 catalog declarations forward but haven't wired the new production-source axis yet. Buyers can filter on this; compliance storyboards skip-gate `declared_only` entries gracefully. Upgrade the value as your runtime catches up. +4. **Test**: validate translated declarations against `/schemas/core/product.json` (use the `npm run test:v2-fixtures` pattern). +5. **Publish dual**: keep your v1 named formats and `list_creative_formats` working through 4.x. Add the v2 `format_options` field on products that have it. +6. **Flatten wrapper**: implement a server-side wrapper that derives the v1 `list_creative_formats` shape from v2 product declarations. Lets v1-era dashboards and tooling keep working. +7. **Deprecate timing**: at 5.0, remove v1 `format_ids` references on your products. Until then, both paths coexist. #### Server-side implementation considerations diff --git a/docs/creative/v2-overview.mdx b/docs/creative/v2-overview.mdx index e787ea2551..2fc0d2347b 100644 --- a/docs/creative/v2-overview.mdx +++ b/docs/creative/v2-overview.mdx @@ -18,9 +18,10 @@ v2 collapses today's separate format registry into product-bound declarations. A | **Canonical format** | One of 11 AdCP-defined format archetypes that products narrow (e.g., `image`, `video_vast`, `audio_hosted`). The buyer's stable validation target. | | **`format_kind`** | Discriminator value naming a canonical format (e.g., `"image"`). Selects which canonical's parameter schema applies. | | **`format_options`** | Array of `ProductFormatDeclaration`s on a v2 product. The 90% case is single-element; multi-element declares "accepts any of." | -| **`ProductFormatDeclaration`** | Inline format declaration: `format_kind` + `params` + optional `capability_id` + optional `applies_to_channels`. | +| **`ProductFormatDeclaration`** | Inline format declaration: `format_kind` + `params` + optional `capability_id` + optional `applies_to_channels` + optional `runtime_status`. | | **`capability_id`** | Stable identifier for a format declaration, used to disambiguate when `format_options` carries multiple declarations sharing the same `format_kind`. | | **`applies_to_channels`** | Subset of the product's declared channels this format declaration applies to. Lets multi-channel products carry per-channel format options. | +| **`runtime_status`** | Per-product-declaration adopter-runtime readiness: `stable` (default), `preview` (partial paths), `declared_only` (forward-looking — runtime not wired yet). Distinct from canonical-level `status` (which is about spec maturity). | | **`slots`** | Programmatic declaration on a format of which `asset_group_id` slots a manifest must (or may) populate, each paired with an `asset_type`. | | **`asset_group_id`** | Canonical slot-name vocabulary (e.g., `image_main`, `script`, `landing_page_url`). Replaces v1's free-text `asset_role`. | | **`composition_model`** | How the surface composes per-impression: `deterministic` (buyer-predictable per-slot) vs `algorithmic` (surface picks combinations from a pool). | @@ -72,6 +73,27 @@ The two `preview` canonicals (`responsive_creative`, `agent_placement`) carry su **Stabilization rubric for preview canonicals.** A preview canonical is promoted to `stable` when (a) at least 2 production adopters have built against it AND (b) 90 consecutive days have passed without a breaking change to its parameter shape. The default escalation date is the next minor release after both conditions are met — i.e., `responsive_creative` and `agent_placement` re-evaluated for stable status by 3.3 if adopters land in 3.1-3.2. To avoid the coordination problem where `preview` reads as "don't build against this" and therefore never stabilizes, sellers shipping preview canonicals SHOULD also publish a `migration_target_version` (carried on the canonical's `_base.json` `migration_target_version` field) so adopters know when to expect either stabilization or a breaking revision. +### Two stability axes: canonical `status` vs declaration `runtime_status` + +Stability splits across two independent axes — the spec-maturity axis on the canonical, and the adopter-runtime axis on each product-format declaration: + +| Axis | Field location | Question it answers | +|---|---|---| +| **Spec maturity** | `params.status` on the canonical (`/schemas/formats/canonical/.json`) | "Has the v2 working group stabilized this format definition?" Values: `stable` / `preview` / `deprecated`. | +| **Adopter runtime** | `runtime_status` on the `ProductFormatDeclaration` (per-product) | "Does THIS seller's runtime actually honor what they declared on THIS product?" Values: `stable` / `preview` / `declared_only`. | + +The two vary independently. A `stable` canonical can have `declared_only` adopters (the spec is settled but the seller hasn't wired the runtime path yet — common during v2 migration when adopters port v1 catalog declarations forward but their adapters still implement only the asset-upload path). A `preview` canonical can have `stable` adopters (a seller built a real working runtime against the preview shape and their integration honors what they declared). + +Why both exist: without `runtime_status`, sellers in mid-migration silently lie about what they support. They declare the shiny new production-source axis (`item_production_model: seller_pre_rendered_from_brief`) on a forward-looking product, but the actual `sync_creatives` runtime is still a buyer-uploaded-bytes loop. Buyers discover the mismatch only at submission time — exactly what v2's canonical-as-contract is supposed to prevent. + +The rule of thumb: + +- Default `stable` (or omit the field). The runtime honors what's declared. +- `preview` — the basic path works; advanced axes (per-item fan-out, brief-driven overrides, advanced `platform_extensions`) may be partial. Buyers should `validate_input` before committing. +- `declared_only` — forward-looking declaration. Runtime not wired yet. Buyers MUST treat as informational; compliance storyboards skip-gate gracefully rather than fail; budget should not flow until upgraded to `preview` or `stable`. + +Sellers MUST upgrade the value as the runtime catches up. Buyers cache it like any other capability field; subsequent `get_products` responses surface the new value naturally. + ## Two axes: composition (per-impression) vs production (who renders) Two orthogonal patterns govern how a creative is produced and how it serves. Conflating them is the most common authoring mistake. diff --git a/static/schemas/source/core/product-format-declaration.json b/static/schemas/source/core/product-format-declaration.json index 246839d8aa..fef96126e7 100644 --- a/static/schemas/source/core/product-format-declaration.json +++ b/static/schemas/source/core/product-format-declaration.json @@ -16,6 +16,12 @@ "items": { "$ref": "/schemas/enums/channels.json" }, "uniqueItems": true, "description": "Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." + }, + "runtime_status": { + "type": "string", + "enum": ["stable", "preview", "declared_only"], + "default": "stable", + "description": "Adopter-runtime readiness for this product-format declaration. **Distinct from the canonical's `status` field** (which describes whether the v2 working group has stabilized the format definition itself). `runtime_status` describes whether THIS seller's runtime actually honors what they declared on THIS product.\n\n- `stable` (default) — adopter's runtime fully honors the declared format + production source. Buyers can rely on the declaration as a serving contract.\n- `preview` — runtime supports the basic path; some axes (e.g., per-item fan-out under `item_production_model`, brief-driven overrides, advanced `platform_extensions`) may be partial. Buyers SHOULD validate via `validate_input` or sandbox before committing budget.\n- `declared_only` — catalog declaration is forward-looking; runtime does NOT yet implement this path. Buyers MUST treat as informational and confirm via `validate_input` or a sandbox storyboard before purchase. Compliance storyboards SHOULD skip-gate `declared_only` entries gracefully rather than failing.\n\nThe two axes vary independently: a `stable` canonical can have `declared_only` adopters (canonical is settled in spec but adopter hasn't wired runtime yet), and a `preview` canonical can have `stable` adopters (adopter built against the preview shape and their runtime fully honors it). Producers SHOULD set this when their product declaration is aspirational; absence is interpreted as `stable`. Sellers MUST upgrade the value as the runtime catches up; buyers cache it like any other capability field." } }, "oneOf": [ diff --git a/static/schemas/source/formats/canonical/sponsored_placement.json b/static/schemas/source/formats/canonical/sponsored_placement.json index 889c521a7c..961f2b421a 100644 --- a/static/schemas/source/formats/canonical/sponsored_placement.json +++ b/static/schemas/source/formats/canonical/sponsored_placement.json @@ -50,9 +50,9 @@ }, "item_production_model": { "type": "string", - "enum": ["buyer_pre_rendered", "seller_pre_rendered_from_brief", "seller_human_designed", "agent_synthesized"], - "default": "buyer_pre_rendered", - "description": "How each per-item creative is produced. `buyer_pre_rendered` (default, current Amazon/Criteo/CitrusAd pattern): the buyer's catalog already contains rendered assets per item; the seller composes the placement using those assets. `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." + "enum": ["buyer_uploaded", "seller_pre_rendered_from_brief", "seller_human_designed", "agent_synthesized"], + "default": "buyer_uploaded", + "description": "How each per-item creative is produced. Aligned with the buyer-side default name (`buyer_uploaded`) used by `image_source` / `video_source` / `audio_source` so SDK codegen emits a single shared 4-value enum across all production-source fields. `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." } }, "additionalProperties": true From 1cc7e2016268e3e2ecedfb7ea8216a18254658a6 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 30 Apr 2026 08:41:11 -0400 Subject: [PATCH 25/41] feat(creative): custom format_kind + format_shape registry + format_schema URI+digest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverses the wrong call from #3666 (which recommended ext-as-vehicle for novel shapes). ext-only puts interesting structure in a free-form bag with no schema, no required fields, no defined semantics — buyer agents can see the blob but can't interpret it reliably, regressing to human-in-the-loop. That breaks the load-bearing claim of v2: buyer agents reason structurally without per-seller integration code. Adds: - canonical-format-kind.json: 'custom' added to the enum (12 values). Description documents that custom requires format_shape + format_schema and points at the promotion queue (#3666). - New /schemas/core/format-shape-vocabulary.json registry. Same pattern as asset-group-vocabulary.json: governance-light entries, non-canonical values valid (soft-warn), promotion to canonical happens when 2+ adopters land + 90 days. Seeded with 9 entries: multi_placement_takeover, roadblock, branded_content, cross_screen_sponsorship, sponsorship_lockup, newsletter_sponsorship, ar_lens, playable, live_event_sponsorship. Each entry carries description, typical_use, tracking_model_hint, promotion_status. - product-format-declaration.json: format_shape (string, references registry) and format_schema (URI+digest, $ref to platform-extension-ref.json) fields. allOf / if/then enforces: when format_kind=custom, format_shape AND format_schema are required; when format_kind=anything-else, both MUST be absent. New 'Custom Format Declaration' branch in the discriminator oneOf. Worked example added (NYTimes Homepage Takeover narrowing multi_placement_takeover with format_schema URI+digest). Buyer agents fetch the schema by uri@digest (immutable per digest, aggressive caching, same mechanic as platform_extensions), validate params and slots against the fetched schema, reason about manifests structurally. No per-seller integration code. ext stays for genuinely experimental shapes that don't even fit a format_shape registry entry — but that's the rare case. The dominant path for novel shapes is custom + format_shape + format_schema. Doc additions: - v2-overview.mdx: 'Custom formats' section between canonicals and asset-group-vocabulary. Explains the mechanism, the three required pieces when format_kind=custom, why custom + format_schema beats ext for agentic-first protocols, the promotion path to canonical. Glossary updated with three new entries. - v2-migration.mdx: 'Shipping a custom format' subsection on sales-agent server-side considerations. Three steps: pick format_shape from registry (or PR a new entry), author a JSON Schema describing your params/slots, host at a stable URI with immutable caching. Open-ecosystem publishers host on their own subdomain; walled-garden sellers route through AAO mirror. Validation: all schema/example/v2-fixture tests green. The worked-example fixture in the schema validates against the schema itself (proves the discriminator + allOf if/then constraints work as intended). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/creative/v2-migration.mdx | 16 ++++- docs/creative/v2-overview.mdx | 60 +++++++++++++++++ .../source/core/canonical-format-kind.json | 5 +- .../source/core/format-shape-vocabulary.json | 64 +++++++++++++++++++ .../core/product-format-declaration.json | 62 +++++++++++++++++- 5 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 static/schemas/source/core/format-shape-vocabulary.json diff --git a/docs/creative/v2-migration.mdx b/docs/creative/v2-migration.mdx index 6e6223d29c..81f37c72b5 100644 --- a/docs/creative/v2-migration.mdx +++ b/docs/creative/v2-migration.mdx @@ -234,8 +234,8 @@ Override fields take precedence over `brand.json` for that creative. ### Sales agents (DSPs, SSPs, retail media networks, walled gardens) -1. **Inventory**: enumerate your existing v1 named formats. Confirm each maps to one of the 11 v2 canonicals. -2. **Translate**: for each named format, write a v2 `ProductFormatDeclaration` narrowing the canonical with your platform's parameters. +1. **Inventory**: enumerate your existing v1 named formats. Confirm each maps to one of the 11 v2 canonicals OR to a custom shape (see "Shipping a custom format" below). Composed/coordinated/sponsorship shapes (multi-placement takeover, roadblock, branded content, cross-screen sponsorship, sponsorship lockup, newsletter, AR lens, playable, live event sponsorship) ship as `format_kind: "custom"` with a `format_shape` registry classifier and a `format_schema` URI+digest reference. +2. **Translate**: for each named format, write a v2 `ProductFormatDeclaration` narrowing the canonical with your platform's parameters. For custom shapes, author a JSON Schema describing your format's `params` and `slots`, host it at a stable URI on your subdomain (or via the AAO mirror for walled-garden sellers), and reference it from `format_schema`. 3. **Be honest about runtime readiness**: set `runtime_status` on each declaration. `stable` (default) means your runtime fully honors the declared format and production source. `preview` means the basic path works but advanced axes (per-item fan-out under `item_production_model`, brief-driven overrides, advanced `platform_extensions`) may be partial. `declared_only` means the catalog declaration is forward-looking and your runtime does NOT yet implement the path — common during migration when you port v1 catalog declarations forward but haven't wired the new production-source axis yet. Buyers can filter on this; compliance storyboards skip-gate `declared_only` entries gracefully. Upgrade the value as your runtime catches up. 4. **Test**: validate translated declarations against `/schemas/core/product.json` (use the `npm run test:v2-fixtures` pattern). 5. **Publish dual**: keep your v1 named formats and `list_creative_formats` working through 4.x. Add the v2 `format_options` field on products that have it. @@ -250,6 +250,18 @@ Three concrete hooks v2 introduces that existing seller implementations don't ha - **`get_products` response gathers extension definitions.** When products carry v2 `format.params.platform_extensions` references, the response SHOULD include the referenced extension definitions in the `extensions` map keyed by `@sha256:`. Implementations gather extensions referenced by any product in the response, dedupe by digest, and emit. Buyers cache by URI@digest; subsequent responses MAY omit definitions the buyer already has cached. Trivial when no products use v2 declarations; only kicks in when tenants opt in. - **`production_window_business_days` on host-read / agent-produced products.** Today most server implementations don't model production turnaround on Products — the field is a v2 addition. Only matters once a tenant ships a v2 host-read or generative-video product (audio_hosted with `audio_source: 'publisher_host_recorded'`, or any product with `synthesis_nondeterministic: true`). Today many of these flows route through hand-trafficked sponsorships and don't surface turnaround over the protocol; v2 makes it declarable. +#### Shipping a custom format + +Sellers with creative structures that don't fit the 11 canonicals (multi-placement takeover, roadblock, branded content, cross-screen sponsorship, sponsorship lockup, newsletter sponsorship, AR lens, playable, live event sponsorship) ship via `format_kind: "custom"`. Three pieces: + +1. **Pick a `format_shape`** from the [vocabulary registry](https://adcontextprotocol.org/schemas/v3/core/format-shape-vocabulary.json). If your shape isn't there, file a vocabulary PR — adding entries is governance-light, doesn't require a major version bump, and helps the working group track adoption velocity per shape. +2. **Author a JSON Schema** describing your format's `params` and `slots`. The schema's job is to give buyer agents enough structure to validate manifests and reason about what assets you accept, how you track, what the impression contract is. Treat it like authoring a v1 named format file — same level of rigor, just hosted at your URI rather than under AdCP's roof. Industry-shared schemas (e.g., a shared `multi_placement_takeover_v1` schema several publishers converge on) are encouraged and accelerate canonical promotion. +3. **Host the schema at a stable URI** with `Cache-Control: public, max-age=31536000, immutable` and a digest. Open-ecosystem publishers host on their own subdomain (`https://yourpub.adcp/schemas/formats/your_shape_v1`); walled-garden sellers route through the AAO mirror at `https://mirror.adcontextprotocol.org/translated//` (AAO accepts translation submissions; same hosting / immutability contract). Reference the schema from `format_schema: { uri, digest }` on your `ProductFormatDeclaration`. + +Buyer agents fetch the schema by `uri@digest`, cache it (immutable), and validate manifests structurally. **No human-in-the-loop is required for buyer agents to interpret your format** — that's the load-bearing claim and the reason custom + format_schema isn't `ext`. Ext remains for genuinely experimental shapes that don't even fit a `format_shape` entry yet, but that's the rare case. + +When 2+ adopters ship the same `format_shape` with substantively similar `format_schema` content for 90+ days, the working group promotes the shape to a first-class canonical (creates `/schemas/formats/canonical/.json`, adds the value to `canonical-format-kind.json`, retires the registry entry). Adopters migrate from `format_kind: "custom"` to `format_kind: ""` at that point. The promotion queue is tracked at [adcp#3666](https://github.com/adcontextprotocol/adcp/issues/3666). + ### Creative agents (Flashtalking, AudioStack, generative platforms, AI rendering services) The spec has historically read sales-agent-first. v2 reshapes the creative-agent path enough to warrant its own walkthrough — both for ad-server-shaped creative agents (Flashtalking, Innovid, Sizmek-class) and transformation-shaped creative agents (AudioStack, Pencil, AdCreative.ai-class). diff --git a/docs/creative/v2-overview.mdx b/docs/creative/v2-overview.mdx index 2fc0d2347b..64409de930 100644 --- a/docs/creative/v2-overview.mdx +++ b/docs/creative/v2-overview.mdx @@ -39,6 +39,9 @@ v2 collapses today's separate format registry into product-bound declarations. A | **`brand_kit_override`** | Per-creative override for the case where `brand.json` is missing, stale, or inappropriate. | | **`fanout_mode`** | On `sponsored_placement`: how items map to delivery — `per_item`, `multi_item_in_creative`, `single_item`. | | **`item_production_model`** | On `sponsored_placement`: how each per-item creative is produced. Captures multi-output generative (1 brief × N items → N creatives). | +| **`format_kind: "custom"`** | Adopter-defined shape that doesn't fit the 11 canonicals (multi-placement takeover, branded content, AR lens, etc.). Requires `format_shape` (registry classifier) and `format_schema` (URI+digest reference to a fetchable schema). | +| **`format_shape`** | Recognized global pattern from the [format-shape vocabulary registry](https://adcontextprotocol.org/schemas/v3/core/format-shape-vocabulary.json). Required when `format_kind: "custom"`. | +| **`format_schema`** | URI+digest reference to the fetchable schema describing a custom shape's `params` and `slots`. Required when `format_kind: "custom"`. Same hosting model as `platform_extensions`. | ## Architectural shift @@ -121,6 +124,63 @@ When `*_source` is `buyer_uploaded`, the buyer ships rendered assets and any tra `vast_tracker` and `daast_tracker` decomposed tracker assets work for both `buyer_uploaded` and seller-rendered sources — when the seller renders, those tracker assets are inputs to the rendered tag, attached to the appropriate VAST/DAAST `` block at production time. When the buyer ships a complete `vast` or `daast` tag, the trackers travel inside the tag. +## Custom formats — shapes the 11 canonicals don't cover + +The 11 canonicals cover atomic creative shapes (one image, one video, one display tag, one carousel, one catalog placement, one AI-surface mention). They don't cover composed / coordinated / sponsorship shapes that high-end publishers and broadcast networks sell as headline products: multi-placement takeover, roadblock, branded content, cross-screen sponsorship, sponsorship lockup, newsletter sponsorship, AR lens, playable, live event sponsorship. + +These shapes are real ad-industry product types — but they're either multi-canonical compositions (takeover = image + video + display_tag + lockup, sold as a unit) or genuinely novel structures (branded content's editorial-sponsorship production model isn't a composition of the 11). v2 handles them via a structured custom mechanism that buyer agents can reason about, NOT via free-form `ext`. + +### The mechanism + +```json test=false +{ + "format_options": [ + { + "format_kind": "custom", + "format_shape": "multi_placement_takeover", + "format_schema": { + "uri": "https://nytimes.adcp/schemas/formats/homepage_takeover_v3", + "digest": "sha256:e1d4f6a9c2b5e8d1f4a7c0b3e6d9f2a5c8b1e4d7f0a3c6b9e2d5f8a1c4b7e0a3" + }, + "capability_id": "nytimes_homepage_takeover_premium", + "applies_to_channels": ["display", "olv"], + "params": { + "components": [ + { "placement_type": "homepage_skin", "required": true }, + { "placement_type": "preroll_video", "required": true }, + { "placement_type": "sponsorship_lockup", "required": true } + ], + "exclusivity_window_hours": 24 + } + } + ] +} +``` + +Three required pieces when `format_kind: "custom"`: + +1. **`format_shape`** — recognized global pattern from the [format-shape vocabulary registry](https://adcontextprotocol.org/schemas/v3/core/format-shape-vocabulary.json). Tells buyer agents what kind of pattern they're looking at (`multi_placement_takeover`, `branded_content`, `ar_lens`, etc.). The registry currently lists 9 shapes; non-canonical values are valid (validators MAY soft-warn) so adopters CAN ship a shape that isn't yet in the registry — adding entries is a vocabulary PR, not a major-version bump. +2. **`format_schema`** — URI+digest reference to a fetchable schema describing the shape's actual `params` and `slots`. **Same hosting model as `platform_extensions`**: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://mirror.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching), validate `params` and `slots` against the fetched schema, and reason about manifests structurally. +3. **`params`** — the actual structure, governed by the schema fetched from `format_schema.uri`. AdCP doesn't bake the params shape; the seller's schema does. + +### Why custom + format_schema instead of `ext` + +A buyer agent calling `get_products` and seeing a format with interesting structure buried in `ext` has no spec-level definition to reason against. There's no schema, no required fields, no defined semantics — the agent can see the blob but can't interpret it reliably. A human has to step in to evaluate whether the format fits the campaign brief, what assets are needed, how it tracks, what the impression contract is, whether the price makes sense. + +That breaks the load-bearing claim of v2: **buyer agents can reason structurally without per-seller integration code.** ext-only puts interesting structure in a free-form bag, regressing to human-in-the-loop. Custom + `format_shape` + `format_schema` keeps the agentic-first contract: the shape has a registered classifier, the structure has a fetchable schema, the buyer agent reasons over both. Same caching mechanics buyer agents already have for `platform_extensions`. + +`ext` remains for genuinely experimental shapes that don't even fit a `format_shape` entry yet — but that's the rare case, not the default. The dominant path for novel shapes is custom + format_shape + format_schema. + +### Promotion to canonical + +A `format_shape` entry is promoted to a first-class `format_kind` when: +1. At least 2 production adopters ship it via custom + format_schema +2. 90 consecutive days without a breaking change to the shape adopters converged on +3. The shape has a defined tracking model (which signals fire, which trackers attach, what the impression contract is) +4. The working group opens a per-canonical promotion issue, drafts a canonical schema (`/schemas/formats/canonical/.json`), lands a fixture, and ships in the next minor release + +Same governance pattern that produced the 11 canonicals from the v1 audit. The promotion queue lives at [adcp#3666](https://github.com/adcontextprotocol/adcp/issues/3666); current candidates are the 9 entries in the format-shape registry. + ## Asset group vocabulary Format `slots` reference canonical `asset_group_id` values from the [vocabulary registry](https://adcontextprotocol.org/schemas/v3/core/asset-group-vocabulary.json). The current canonical entries: diff --git a/static/schemas/source/core/canonical-format-kind.json b/static/schemas/source/core/canonical-format-kind.json index 489557dd14..1d669254ac 100644 --- a/static/schemas/source/core/canonical-format-kind.json +++ b/static/schemas/source/core/canonical-format-kind.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/canonical-format-kind.json", "title": "Canonical Format Kind", - "description": "Discriminator value naming one of the 11 canonical creative formats. Used by `product-format-declaration.json` (the product's inline format declaration), `creative-manifest.json` (the buyer's v2 manifest path), and any other surface that needs to identify which canonical a payload targets. The enum mirrors the `oneOf` branches in `product-format-declaration.json`; keep them in sync.", + "description": "Discriminator value naming one of the 11 canonical creative formats — plus `custom` for adopter-defined shapes that don't fit the canonicals (multi-placement takeover, roadblock, branded content, cross-screen sponsorship, AR lens, etc.). Used by `product-format-declaration.json` (the product's inline format declaration), `creative-manifest.json` (the buyer's v2 manifest path), and any other surface that needs to identify which canonical a payload targets.\n\nWhen `format_kind: \"custom\"`, the declaration MUST also carry `format_shape` (referencing the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) — recognized global pattern this custom shape is an instance of) and `format_schema` (URI+digest reference to a fetchable schema describing the shape's actual `params` and `slots`). Buyer agents fetch the schema, validate manifests structurally, and reason about manifests without per-seller integration code — same mechanic as `platform_extensions`. See [adcp#3666](https://github.com/adcontextprotocol/adcp/issues/3666) for the canonical promotion queue.\n\nThe canonical enum mirrors the `oneOf` branches in `product-format-declaration.json`; keep them in sync.", "type": "string", "enum": [ "image", @@ -15,6 +15,7 @@ "audio_daast", "sponsored_placement", "responsive_creative", - "agent_placement" + "agent_placement", + "custom" ] } diff --git a/static/schemas/source/core/format-shape-vocabulary.json b/static/schemas/source/core/format-shape-vocabulary.json new file mode 100644 index 0000000000..4ebbf2bd08 --- /dev/null +++ b/static/schemas/source/core/format-shape-vocabulary.json @@ -0,0 +1,64 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/format-shape-vocabulary.json", + "title": "AdCP Format Shape Vocabulary Registry", + "description": "Canonical registry of `format_shape` values used on `ProductFormatDeclaration` when `format_kind: \"custom\"`. Captures recognized creative-structure patterns that are NOT yet first-class canonical formats — composed/coordinated/sponsorship shapes that high-end publishers and broadcast networks sell as headline products. Each registry entry names a global shape; the seller's actual structure lives in `format_schema` (URI+digest reference to the seller-hosted or AAO-mirrored schema) so buyer agents can fetch and validate against a real schema rather than reasoning over an opaque ext blob.\n\n**Two-layer extensibility:**\n- **Canonical** (`format_kind: image`, `video_vast`, etc.): full spec coverage, stable contract.\n- **Custom + format_shape + format_schema** (`format_kind: \"custom\"`): recognized pattern, classified against this vocabulary, but the params/slots structure is supplied by a fetchable schema rather than baked into AdCP.\n\nNon-canonical `format_shape` values remain valid (validators MAY soft-warn) so adopters CAN ship a shape that isn't yet in the registry — adding entries is a vocabulary PR, not a major-version bump. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group promotes it to a first-class canonical (creates `/schemas/formats/canonical/.json`, adds the value to `canonical-format-kind.json`, retires the registry entry). See [adcp#3666](https://github.com/adcontextprotocol/adcp/issues/3666) for the promotion queue.", + "version": "1.0.0", + "lastUpdated": "2026-04-30", + "vocabulary": { + "multi_placement_takeover": { + "description": "Coordinated buy targeting several placements at once on a single page or content unit, sold as a unit (homepage skin + banner + preroll + sponsorship lockup, all firing for the same advertiser concurrently). Used by NYTimes, WSJ, Hulu, premium publishers. Multi-canonical composition — the format_schema enumerates each component placement and its own format constraints.", + "typical_use": "Premium publisher homepage takeovers, day-part broadcast takeovers, content-section sponsorships", + "tracking_model_hint": "Per-component impressions plus a takeover-level engagement metric (time-on-page, scroll depth)", + "promotion_status": "tracking — see adcp#3666" + }, + "roadblock": { + "description": "Single advertiser owns all spots in a content unit (every commercial break in a half-hour show, every banner on a section page, every preroll for a content category). Linear-TV-shaped but applies to digital too. Distinct from `multi_placement_takeover` because all spots share the SAME format (ten preroll videos), not different formats coordinated together.", + "typical_use": "Linear TV roadblocks, podcast network sponsorships of a content category, all-spots-on-section", + "tracking_model_hint": "Per-spot impressions plus exclusivity attestation", + "promotion_status": "tracking — see adcp#3666" + }, + "branded_content": { + "description": "Publisher-produced editorial sponsorship (advertorial, sponsored articles, paid features, branded videos). Shape doesn't compose from canonicals — it's its own creative production model where the publisher's editorial team produces the asset from a buyer brief. Distinct from `agent_placement` (AI-surface composition) and `audio_hosted` host-read (shorter-form, scripted-from-buyer).", + "typical_use": "NYT T Brand Studio, WSJ Custom Content, Vox Creative", + "tracking_model_hint": "Engagement-keyed (time-on-page, scroll depth, video completion) rather than impression-keyed", + "promotion_status": "tracking — see adcp#3666" + }, + "cross_screen_sponsorship": { + "description": "Synchronized buys across linear TV / CTV / display / audio. Frequency-capped at the user, not the screen. Concurrent-session-keyed tracking that ties impressions across devices to the same household / individual.", + "typical_use": "Live sports sponsorship spanning broadcast + streaming + social, multi-screen reach campaigns", + "tracking_model_hint": "Cross-device impression dedup; household-level reach measurement", + "promotion_status": "tracking — see adcp#3666" + }, + "sponsorship_lockup": { + "description": "Persistent brand presence over a content window (Hulu's 'commercial-free experience presented by Brand X', podcast network sponsorship lockups, newsletter section sponsorships, Spotify-shaped audio-on-demand sponsorships). Long-duration, low-impression count, premium pricing. Structure: a brand reference + a duration window + (optionally) a small lockup creative (logo, tagline) that persists.", + "typical_use": "Premium streaming sponsorships, podcast network presenting sponsorships", + "tracking_model_hint": "Window-duration impressions plus a single lockup-creative-view event", + "promotion_status": "tracking — see adcp#3666" + }, + "newsletter_sponsorship": { + "description": "Email-embedded creative with newsletter-issue-keyed measurement. Distinct from display because the impression event is open-tracking-pixel-shaped, not page-view-shaped. Distinct from email marketing because the surface is a third-party newsletter (the publisher's), not a brand-owned send.", + "typical_use": "Substack newsletter sponsorships, Morning Brew, Axios, The Skimm", + "tracking_model_hint": "Open-pixel impressions (proxied via the newsletter's email service); CTR via redirect URLs", + "promotion_status": "tracking — see adcp#3666" + }, + "ar_lens": { + "description": "Interactive AR creative (Snap lens, Meta camera filter, TikTok effect). SDK-specific composition model; doesn't fit `html5` because the engagement shape is fundamentally different — face/world tracking, gesture interactions, share-out mechanics, rendered-on-device.", + "typical_use": "Snap branded lenses, Meta camera filters, TikTok branded effects", + "tracking_model_hint": "Lens-specific events (open, capture, share, time-played) tied to lens_id", + "promotion_status": "tracking — see adcp#3666" + }, + "playable": { + "description": "Interactive HTML5 mini-game or experience (Unity playable, IAB MRAID 3.0 playable). Distinct from `html5` banner because engagement is the primary impression event, not view-through.", + "typical_use": "Mobile game ads, branded mini-experiences (IKEA Place-shaped AR-lite)", + "tracking_model_hint": "Engagement-event-keyed (game start, level complete, completion)", + "promotion_status": "tracking — see adcp#3666" + }, + "live_event_sponsorship": { + "description": "Sponsorship attached to a specific live broadcast (sports, concert, breaking-news window). Concurrent-impression and stream-state tracking — the impression is bounded by the event's start/end times and may include during-event creative variants.", + "typical_use": "Super Bowl pregame sponsorship, live concert streaming sponsorships, breaking-news sponsorship windows", + "tracking_model_hint": "Event-window-bounded impressions; stream-state correlation (live vs replay vs ended)", + "promotion_status": "tracking — see adcp#3666" + } + } +} diff --git a/static/schemas/source/core/product-format-declaration.json b/static/schemas/source/core/product-format-declaration.json index fef96126e7..bd8b505ab5 100644 --- a/static/schemas/source/core/product-format-declaration.json +++ b/static/schemas/source/core/product-format-declaration.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/product-format-declaration.json", "title": "Product Format Declaration", - "description": "Inline format declaration on a product. The `format_kind` discriminator names which canonical format the product narrows; `params` carries the canonical's parameter schema (slots, dimensions, durations, codecs, character limits, platform_extensions, tracking_extensions, etc.). Optional `capability_id` (stable identifier for routing when a product's `format_options` contains multiple declarations sharing the same `format_kind`) and `applies_to_channels` (subset of the product's declared channels this declaration applies to — lets a multi-channel product carry distinct format_options per channel). Discriminated-union shape generates clean tagged unions in TypeScript and Pydantic codegen. Replaces v1's named-format pattern (where products referenced a separately-defined format file via compound `format_id`). v1 named formats remain supported through the deprecation cycle; v2 product-bound declarations are opt-in.", + "description": "Inline format declaration on a product. The `format_kind` discriminator names which canonical format the product narrows; `params` carries the canonical's parameter schema (slots, dimensions, durations, codecs, character limits, platform_extensions, tracking_extensions, etc.). Optional `capability_id` (stable identifier for routing when a product's `format_options` contains multiple declarations sharing the same `format_kind`) and `applies_to_channels` (subset of the product's declared channels this declaration applies to — lets a multi-channel product carry distinct format_options per channel). Discriminated-union shape generates clean tagged unions in TypeScript and Pydantic codegen. Replaces v1's named-format pattern (where products referenced a separately-defined format file via compound `format_id`). v1 named formats remain supported through the deprecation cycle; v2 product-bound declarations are opt-in.\n\n**Custom format_kind** (`format_kind: \"custom\"`): for adopter-defined shapes that don't fit the 11 canonicals (multi-placement takeover, roadblock, branded content, cross-screen sponsorship, sponsorship lockup, newsletter sponsorship, AR lens, playable, live event sponsorship). When `format_kind` is `custom`, the declaration MUST carry `format_shape` (recognized global pattern from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json)) AND `format_schema` (URI+digest reference to a fetchable schema describing the actual `params` and `slots`). Buyer agents fetch the schema, validate manifests structurally, and reason about manifests without per-seller integration code. See [adcp#3666](https://github.com/adcontextprotocol/adcp/issues/3666) for the canonical promotion queue.", "type": "object", "required": ["format_kind", "params"], "discriminator": { "propertyName": "format_kind" }, @@ -22,8 +22,33 @@ "enum": ["stable", "preview", "declared_only"], "default": "stable", "description": "Adopter-runtime readiness for this product-format declaration. **Distinct from the canonical's `status` field** (which describes whether the v2 working group has stabilized the format definition itself). `runtime_status` describes whether THIS seller's runtime actually honors what they declared on THIS product.\n\n- `stable` (default) — adopter's runtime fully honors the declared format + production source. Buyers can rely on the declaration as a serving contract.\n- `preview` — runtime supports the basic path; some axes (e.g., per-item fan-out under `item_production_model`, brief-driven overrides, advanced `platform_extensions`) may be partial. Buyers SHOULD validate via `validate_input` or sandbox before committing budget.\n- `declared_only` — catalog declaration is forward-looking; runtime does NOT yet implement this path. Buyers MUST treat as informational and confirm via `validate_input` or a sandbox storyboard before purchase. Compliance storyboards SHOULD skip-gate `declared_only` entries gracefully rather than failing.\n\nThe two axes vary independently: a `stable` canonical can have `declared_only` adopters (canonical is settled in spec but adopter hasn't wired runtime yet), and a `preview` canonical can have `stable` adopters (adopter built against the preview shape and their runtime fully honors it). Producers SHOULD set this when their product declaration is aspirational; absence is interpreted as `stable`. Sellers MUST upgrade the value as the runtime catches up; buyers cache it like any other capability field." + }, + "format_shape": { + "type": "string", + "description": "REQUIRED when `format_kind: \"custom\"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`multi_placement_takeover`, `roadblock`, `branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values valid (validators MAY soft-warn) — adopters CAN ship a shape that isn't yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group promotes it to a first-class canonical." + }, + "format_schema": { + "$ref": "/schemas/core/platform-extension-ref.json", + "description": "REQUIRED when `format_kind: \"custom\"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape's actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://mirror.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that's why the schema is required, not optional." } }, + "allOf": [ + { + "if": { + "properties": { "format_kind": { "const": "custom" } }, + "required": ["format_kind"] + }, + "then": { + "required": ["format_shape", "format_schema"] + }, + "else": { + "not": { "anyOf": [ + { "required": ["format_shape"] }, + { "required": ["format_schema"] } + ] } + } + } + ], "oneOf": [ { "title": "Image Format Declaration", @@ -112,6 +137,19 @@ "params": { "$ref": "/schemas/formats/canonical/agent_placement.json" } }, "required": ["format_kind", "params"] + }, + { + "title": "Custom Format Declaration", + "description": "Adopter-defined shape that doesn't fit the 11 canonicals. Requires `format_shape` (vocabulary-registered global pattern) and `format_schema` (URI+digest reference to a fetchable schema describing the actual params/slots). `params` shape is governed by the fetched schema rather than baked into AdCP — kept as `type: object` here with `additionalProperties: true` because the canonical schema validates dynamically post-fetch.", + "properties": { + "format_kind": { "type": "string", "const": "custom" }, + "params": { + "type": "object", + "additionalProperties": true, + "description": "Custom shape's params. Validated against the schema fetched from `format_schema.uri` at the cached `format_schema.digest`." + } + }, + "required": ["format_kind", "params"] } ], "examples": [ @@ -174,6 +212,28 @@ "production_window_business_days": 7 } } + }, + { + "description": "NYTimes Homepage Takeover — custom format_kind, classified against the multi_placement_takeover format_shape, with format_schema pointing at NYTimes's hosted schema. Buyer agents fetch the schema by uri@digest (cached, immutable) and validate the manifest structurally.", + "data": { + "format_kind": "custom", + "format_shape": "multi_placement_takeover", + "format_schema": { + "uri": "https://nytimes.adcp/schemas/formats/homepage_takeover_v3", + "digest": "sha256:e1d4f6a9c2b5e8d1f4a7c0b3e6d9f2a5c8b1e4d7f0a3c6b9e2d5f8a1c4b7e0a3" + }, + "capability_id": "nytimes_homepage_takeover_premium", + "applies_to_channels": ["display", "olv"], + "params": { + "components": [ + { "placement_type": "homepage_skin", "required": true }, + { "placement_type": "preroll_video", "required": true }, + { "placement_type": "sponsorship_lockup", "required": true } + ], + "exclusivity_window_hours": 24, + "ssl_required": true + } + } } ] } From a8228b666fbf4aeb755e5776484fb6b9b2c8dc5c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 1 May 2026 14:23:00 -0400 Subject: [PATCH 26/41] feat(creative): A1 dual-emission of format_ids+format_options legal; A2 canonical mapping registry + canonical field on v1 format.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #3765 and #3767. Reverses the wrong direction of b3c29bc17 (which forced exactly-one of format_ids xor format_options on products) — that broke the migration story by tying wire shape to AdCP-Version. Sellers should be able to author once and let SDKs project to both wire shapes. A1 (#3765): relax product.json from oneOf-with-not to anyOf - product.json: oneOf → anyOf. Both branches still required at- least-one; not clauses dropped. Both shapes legal during migration. - format_ids and format_options descriptions document the dual- emission contract: same underlying declaration when both present; buyers prefer format_options; SDK derives one from the other to guarantee invariant; non-projectable formats ship format_ids only. - tests/schema-validation.test.cjs assertion updated to anyOf with no-not invariant — surfaces regression to the old shape. - Phase 2 status callout in v2-overview.mdx updated. A2 (#3767): canonical mapping registry + canonical field on v1 format declaration - New /schemas/registries/v1-canonical-mapping.json with 15 seed entries (IAB display sizes 300×250, 728×90, 160×600, 970×250, 300×600, 320×50, 320×480, 336×280; VAST 4.x and 2.x/3.x; DAAST 1.x; structural matches for video/audio/zip/url last-resort). Two match modes: format_id_glob and structural. Governance via same vocabulary rules as asset-group-vocabulary.json. Initial scope deliberately small per discussion — full ~50-100 entries land in follow-up PRs once SDKs consume. - format.json (v1 format declaration) gets canonical and canonical_parameters fields. canonical $refs canonical-format- kind.json; canonical_parameters $refs product-format-declaration for strict typing. allOf if/then enforces canonical is set whenever canonical_parameters is. - v2-migration.mdx new "v1 → v2 canonical mapping" section documenting the resolution order: explicit canonical → glob → structural → fail closed (SDK MUST NOT emit format_options). Reverse direction (v2 → v1 demotion) is the inverse projection through the same registry. Without these, the "publish both during transition" story isn't actually legal under the schema and SDKs would diverge on v1↔v2 projection. With them: buyers and sellers move independently, neither blocked by the other's pace, all SDKs project identically because the data is identical. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/creative/v2-migration.mdx | 59 +++++- docs/creative/v2-overview.mdx | 2 +- static/schemas/source/core/format.json | 28 +++ static/schemas/source/core/product.json | 12 +- .../registries/v1-canonical-mapping.json | 180 ++++++++++++++++++ tests/schema-validation.test.cjs | 18 +- 6 files changed, 285 insertions(+), 14 deletions(-) create mode 100644 static/schemas/source/registries/v1-canonical-mapping.json diff --git a/docs/creative/v2-migration.mdx b/docs/creative/v2-migration.mdx index 81f37c72b5..bf0764d0a7 100644 --- a/docs/creative/v2-migration.mdx +++ b/docs/creative/v2-migration.mdx @@ -95,10 +95,67 @@ Most of AdCP doesn't change. v2 builds on the existing primitives: } ``` -`format_options` is an array. The 90% case is one element — one canonical narrowed for the product. Multi-element arrays declare that the product accepts any of the listed format options, picked by the buyer at `sync_creatives` time. Common multi-element use cases: a placement that accepts EITHER a third-party-hosted creative (e.g., Flashtalking-served `html5`) OR an internal `display_tag`; a video product that accepts a hosted upload (`video_hosted`) OR a tag (`video_vast`). Each entry is a discriminated union: `format_kind` names the canonical format; `params` carries that canonical's parameter schema. SDKs codegen clean tagged unions in TypeScript and Pydantic. A v2 product is **either** v1 (uses `format_ids`) **or** v2 (uses `format_options`) — not both. The product schema's `oneOf` enforces this. +`format_options` is an array. The 90% case is one element — one canonical narrowed for the product. Multi-element arrays declare that the product accepts any of the listed format options, picked by the buyer at `sync_creatives` time. Common multi-element use cases: a placement that accepts EITHER a third-party-hosted creative (e.g., Flashtalking-served `html5`) OR an internal `display_tag`; a video product that accepts a hosted upload (`video_hosted`) OR a tag (`video_vast`). Each entry is a discriminated union: `format_kind` names the canonical format; `params` carries that canonical's parameter schema. SDKs codegen clean tagged unions in TypeScript and Pydantic. + +**Dual emission during the migration window**: Products MAY carry `format_ids`, `format_options`, or BOTH; at least one is required (the schema enforces this via an `anyOf`, not `oneOf`). The recommended seller pattern is to author once and let the SDK project to both wire shapes via the [v1↔v2 canonical mapping registry](https://adcontextprotocol.org/schemas/v3/registries/v1-canonical-mapping.json), so every buyer reads what it knows. When both shapes are present on a product, the two MUST refer to the same underlying format declaration — the `format_options[i]` must narrow the canonical that `format_ids[i]` resolves to via the registry. SDKs that derive both shapes from one source guarantee this invariant; SDKs that hand-author both MUST treat divergence as a build error and refuse to emit. Buyers prefer `format_options` when both are present; treat `format_ids` as fallback for v1-only buyers. Sellers whose v1 named formats have no clean v2 projection ship `format_ids` only for those products until they add an explicit `canonical` declaration on the v1 format (see "v1 → v2 canonical mapping" below) — the SDK MUST NOT emit `format_options` for non-projectable formats. For 12 fully-validated reference Product fixtures spanning all 11 canonical formats — Meta Reels (`video_hosted` vertical), IAB MREC (`image` 300×250), NYTimes HTML5 (`html5`), GAM 3P display tag (`display_tag`), Meta Carousel (`image_carousel`), YouTube VAST pre-roll (`video_vast`), podcast 30s host-read (`audio_hosted`), Triton DAAST audio (`audio_daast`), Amazon Sponsored Products (`sponsored_placement`), Google PMax (`responsive_creative`), ChatGPT brand mention (`agent_placement`), Veo 15s generative video (`video_hosted` with `synthesis_nondeterministic` + `provenance_required`) — plus 1 `get_products` response fixture exercising bundled extensions, see `static/examples/products/v2/` and `static/examples/get_products_responses/v2/`. The Veo fixture exercises `synthesis_nondeterministic: true` and `provenance_required: true`. Each fixture passes `npm run test:v2-fixtures`. +## v1 → v2 canonical mapping + +The slot-level `asset_group_id` bridge below tells SDKs which v2 canonical *slot* a v1 slot corresponds to. The format-level mapping — which v2 *canonical format* a v1 named format projects to — is solved by two complementary mechanisms: + +### The canonical mapping registry + +[`/schemas/registries/v1-canonical-mapping.json`](https://adcontextprotocol.org/schemas/v3/registries/v1-canonical-mapping.json) is the authoritative AAO-published registry. SDKs consume it to project v1 formats to v2 canonicals during dual-emission and v1↔v2 translation. Two match modes per entry: + +- **`format_id_glob`** — exact / glob match against v1 `format_id.id`. Covers IAB-conventional sizes (`iab/mrec_300x250` → `image` 300×250), named platform formats, common publisher conventions. +- **`structural`** — match against the format's slot shape, asset types, and version constraints. Catches custom v1 formats that are structurally a standard format under a different name (a seller's `acme_homepage_300x250` is structurally an IAB MREC). + +Initial registry covers ~15 unambiguous entries (IAB display sizes, VAST 4.x, DAAST 1.x). Subsequent PRs expand coverage as adopter feedback surfaces patterns. Governance follows the same rules as `asset-group-vocabulary.json`: PR with rationale + ≥1 reference adopter + AAO maintainer review; entries are additive and digest-pinned. + +### `canonical` field on v1 format declarations (custom / non-registered formats) + +For custom seller formats not covered by the registry, the seller declares the mapping inline at the v1 format-declaration level: + +```json test=false +{ + "format_id": "acme/sponsored_recipe_card", + "name": "Sponsored Recipe Card", + "canonical": "sponsored_placement", + "canonical_parameters": { + "format_kind": "sponsored_placement", + "params": { + "supported_catalog_types": ["product"], + "supported_id_types": ["sku"], + "fanout_mode": "per_item", + "item_production_model": "buyer_uploaded" + } + }, + "assets": [ + { "asset_id": "headline", "asset_type": "text", "asset_group_id": "headline", "required": true }, + { "asset_id": "recipe_image", "asset_type": "image", "asset_group_id": "image_main", "required": true } + ] +} +``` + +The `canonical` field names the v2 canonical; `canonical_parameters` (optional) carries a full ProductFormatDeclaration the SDK projects this v1 format into. Combined with slot-level `asset_group_id` declarations on each `assets[]` entry, the v1 format becomes fully self-describing for v1↔v2 translation. Seller does this once per custom format, not per buyer or per product. + +For the `format_kind: "custom"` case (a custom seller format that itself doesn't fit any v2 canonical), the `canonical_parameters` carries the same `format_kind: "custom"` + `format_shape` + `format_schema` triple as a normal v2 declaration would. + +### Normative SDK projection rules + +Resolution order when an SDK projects a v1 format to its v2 canonical (or back): + +1. If the v1 format declaration carries `canonical`, use it (seller-declared, highest priority). When `canonical_parameters` is also present, use that as the projected ProductFormatDeclaration verbatim. +2. Else, look up `format_id` in `/schemas/registries/v1-canonical-mapping.json`'s `format_id_glob` entries. +3. Else, attempt structural match against the registry's `structural` entries. +4. Else, **fail closed**: SDK MUST NOT emit `format_options` for products carrying this format. Surface a validation warning suggesting the seller add an explicit `canonical` field or file a registry entry. Buyers see `format_ids` only for these products. + +Reverse direction (v2 → v1 demotion for v1-only buyers reading a v2 seller) is the inverse projection: same registry, same algorithm, run backward. SDKs ship a single mapping engine that handles both directions. + +This is what makes "publish both wire shapes during migration" tractable and consistent across SDK implementations — every SDK projects through the same registry so dual-emitted products from any seller resolve identically across any buyer. + ## Slot name mapping (v1 → canonical) If a v1 format slot uses an author-invented name that the canonical vocabulary covers, the format declaration carries an optional `asset_group_id` field on the slot pointing at the canonical entry. Same as the existing `asset_role` field, but referencing the [canonical vocabulary](https://adcontextprotocol.org/schemas/v3/core/asset-group-vocabulary.json) rather than free text. diff --git a/docs/creative/v2-overview.mdx b/docs/creative/v2-overview.mdx index 64409de930..4663499ff9 100644 --- a/docs/creative/v2-overview.mdx +++ b/docs/creative/v2-overview.mdx @@ -811,7 +811,7 @@ The third-party-creative-agent worked example assumes Flashtalking-shaped tools | Phase | Status | What's in it | |---|---|---| | Phase 1 | ✅ in #3307 | `asset_group_id` vocabulary registry (canonical entries + audit-grounded aliases), `scenes` schema, `zip` asset type, video/audio doc fixes | -| Phase 2 | ✅ in #3307 | 11 canonical format definitions with structured `slots` declaration, `ProductFormatDeclaration` (format_kind discriminator + params), `validate_input` primitive, `creative.supported_formats` on get_adcp_capabilities, `brand_kit_override`, `platform-extension-ref`, typed inline `product_card` / `product_card_detailed`, `format_ids` ⊕ `format_options` oneOf on Product | +| Phase 2 | ✅ in #3307 | 11 canonical format definitions with structured `slots` declaration, `ProductFormatDeclaration` (format_kind discriminator + params), `validate_input` primitive, `creative.supported_formats` on get_adcp_capabilities, `brand_kit_override`, `platform-extension-ref`, typed inline `product_card` / `product_card_detailed`, `format_ids` + `format_options` `anyOf` on Product (dual emission legal during migration per #3765) | | Phase 3 | ✅ in #3307 | v1↔v2 migration guide, 12 fully-validated reference Product fixtures + 1 get_products response fixture with bundled extensions, fixture-validation test suite (`npm run test:v2-fixtures`) | | Phase 4 | ⚠️ blocking adoption | Reference SDK codegen (TypeScript first, then Python), server-side flatten wrapper reference implementation. Without Phase 4, adopters cannot consume v2 cleanly — the typed-tagged-union ergonomics this PR's design earns require codegen to deliver. v2 is opt-in and additive at the schema layer today; Phase 4 makes it usable. | | Native canonical | TBD | Deferred to 3.2 after TemplateCreative + OpenRTB Native 1.2 audit | diff --git a/static/schemas/source/core/format.json b/static/schemas/source/core/format.json index d86c63f0b0..dafb4e153e 100644 --- a/static/schemas/source/core/format.json +++ b/static/schemas/source/core/format.json @@ -655,11 +655,39 @@ "$ref": "/schemas/core/vendor-pricing-option.json" }, "minItems": 1 + }, + "canonical": { + "$ref": "/schemas/core/canonical-format-kind.json", + "description": "Optional v2 canonical format this v1 named format projects to. When set, SDKs use this declaration as the authoritative v1 → v2 mapping for this format, bypassing the [v1 canonical mapping registry](/schemas/registries/v1-canonical-mapping.json) lookup. Combined with the slot-level `asset_group_id` declarations on each `assets[i]` entry, a v1 format declaration with `canonical` set is fully self-describing for v1↔v2 translation.\n\nResolution order for SDK projection from v1 wire shape to v2 (per RFC #3305 amendment #3767):\n1. If this `canonical` field is set, use it (seller-declared, highest priority).\n2. Else, look up `format_id` in the canonical mapping registry's `format_id_glob` entries.\n3. Else, attempt structural match against the registry's `structural` entries (asset types, slot shape, vast_versions, etc.).\n4. Else, fail closed: SDK MUST NOT emit `format_options` for products carrying this format. Surface a validation warning suggesting the seller add an explicit `canonical` field or file a registry entry.\n\nWhen the canonical is `custom`, the seller MUST also declare `canonical_format_shape` and `canonical_format_schema` (parallel to ProductFormatDeclaration's `format_shape` and `format_schema`) so buyer SDKs can fetch the seller's custom format schema." + }, + "canonical_parameters": { + "$ref": "/schemas/core/product-format-declaration.json", + "description": "Optional. When `canonical` is set, this field carries the full ProductFormatDeclaration that the SDK projects this v1 format into. Strict-typed via `$ref` to product-format-declaration so the discriminator + canonical-specific params validate consistently. The `format_kind` MUST equal the `canonical` field value (validators enforce). When set, this is the authoritative source for SDK v1→v2 projection — the registry's structural-match parameter inference is bypassed.\n\nUse case: a custom seller format whose v2 narrowing isn't representable purely from registry structural-match (e.g., a takeover that bundles multi-canonical components — set `canonical: \"custom\"` and provide `canonical_parameters` with a fully-formed ProductFormatDeclaration including `format_shape` and `format_schema`)." } }, "required": [ "format_id", "name" ], + "allOf": [ + { + "if": { + "required": ["canonical_parameters"] + }, + "then": { + "required": ["canonical"], + "properties": { + "canonical_parameters": { + "type": "object", + "properties": { + "format_kind": { + "$comment": "Must agree with the sibling `canonical` field — validators SHOULD assert this constraint at build time. JSON Schema can't directly express cross-field equality without ext keywords." + } + } + } + } + } + } + ], "additionalProperties": true } diff --git a/static/schemas/source/core/product.json b/static/schemas/source/core/product.json index 30b13fe2d7..bf03b5b947 100644 --- a/static/schemas/source/core/product.json +++ b/static/schemas/source/core/product.json @@ -36,7 +36,7 @@ }, "format_ids": { "type": "array", - "description": "v1 path: array of supported creative format IDs (structured format_id objects with agent_url and id). Products MAY use either `format_ids` (v1) or `format_options` (v2 inline declarations) — not both. v1 named formats remain supported through the deprecation cycle.", + "description": "v1 path: array of supported creative format IDs (structured format_id objects with agent_url and id). Products MUST carry `format_ids`, `format_options`, or BOTH; at least one is required. v1 named formats remain supported through the v1-deprecation calendar (2027-Q4 floor / 2029-Q1 ceiling).\n\n**Dual emission**: A product MAY carry both `format_ids` and `format_options` simultaneously during the migration window. This is the recommended seller pattern — author once, SDK projects to both wire shapes via the [v1↔v2 canonical mapping registry](/schemas/registries/v1-canonical-mapping.json), every buyer reads what it knows. When both are present, the two MUST refer to the SAME underlying format declaration (the `format_options[i]` narrows the canonical that the named format in `format_ids[i]` resolves to via the registry / explicit `canonical` field). SDKs that derive both shapes from one source guarantee this invariant; SDKs that don't MUST treat divergence as a build error and refuse to emit. **Buyer rule**: when both are present, prefer `format_options`; treat `format_ids` as fallback for v1-only buyers. **Non-projectable formats**: when a v1 named format has no clean v2 projection (no registry entry, no explicit `canonical` declaration on the v1 format, no structural match), SDKs MUST NOT emit `format_options` for that product — only `format_ids` ships, and the product remains v1-only until the seller adds an explicit `canonical` field or files a registry entry.", "items": { "$ref": "/schemas/core/format-id.json" } @@ -44,7 +44,7 @@ "format_options": { "type": "array", "minItems": 1, - "description": "v2 path: one or more inline format declarations the product accepts. Each element narrows a canonical format with parameters, slots, and platform_extensions. The 90% case is a single-element array (one canonical narrowed for the product). Multi-element use cases: a product that accepts EITHER a third-party-hosted creative (e.g., Flashtalking-served `html5`) OR an internal `display_tag`; a video product that accepts a hosted `video_hosted` upload OR a `video_vast` tag. Buyers pick which option they're shipping at `sync_creatives` time by aligning their manifest to the matching declaration's `format_kind` and slots. Mutually exclusive with `format_ids`.", + "description": "v2 path: one or more inline format declarations the product accepts. Each element narrows a canonical format with parameters, slots, and platform_extensions. The 90% case is a single-element array (one canonical narrowed for the product). Multi-element use cases: a product that accepts EITHER a third-party-hosted creative (e.g., Flashtalking-served `html5`) OR an internal `display_tag`; a video product that accepts a hosted `video_hosted` upload OR a `video_vast` tag. Buyers pick which option they're shipping at `sync_creatives` time by aligning their manifest to the matching declaration's `format_kind` and slots.\n\nProducts MUST carry `format_ids`, `format_options`, or BOTH; at least one is required. See `format_ids` description for the dual-emission contract (same underlying declaration when both are present; SDK derives one from the other; buyers prefer `format_options` when both are present).", "items": { "$ref": "/schemas/core/product-format-declaration.json" } @@ -497,18 +497,16 @@ "pricing_options", "reporting_capabilities" ], - "oneOf": [ + "anyOf": [ { "title": "v1 Product (named-format reference)", "description": "Product references one or more named formats by structured format_id ({ agent_url, id }). The v1 path; remains supported through 4.x.", - "required": ["format_ids"], - "not": { "required": ["format_options"] } + "required": ["format_ids"] }, { "title": "v2 Product (inline format declarations)", "description": "Product carries one or more inline ProductFormatDeclarations, each narrowing a canonical format. The v2 path introduced by RFC #3305. A single-element `format_options` array is the 90% case; multi-element arrays declare that the product accepts any of the listed format options.", - "required": ["format_options"], - "not": { "required": ["format_ids"] } + "required": ["format_options"] } ], "additionalProperties": true diff --git a/static/schemas/source/registries/v1-canonical-mapping.json b/static/schemas/source/registries/v1-canonical-mapping.json new file mode 100644 index 0000000000..323fd0d2b0 --- /dev/null +++ b/static/schemas/source/registries/v1-canonical-mapping.json @@ -0,0 +1,180 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/registries/v1-canonical-mapping.json", + "title": "v1 → v2 Canonical Format Mapping Registry", + "description": "Authoritative AAO-published mapping from v1 named formats to v2 canonical declarations. Used by SDKs to project between wire shapes during the migration window: v1 format on the wire → v2 canonical in memory (or vice versa).\n\n**Resolution order** (per RFC #3305 amendment #3767, normative):\n1. If the v1 format declaration carries an explicit `canonical` field, use it (seller-declared, highest priority).\n2. Else, look up `format_id` in this registry's `format_id_glob` entries.\n3. Else, attempt structural match against this registry's `structural` entries.\n4. Else, fail closed: SDK MUST NOT emit `format_options` for products carrying this format. Surface a validation warning suggesting the seller add an explicit `canonical` field or file a registry entry against this file.\n\n**Match modes:**\n- `format_id_glob` — exact / glob match against the v1 `format_id.id` (the named-format identifier). Covers IAB-conventional sizes, named platform formats, common publisher conventions. Glob syntax: `*` matches any segment.\n- `structural` — match against the format's slot shape, asset types, and version constraints. Catches custom v1 formats that are structurally a standard format under a different name (e.g., `acme_homepage_300x250` is structurally an IAB MREC).\n\n**Governance**: same vocabulary-governance rules as `asset-group-vocabulary.json` and `format-shape-vocabulary.json` — additions land via PR with rationale + ≥1 reference adopter; AAO maintainer review; versioned + content-digested. Entries are additive; once published they are not removed (they may be marked `deprecated: true` if superseded).\n\n**Initial scope (3.1)**: ~15 unambiguous entries covering IAB display sizes, VAST 4.x, DAAST 1.x. Subsequent PRs expand coverage as adopter feedback surfaces patterns. The full v1-format audit dataset (~76% of formats from the 12-platform / 86-format audit in #3305) seeds the long-term roadmap.\n\nDigest the file content (sha256) when emitting in capabilities responses or referencing from SDK output. Buyers cache by `version` + `digest`.", + "version": "1.0.0", + "last_updated": "2026-05-01", + "type": "object", + "required": ["version", "mappings"], + "properties": { + "version": { + "type": "string", + "description": "Semver of this registry. Bumped on every published change." + }, + "last_updated": { + "type": "string", + "format": "date", + "description": "ISO date of the last published change." + }, + "mappings": { + "type": "array", + "description": "Ordered list of v1 → v2 mappings. SDKs apply mappings in order and use the first match.", + "items": { + "type": "object", + "required": ["v1_pattern", "v2"], + "properties": { + "v1_pattern": { + "type": "object", + "description": "Match pattern. Carries either format_id_glob OR structural, not both.", + "oneOf": [ + { + "required": ["format_id_glob"], + "properties": { + "format_id_glob": { + "type": "string", + "description": "Glob pattern matched against v1 format_id.id. Examples: 'iab/mrec_300x250', 'iab/leaderboard_*', 'meta/*_reels'." + } + } + }, + { + "required": ["structural"], + "properties": { + "structural": { + "type": "object", + "description": "Structural match against the format's slot shape, asset types, and version constraints.", + "properties": { + "asset_types": { + "type": "array", + "items": { "type": "string" }, + "description": "Set of asset_type values that must appear in the format's slots (in any order, any count)." + }, + "vast_versions": { + "type": "array", + "items": { "type": "string" }, + "description": "VAST version constraints. Strings like '>=4.0', '4.x', '4.2'." + }, + "daast_versions": { + "type": "array", + "items": { "type": "string" } + }, + "dimensions": { + "type": "object", + "properties": { + "width": { "type": "integer" }, + "height": { "type": "integer" } + } + } + }, + "additionalProperties": true + } + } + } + ] + }, + "v2": { + "type": "object", + "required": ["canonical"], + "properties": { + "canonical": { + "$ref": "/schemas/core/canonical-format-kind.json", + "description": "v2 canonical format the v1 pattern projects to." + }, + "parameters": { + "type": "object", + "description": "Optional parameters that narrow the canonical (e.g., width/height, vast_version). When present, become the params on the projected v2 ProductFormatDeclaration. The shape MUST be valid params for the named canonical.", + "additionalProperties": true + } + } + }, + "deprecated": { + "type": "boolean", + "default": false, + "description": "When true, this mapping is retained for backward-compatibility but should not be used for new mappings. SDKs SHOULD emit lint warnings when matching a deprecated entry." + }, + "notes": { + "type": "string", + "description": "Optional human-readable explanation, examples, or rationale." + } + } + } + } + }, + "mappings": [ + { + "v1_pattern": { "format_id_glob": "iab/mrec_300x250" }, + "v2": { "canonical": "image", "parameters": { "width": 300, "height": 250 } }, + "notes": "IAB Universal Ad Package — Medium Rectangle. Industry standard since 2005." + }, + { + "v1_pattern": { "format_id_glob": "iab/leaderboard_728x90" }, + "v2": { "canonical": "image", "parameters": { "width": 728, "height": 90 } }, + "notes": "IAB Universal Ad Package — Leaderboard." + }, + { + "v1_pattern": { "format_id_glob": "iab/wide_skyscraper_160x600" }, + "v2": { "canonical": "image", "parameters": { "width": 160, "height": 600 } }, + "notes": "IAB Universal Ad Package — Wide Skyscraper." + }, + { + "v1_pattern": { "format_id_glob": "iab/billboard_970x250" }, + "v2": { "canonical": "image", "parameters": { "width": 970, "height": 250 } }, + "notes": "IAB Rising Star — Billboard. Often used for premium homepage units." + }, + { + "v1_pattern": { "format_id_glob": "iab/half_page_300x600" }, + "v2": { "canonical": "image", "parameters": { "width": 300, "height": 600 } }, + "notes": "IAB Universal Ad Package — Half Page." + }, + { + "v1_pattern": { "format_id_glob": "iab/mobile_banner_320x50" }, + "v2": { "canonical": "image", "parameters": { "width": 320, "height": 50 } }, + "notes": "IAB mobile portrait banner." + }, + { + "v1_pattern": { "format_id_glob": "iab/mobile_interstitial_320x480" }, + "v2": { "canonical": "image", "parameters": { "width": 320, "height": 480 } }, + "notes": "IAB mobile portrait interstitial." + }, + { + "v1_pattern": { "format_id_glob": "iab/large_rectangle_336x280" }, + "v2": { "canonical": "image", "parameters": { "width": 336, "height": 280 } }, + "notes": "IAB Universal Ad Package — Large Rectangle." + }, + { + "v1_pattern": { "structural": { "asset_types": ["vast"], "vast_versions": [">=4.0"] } }, + "v2": { "canonical": "video_vast", "parameters": { "vast_version": "4.2" } }, + "notes": "Any v1 format whose primary asset is a VAST 4.x tag. SDKs should narrow the parameters.vast_version to the lowest common denominator they support." + }, + { + "v1_pattern": { "structural": { "asset_types": ["vast"], "vast_versions": ["3.x", "2.x"] } }, + "v2": { "canonical": "video_vast", "parameters": { "vast_version": "3.0" } }, + "notes": "Legacy VAST 2.x/3.x — projected as video_vast with the lowest-supported version." + }, + { + "v1_pattern": { "structural": { "asset_types": ["daast"], "daast_versions": ["1.0", "1.1"] } }, + "v2": { "canonical": "audio_daast", "parameters": { "daast_version": "1.1" } }, + "notes": "DAAST 1.x audio tag → audio_daast canonical." + }, + { + "v1_pattern": { "structural": { "asset_types": ["zip"] } }, + "v2": { "canonical": "html5" }, + "notes": "Any v1 format whose primary asset is a zip bundle (HTML5 banner). Caller must add platform_extensions for OM-SDK / clickTag specifics from the v1 declaration." + }, + { + "v1_pattern": { "structural": { "asset_types": ["video"] } }, + "v2": { "canonical": "video_hosted" }, + "notes": "v1 format with a hosted video file as its primary asset → video_hosted. Caller infers orientation/dimensions from the v1 slot constraints." + }, + { + "v1_pattern": { "structural": { "asset_types": ["audio"] } }, + "v2": { "canonical": "audio_hosted" }, + "notes": "v1 format with a hosted audio file as its primary asset → audio_hosted. Distinct from audio_daast (which uses tag delivery)." + }, + { + "v1_pattern": { "structural": { "asset_types": ["url"] } }, + "v2": { "canonical": "display_tag" }, + "notes": "Last-resort structural match: v1 format whose primary asset is a URL pointing at a third-party-served creative → display_tag canonical. Lower confidence than other entries — sellers SHOULD declare an explicit `canonical` for url-shaped formats whenever possible." + } + ] +} diff --git a/tests/schema-validation.test.cjs b/tests/schema-validation.test.cjs index dc3e01a635..fc759d98cf 100644 --- a/tests/schema-validation.test.cjs +++ b/tests/schema-validation.test.cjs @@ -302,15 +302,23 @@ async function runTests() { } } - // product.json: assert v1 (format_ids) OR v2 (format_options) is required via oneOf + // product.json: assert v1 (format_ids) OR v2 (format_options) is required via anyOf — at-least-one, + // BOTH allowed during the migration window (per RFC #3305 amendment #3765). The previous oneOf-with-not + // shape required exactly one and forbade dual emission, which broke the seller migration story. const productEntry = coreSchemas.find(([p]) => path.basename(p) === 'product.json'); if (productEntry) { const [, productSchema] = productEntry; - const oneOf = productSchema.oneOf || []; - const hasV1Branch = oneOf.some((branch) => (branch.required || []).includes('format_ids')); - const hasV2Branch = oneOf.some((branch) => (branch.required || []).includes('format_options')); + const anyOf = productSchema.anyOf || []; + const hasV1Branch = anyOf.some((branch) => (branch.required || []).includes('format_ids')); + const hasV2Branch = anyOf.some((branch) => (branch.required || []).includes('format_options')); if (!hasV1Branch || !hasV2Branch) { - return `product.json: must have a oneOf with v1 branch (required: ["format_ids"]) and v2 branch (required: ["format_options"]); found v1=${hasV1Branch}, v2=${hasV2Branch}`; + return `product.json: must have an anyOf with v1 branch (required: ["format_ids"]) and v2 branch (required: ["format_options"]); found v1=${hasV1Branch}, v2=${hasV2Branch}`; + } + // No-not invariant: branches MUST NOT carry `not` clauses excluding the other branch — that would + // be the old oneOf behavior. anyOf with no negative constraints lets dual-emission products validate. + const hasForbiddenNotClause = anyOf.some((branch) => branch.not && branch.not.required); + if (hasForbiddenNotClause) { + return `product.json: anyOf branches must not carry 'not: required' clauses — dual emission of format_ids + format_options is legal during migration. See #3765.`; } } From fbb207bba1dd39c6c865e76abbddff8988e5e3b2 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 1 May 2026 14:34:40 -0400 Subject: [PATCH 27/41] =?UTF-8?q?fix(creative):=20R1-R7=20review-line=20fi?= =?UTF-8?q?xes=20=E2=80=94=20card-asset,=20validate=5Finput=20result=5Fkin?= =?UTF-8?q?d,=20capability=5Fid=20rule,=20duration=20precedence,=20negativ?= =?UTF-8?q?e=20fixtures,=20test=20hygiene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes review-line comments R1-R7 from nastassiafulconis on PR #3307. R6: validate_input worked example fixed - assets.video → assets.video_main (matched the violation field path) - predicted: 30000 → 95000 (was inside the expected 3000-90000 range, now clearly out-of-range) - v1 format_id removed; example now uses v2 format_kind shape - example uses new result_kind discriminator (R2) R5: capability_id required-when rule rewritten - v2-overview line ~572 sentence was garbled (two thoughts collided). Rewrote as a normative routing rule: capability_id REQUIRED when multiple format_options share the same format_kind; OPTIONAL when each format_kind in the product is unique. Recommended habit even when optional, for log/replay/tooling clarity. R1: image_carousel cards as a real asset type - New /schemas/core/assets/card-asset.json with asset_type "card", required `media` (image|video oneOf), optional headline, landing_page_url, platform_extensions, provenance. - Added card to asset-union.json oneOf and to creative/asset-types/index.json registry. - Added "card" to canonical _base.json slot asset_type enum. - image_carousel slot changed from asset_type:"object" → "card". - Renamed allowed_card_asset_types → allowed_card_media_asset_types (with deprecated alias retained for back-compat) to disambiguate what the field constrains. - Manifest assets patternProperties now accept oneOf(single asset | array of assets) — unblocks carousel cards AND responsive_creative multi-value slots (headlines, descriptions, etc.) that were structurally broken under the previous single-asset constraint. Same change in creative-asset.json and list-creatives-response.json. R3: negative fixtures regression test - Worry was: top-level discriminator + allOf if/then/else + 12-branch oneOf might allow format_kind:"image" with stray format_schema to slip through. Verified via 7 negative fixtures + 4 validate-input-result fixtures that the schema rejects every malformed case. New tests/v2-negative-fixtures.test.cjs surfaces regressions; wired up as test:v2-negative npm script. R4: duration_ms_range vs duration_ms_exact precedence - Documented exact-wins precedence in description prose on video_hosted, audio_hosted, video_vast, audio_daast canonicals. SDKs SHOULD lint a warning when both fields ship; producers SHOULD pick one. Schema stays permissive (no oneOf) for back- compat with any product currently emitting both. R2: result_kind replaces ok on validate-input-result - Replaced boolean `ok` with discriminator `result_kind: validated_pass | validated_fail | unvalidatable_nondeterministic`. Lets buyers distinguish "manifest validates and fails" from "platform is nondeterministic, can't pre-validate" — previously conflated. allOf if/then enforces violations[] absent for the two non-failure result_kinds. - validate-input-response description updated to reference the three result_kind values. - No consumers yet (v2 not shipped); breaking shape change uncontroversial. R7: v2-fixture-validation.test.cjs hygiene - (a) Schema parse errors during load now log + exit 2 (was: silently swallowed). Only the addSchema duplicate-$id error stays tolerated (benign during partial dist/ rebuilds). - (b) Removed truncate-at-10 on error reporting. The 12-branch product-format-declaration emits one error per non-matching branch — full output is necessary to find the actual failure cause. - (c) Per-canonical params strict-mode validation runs against each format_options[i].params using a separate AJV instance with additionalProperties-tolerance disabled. Surfaces typos in params that the product envelope (additionalProperties:true) lets pass. Custom format_kind excluded — its params shape is governed by the seller's fetched format_schema. Validation: schema/example/v2-fixture/v2-negative tests all green. Carousel manifests now actually validate (verified ad-hoc). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/creative/v2-overview.mdx | 42 ++-- package.json | 1 + .../source/core/assets/asset-union.json | 3 +- .../source/core/assets/card-asset.json | 41 ++++ .../schemas/source/core/creative-asset.json | 11 +- .../source/core/creative-manifest.json | 11 +- .../source/creative/asset-types/index.json | 5 + .../creative/list-creatives-response.json | 11 +- .../creative/validate-input-response.json | 2 +- .../creative/validate-input-result.json | 57 +++++- .../source/formats/canonical/_base.json | 4 +- .../source/formats/canonical/audio_daast.json | 6 +- .../formats/canonical/audio_hosted.json | 6 +- .../formats/canonical/image_carousel.json | 31 +-- .../formats/canonical/video_hosted.json | 4 +- .../source/formats/canonical/video_vast.json | 6 +- tests/v2-fixture-validation.test.cjs | 157 +++++++++++--- tests/v2-negative-fixtures.test.cjs | 192 ++++++++++++++++++ 18 files changed, 496 insertions(+), 94 deletions(-) create mode 100644 static/schemas/source/core/assets/card-asset.json create mode 100644 tests/v2-negative-fixtures.test.cjs diff --git a/docs/creative/v2-overview.mdx b/docs/creative/v2-overview.mdx index 4663499ff9..47b9d31858 100644 --- a/docs/creative/v2-overview.mdx +++ b/docs/creative/v2-overview.mdx @@ -569,7 +569,23 @@ A placement that accepts EITHER a third-party-hosted creative OR an internal tag } ``` -Buyer's manifest: `{ "format_kind": "html5", "capability_id": "html5_flashtalking_hosted", "assets": { "html5_bundle": {...}, "backup_image": {...} } }` — `format_kind` selects the canonical's slot vocabulary; `capability_id` disambiguates because both options share `format_kind: html5`/`display_tag`-shaped slots are different anyway, but `capability_id` removes any ambiguity for the seller's router. (When `format_options` carries one element per `format_kind`, `capability_id` is optional — `format_kind` alone routes the manifest.) +Buyer's manifest, targeting the html5 option: + +```json test=false +{ + "format_kind": "html5", + "capability_id": "html5_flashtalking_hosted", + "assets": { "html5_bundle": { /* ... */ }, "backup_image": { /* ... */ } } +} +``` + +**Routing rule for multi-element `format_options`** (normative): + +- `format_kind` selects the canonical and its slot vocabulary. +- `capability_id` is **REQUIRED** on the manifest when the target product's `format_options` contains two or more declarations sharing the same `format_kind` — without it, the seller can't disambiguate which option the buyer is shipping against. +- `capability_id` is **OPTIONAL** when each `format_kind` in the product's `format_options` is unique (the example above: one html5 entry, one display_tag entry) — `format_kind` alone routes the manifest. Buyers MAY still send `capability_id` as a clarity hint. + +In this example each option carries a distinct `format_kind`, so `capability_id` is optional. Including it (as shown) is a recommended habit — it makes the manifest unambiguous to logs, replays, and downstream tooling, and it keeps the buyer-side codepath identical regardless of whether the seller's product has one or many options sharing a kind. ## Worked example — sponsored_placement with item_production_model @@ -605,45 +621,47 @@ A retail-media product that accepts a catalog reference plus a brief, and render ## Validation flow — `validate_input` -Buyers can dry-run a manifest against canonicals and/or specific products without committing to a render: +Buyers can dry-run a manifest against canonicals and/or specific products without committing to a render. The buyer's manifest below is a v2 manifest (`format_kind: "video_hosted"`); the slot key is the canonical's `asset_group_id` (`video_main`); the asset value carries its `asset_type` discriminator. The buyer asks `validate_input` to check both the canonical contract AND the seller's specific product narrowing in a single round-trip: ```json test=false { "manifest": { - "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_30s" }, + "format_kind": "video_hosted", "assets": { - "video": { + "video_main": { "asset_type": "video", - "url": "https://cdn.acme.example/spring-30s.mp4", - "duration_ms": 30000, + "url": "https://cdn.acme.example/spring-95s.mp4", + "duration_ms": 95000, "width": 1080, "height": 1920 } }, "brand": { "domain": "acme.example" } }, - "format_ids": ["video_hosted"], - "product_ids": ["meta_reels_us"] + "targets": [ + { "kind": "canonical", "id": "video_hosted" }, + { "kind": "product", "id": "meta_reels_us" } + ] } ``` -Response carries per-target results: +Response carries per-target results. The canonical accepts the duration (canonical `video_hosted` doesn't constrain duration — products narrow); the Meta Reels product narrows duration to `[3000, 90000]` ms, so 95000 is out of range and the product target fails: ```json test=false { "results": [ { "target": { "kind": "canonical", "id": "video_hosted" }, - "ok": true + "result_kind": "validated_pass" }, { "target": { "kind": "product", "id": "meta_reels_us" }, - "ok": false, + "result_kind": "validated_fail", "violations": [ { "rule": "duration_ms_range", "expected": "3000-90000", - "predicted": 30000, + "predicted": 95000, "field": "assets.video_main.duration_ms" } ] diff --git a/package.json b/package.json index 91066fe2d4..f2ee941dcc 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "test:snippets": "node tests/snippet-validation.test.cjs", "test:json-schema": "node tests/json-schema-validation.test.cjs", "test:v2-fixtures": "node tests/v2-fixture-validation.test.cjs", + "test:v2-negative": "node tests/v2-negative-fixtures.test.cjs", "test:error-handling": "node tests/check-error-handling.cjs", "test:composed": "node tests/composed-schema-validation.test.cjs", "test:migrations": "node tests/migration-validation.test.cjs", diff --git a/static/schemas/source/core/assets/asset-union.json b/static/schemas/source/core/assets/asset-union.json index a0ff0c376a..552544e6c2 100644 --- a/static/schemas/source/core/assets/asset-union.json +++ b/static/schemas/source/core/assets/asset-union.json @@ -18,7 +18,8 @@ { "$ref": "/schemas/core/assets/daast-asset.json" }, { "$ref": "/schemas/core/assets/markdown-asset.json" }, { "$ref": "/schemas/core/assets/brief-asset.json" }, - { "$ref": "/schemas/core/assets/catalog-asset.json" } + { "$ref": "/schemas/core/assets/catalog-asset.json" }, + { "$ref": "/schemas/core/assets/card-asset.json" } ], "discriminator": { "propertyName": "asset_type" diff --git a/static/schemas/source/core/assets/card-asset.json b/static/schemas/source/core/assets/card-asset.json new file mode 100644 index 0000000000..d24ba9fd34 --- /dev/null +++ b/static/schemas/source/core/assets/card-asset.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/assets/card-asset.json", + "title": "Card Asset", + "description": "A single card in a multi-card creative (image_carousel, future composed carousels). Carries: `media` (an image OR video asset), optional `headline` (text), optional `landing_page_url` (url asset with `url_type: \"clickthrough\"`).\n\nUsed as the array element type for the `cards` slot on image_carousel canonicals. Adopters MUST NOT invent per-card key conventions like `card_0_headline` / `cards.0.headline` — the manifest's `assets.cards` value is an array of card-asset objects, period. Per-card platform extensions (e.g., Meta-specific carousel attributes, Pinterest pin overrides) attach via the `platform_extensions` field, never via inline non-canonical keys.", + "type": "object", + "required": ["asset_type", "media"], + "properties": { + "asset_type": { + "type": "string", + "const": "card", + "description": "Discriminator identifying this as a card asset. See /schemas/creative/asset-types for the registry." + }, + "media": { + "description": "The card's primary visual asset. Either an `image` or `video` asset, matching the parent format's `allowed_card_asset_types` parameter.", + "oneOf": [ + { "$ref": "/schemas/core/assets/image-asset.json" }, + { "$ref": "/schemas/core/assets/video-asset.json" } + ], + "discriminator": { "propertyName": "asset_type" } + }, + "headline": { + "type": "string", + "description": "Optional per-card headline. Length governed by `card_headline_max_chars` on the format declaration." + }, + "landing_page_url": { + "$ref": "/schemas/core/assets/url-asset.json", + "description": "Optional per-card click-through URL. URL asset with `url_type: \"clickthrough\"`." + }, + "platform_extensions": { + "type": "array", + "description": "Per-card platform-specific extensions (URI+digest references). Same hosting model as format-level platform_extensions. Use this for Meta carousel-card attributes, Pinterest pin overrides, etc. — NEVER inline non-canonical keys on the card object directly.", + "items": { "$ref": "/schemas/core/platform-extension-ref.json" } + }, + "provenance": { + "$ref": "/schemas/core/provenance.json", + "description": "Provenance metadata for this card, overrides manifest-level provenance." + } + }, + "additionalProperties": true +} diff --git a/static/schemas/source/core/creative-asset.json b/static/schemas/source/core/creative-asset.json index e6c691aaff..f32c890020 100644 --- a/static/schemas/source/core/creative-asset.json +++ b/static/schemas/source/core/creative-asset.json @@ -28,10 +28,17 @@ }, "assets": { "type": "object", - "description": "Assets required by the format, keyed by asset_id. Each asset value carries an `asset_type` discriminator that selects the matching asset schema.", + "description": "Assets required by the format, keyed by asset_id. Each slot value is either a single asset object or an array of asset objects (for slots with `min`/`max > 1` like carousel `cards` or responsive_creative `headlines`). Each asset value carries an `asset_type` discriminator that selects the matching asset schema.", "patternProperties": { "^[a-z0-9_]+$": { - "$ref": "/schemas/core/assets/asset-union.json" + "oneOf": [ + { "$ref": "/schemas/core/assets/asset-union.json" }, + { + "type": "array", + "items": { "$ref": "/schemas/core/assets/asset-union.json" }, + "minItems": 1 + } + ] } }, "additionalProperties": true diff --git a/static/schemas/source/core/creative-manifest.json b/static/schemas/source/core/creative-manifest.json index 4b2e132536..10ef1d38c1 100644 --- a/static/schemas/source/core/creative-manifest.json +++ b/static/schemas/source/core/creative-manifest.json @@ -19,10 +19,17 @@ }, "assets": { "type": "object", - "description": "Map of slot keys to actual asset content. v1 path: each key matches an `asset_id` from the format's `assets` array (e.g., 'banner_image', 'clickthrough_url', 'video_file', 'vast_tag'). v2 path: each key matches an `asset_group_id` from the format's `slots` declaration drawn from the canonical vocabulary registry (e.g., 'images_landscape', 'video', 'landing_page_url', 'vast_tag', 'script', 'creative_brief'). Either path produces the same envelope shape; only the slot-key vocabulary differs.\n\nEach asset value carries an `asset_type` discriminator (image, video, audio, vast, daast, text, markdown, url, html, css, webhook, javascript, brief, catalog, zip) that selects the matching asset schema. Validators with OpenAPI-style discriminator support use `asset_type` to report errors against only the selected branch instead of all branches.", + "description": "Map of slot keys to actual asset content. v1 path: each key matches an `asset_id` from the format's `assets` array (e.g., 'banner_image', 'clickthrough_url', 'video_file', 'vast_tag'). v2 path: each key matches an `asset_group_id` from the format's `slots` declaration drawn from the canonical vocabulary registry (e.g., 'images_landscape', 'video', 'landing_page_url', 'vast_tag', 'script', 'creative_brief'). Either path produces the same envelope shape; only the slot-key vocabulary differs.\n\nEach slot value is **either** a single asset object (most slots — image, video, vast_tag, landing_page_url, etc.) **or** an array of asset objects (slots with `min`/`max` counts on the format declaration — `cards` on `image_carousel`, `headlines` / `descriptions` / `images_landscape` on `responsive_creative`, etc.). Single-vs-array shape is governed by the format's `slots[].min` and `slots[].max` parameters: when `max > 1` (or when the slot is conceptually a pool), the value MUST be an array; when the slot is single-valued, the value MUST be a single object. Each asset value (single or array element) carries an `asset_type` discriminator (image, video, audio, vast, daast, text, markdown, url, html, css, webhook, javascript, brief, catalog, zip, card) that selects the matching asset schema. Validators with OpenAPI-style discriminator support use `asset_type` to report errors against only the selected branch instead of all branches.", "patternProperties": { "^[a-z0-9_]+$": { - "$ref": "/schemas/core/assets/asset-union.json" + "oneOf": [ + { "$ref": "/schemas/core/assets/asset-union.json" }, + { + "type": "array", + "items": { "$ref": "/schemas/core/assets/asset-union.json" }, + "minItems": 1 + } + ] } }, "additionalProperties": true diff --git a/static/schemas/source/creative/asset-types/index.json b/static/schemas/source/creative/asset-types/index.json index d4cd88bd3c..bd96c7bcda 100644 --- a/static/schemas/source/creative/asset-types/index.json +++ b/static/schemas/source/creative/asset-types/index.json @@ -80,6 +80,11 @@ "description": "Typed data feed (products, stores, jobs, etc.)", "schema": "/schemas/core/assets/catalog-asset.json", "typical_use": "Product catalogs, store locators, job feeds for dynamic creatives" + }, + "card": { + "description": "Single card in a multi-card creative — image_carousel `cards` slot element", + "schema": "/schemas/core/assets/card-asset.json", + "typical_use": "Carousel cards (Meta-style mixed-media, Pinterest collections), Snap collection cards. Each card carries its own media (image OR video), optional headline, optional landing_page_url. The carousel format's `cards` slot is an array of card-asset values." } }, "architecture": { diff --git a/static/schemas/source/creative/list-creatives-response.json b/static/schemas/source/creative/list-creatives-response.json index 677b4d8ff5..f2456e5c96 100644 --- a/static/schemas/source/creative/list-creatives-response.json +++ b/static/schemas/source/creative/list-creatives-response.json @@ -92,10 +92,17 @@ }, "assets": { "type": "object", - "description": "Assets for this creative, keyed by asset_id. Each asset value carries an `asset_type` discriminator that selects the matching asset schema.", + "description": "Assets for this creative, keyed by asset_id. Each slot value is either a single asset object or an array of asset objects (for slots with `min`/`max > 1`). Each asset value carries an `asset_type` discriminator that selects the matching asset schema.", "patternProperties": { "^[a-z0-9_]+$": { - "$ref": "/schemas/core/assets/asset-union.json" + "oneOf": [ + { "$ref": "/schemas/core/assets/asset-union.json" }, + { + "type": "array", + "items": { "$ref": "/schemas/core/assets/asset-union.json" }, + "minItems": 1 + } + ] } }, "additionalProperties": true diff --git a/static/schemas/source/creative/validate-input-response.json b/static/schemas/source/creative/validate-input-response.json index f88cce43d4..9afc6710d9 100644 --- a/static/schemas/source/creative/validate-input-response.json +++ b/static/schemas/source/creative/validate-input-response.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/creative/validate-input-response.json", "title": "Validate Input Response", - "description": "Response payload for the validate_input task. Returns per-target validation results — one entry per format_id or product_id requested. `predicted` carries the platform's pre-flight estimate (e.g., predicted audio duration from text-length analysis), NOT the actual output — there is no protocol state for orphaned out-of-spec artifacts. For nondeterministic generative platforms, the QA-loop obligation means out-of-spec output never reaches this surface; instead, build_creative returns task_failed with synthesis_failed reason.\n\nThe `ValidateInputResult` type is split into its own schema (`/schemas/creative/validate-input-result.json`) rather than inlined here because the same per-target shape is intended for reuse by adjacent async-validation surfaces (planned: per-batch result envelopes on `build_creative` async paths, and asynchronous canonical-against-product validation in `sync_creatives`). Producers that only need the synchronous batch shape today MAY treat the split as YAGNI, but the schema reuse anchors the violation/retry shape so downstream surfaces don't drift.", + "description": "Response payload for the validate_input task. Returns per-target validation results — one entry per format_id or product_id requested. Each result carries a `result_kind` discriminator (`validated_pass` / `validated_fail` / `unvalidatable_nondeterministic`) so callers can branch on three meaningfully different outcomes. The `predicted` field on violations carries the platform's pre-flight estimate (e.g., predicted audio duration from text-length analysis), NOT the actual output — there is no protocol state for orphaned out-of-spec artifacts. For nondeterministic generative platforms (Veo / Sora / Runway-class with `synthesis_nondeterministic: true`), the result_kind is `unvalidatable_nondeterministic` — predictive validation is impossible, the platform's post-synthesis QA loop applies on `build_creative`, and out-of-spec output never reaches this surface (instead `build_creative` returns task_failed with synthesis_failed reason).\n\nThe `ValidateInputResult` type is split into its own schema (`/schemas/creative/validate-input-result.json`) rather than inlined here because the same per-target shape is intended for reuse by adjacent async-validation surfaces (planned: per-batch result envelopes on `build_creative` async paths, and asynchronous canonical-against-product validation in `sync_creatives`). Producers that only need the synchronous batch shape today MAY treat the split as YAGNI, but the schema reuse anchors the violation/retry shape so downstream surfaces don't drift.", "type": "object", "required": ["results"], "properties": { diff --git a/static/schemas/source/creative/validate-input-result.json b/static/schemas/source/creative/validate-input-result.json index 17f144a5bb..49ad6f2e0c 100644 --- a/static/schemas/source/creative/validate-input-result.json +++ b/static/schemas/source/creative/validate-input-result.json @@ -2,9 +2,9 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/creative/validate-input-result.json", "title": "Validate Input Result", - "description": "Per-target result of a validate_input call.", + "description": "Per-target result of a validate_input call. The `result_kind` discriminator (replacing the earlier boolean `ok`) lets buyers distinguish three meaningfully different outcomes:\n\n- `validated_pass` — manifest validates cleanly against the target. Buyers can submit with confidence.\n- `validated_fail` — manifest is structurally evaluable AND fails specific constraints. `violations[]` enumerates which. Buyers fix and retry.\n- `unvalidatable_nondeterministic` — predictive validation is impossible because the target's production pipeline is genuinely nondeterministic (Veo / Sora / Runway-class formats with `synthesis_nondeterministic: true`). The platform's own post-synthesis QA loop applies; outcome is unknowable until `build_creative` runs. Buyers MUST plan for the QA-loop semantics: submission may return `task_failed` with a `synthesis_failed` reason if the QA loop exhausts. There is no protocol state for orphaned out-of-spec artifacts.\n\nThe boolean `ok` field carried in earlier drafts is removed — it conflated `validated_fail` (a real validation result the buyer can act on) with `unvalidatable_nondeterministic` (a structural property of the target the buyer needs to handle differently). `validated_fail` returns `violations[]`; `unvalidatable_nondeterministic` does not (there's nothing to enumerate).", "type": "object", - "required": ["target", "ok"], + "required": ["target", "result_kind"], "properties": { "target": { "type": "object", @@ -21,13 +21,14 @@ }, "additionalProperties": true }, - "ok": { - "type": "boolean", - "description": "True when the manifest validates against the target." + "result_kind": { + "type": "string", + "enum": ["validated_pass", "validated_fail", "unvalidatable_nondeterministic"], + "description": "Discriminator for the validation outcome. See schema description for the three states. Replaces the earlier boolean `ok` to distinguish 'failed validation' from 'platform is nondeterministic, can't pre-validate'." }, "violations": { "type": "array", - "description": "When ok is false, the specific constraints the manifest fails to meet.", + "description": "When `result_kind` is `validated_fail`, the specific constraints the manifest fails to meet. MUST be absent (or empty) for `validated_pass` and `unvalidatable_nondeterministic` — neither has constraint violations to enumerate (`unvalidatable_nondeterministic` doesn't validate at all; `validated_pass` has nothing to fail).", "items": { "type": "object", "required": ["rule", "field"], @@ -56,23 +57,57 @@ } } }, + "allOf": [ + { + "if": { + "properties": { "result_kind": { "const": "validated_pass" } }, + "required": ["result_kind"] + }, + "then": { + "not": { "required": ["violations"] } + } + }, + { + "if": { + "properties": { "result_kind": { "const": "unvalidatable_nondeterministic" } }, + "required": ["result_kind"] + }, + "then": { + "not": { "required": ["violations"] } + } + } + ], "additionalProperties": true, "examples": [ { - "description": "Manifest fails canonical video_vertical because its duration is too long", + "description": "Manifest fails Meta Reels product narrowing because its duration exceeds the [3000, 90000] range", "data": { - "target": { "kind": "canonical", "id": "video_vertical" }, - "ok": false, + "target": { "kind": "product", "id": "meta_reels_us" }, + "result_kind": "validated_fail", "violations": [ { "rule": "duration_ms_range", "expected": "3000-90000", - "predicted": 91500, + "predicted": 95000, "field": "assets.video_main.duration_ms", - "retry_with": { "trim_ms_from_end": 1500 } + "retry_with": { "trim_ms_from_end": 5000 } } ] } + }, + { + "description": "Manifest passes canonical video_hosted (canonical doesn't constrain duration; products narrow)", + "data": { + "target": { "kind": "canonical", "id": "video_hosted" }, + "result_kind": "validated_pass" + } + }, + { + "description": "Veo generative video product is unvalidatable up-front; buyer plans for the QA-loop semantics on build_creative", + "data": { + "target": { "kind": "product", "id": "veo_generative_video_vertical_15s" }, + "result_kind": "unvalidatable_nondeterministic" + } } ] } diff --git a/static/schemas/source/formats/canonical/_base.json b/static/schemas/source/formats/canonical/_base.json index 359e69471d..e7c479466a 100644 --- a/static/schemas/source/formats/canonical/_base.json +++ b/static/schemas/source/formats/canonical/_base.json @@ -56,8 +56,8 @@ }, "asset_type": { "type": "string", - "enum": ["image", "video", "audio", "text", "markdown", "url", "html", "css", "javascript", "vast", "daast", "webhook", "brief", "catalog", "zip", "object"], - "description": "Discriminator selecting the asset schema this slot accepts. SDK codegen uses this to type the slot value. `object` is a fallback for structured non-asset inputs that don't fit a primitive asset_type (rare; prefer specific types when possible)." + "enum": ["image", "video", "audio", "text", "markdown", "url", "html", "css", "javascript", "vast", "daast", "webhook", "brief", "catalog", "zip", "card", "object"], + "description": "Discriminator selecting the asset schema this slot accepts. SDK codegen uses this to type the slot value. `card` is the multi-card carousel element type (see card-asset.json). `object` is a last-resort fallback for structured non-asset inputs that don't fit any primitive asset_type — prefer specific types whenever possible." }, "required": { "type": "boolean", diff --git a/static/schemas/source/formats/canonical/audio_daast.json b/static/schemas/source/formats/canonical/audio_daast.json index f0a50b7d28..f008149932 100644 --- a/static/schemas/source/formats/canonical/audio_daast.json +++ b/static/schemas/source/formats/canonical/audio_daast.json @@ -20,11 +20,13 @@ "type": "array", "items": { "type": "integer", "minimum": 0 }, "minItems": 2, - "maxItems": 2 + "maxItems": 2, + "description": "[min, max] duration in milliseconds. **Precedence**: `duration_ms_exact` takes precedence when both ship. SDKs SHOULD lint a warning when both fields ship." }, "duration_ms_exact": { "type": "integer", - "minimum": 1 + "minimum": 1, + "description": "When set, duration must equal exactly this value. Takes precedence over `duration_ms_range` when both ship." }, "linear_required": { "type": "boolean" diff --git a/static/schemas/source/formats/canonical/audio_hosted.json b/static/schemas/source/formats/canonical/audio_hosted.json index 0169668a1b..d9dff0edf3 100644 --- a/static/schemas/source/formats/canonical/audio_hosted.json +++ b/static/schemas/source/formats/canonical/audio_hosted.json @@ -18,11 +18,13 @@ "type": "array", "items": { "type": "integer", "minimum": 0 }, "minItems": 2, - "maxItems": 2 + "maxItems": 2, + "description": "[min, max] duration in milliseconds. **Precedence**: `duration_ms_exact` takes precedence when both ship on the same product. SDKs SHOULD lint a warning when both fields ship." }, "duration_ms_exact": { "type": "integer", - "minimum": 1 + "minimum": 1, + "description": "When set, duration must equal exactly this value. Takes precedence over `duration_ms_range` when both ship." }, "audio_codecs": { "type": "array", diff --git a/static/schemas/source/formats/canonical/image_carousel.json b/static/schemas/source/formats/canonical/image_carousel.json index 15e142faab..63b290ee71 100644 --- a/static/schemas/source/formats/canonical/image_carousel.json +++ b/static/schemas/source/formats/canonical/image_carousel.json @@ -2,32 +2,16 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/formats/canonical/image_carousel.json", "title": "Canonical Format: Image Carousel", - "description": "Multi-card swipeable carousel. The buyer ships a `cards` slot whose value is an **array** of card-shaped objects (a single key with an array value — NOT one key per card, NOT dotted/bracketed paths). Each card carries: `media` (an image or video asset), optional `headline` (text), optional `landing_page_url` (url asset). Per-card structure is the same across all cards; mixed orientations not allowed within a single carousel. Tracking model: per-card impression and engagement pixels + carousel-level engagement (swipe, view-time). Allowed asset types per card: `image` and `video` (Meta-style mixed-media); platforms can narrow to image-only or video-only via `allowed_card_asset_types`.\n\nThe manifest's `assets.cards` value is an array of card objects matching `card_shape` below. Example: `\"cards\": [{\"media\": {\"asset_type\": \"image\", \"url\": \"...\"}, \"headline\": \"Buy now\", \"landing_page_url\": {\"asset_type\": \"url\", \"url\": \"...\"}}, ...]`. This is the normative shape — adopters MUST NOT invent per-card key conventions (`card_0_headline`, `cards.0.headline`, etc.).", + "description": "Multi-card swipeable carousel. The buyer ships a `cards` slot whose value is an **array** of [card-asset](/schemas/core/assets/card-asset.json) objects (a single key with an array value — NOT one key per card, NOT dotted/bracketed paths). Each card-asset carries: `asset_type: \"card\"`, `media` (an image or video asset), optional `headline` (text), optional `landing_page_url` (url asset). Per-card structure is the same across all cards; mixed orientations not allowed within a single carousel. Tracking model: per-card impression and engagement pixels + carousel-level engagement (swipe, view-time). Allowed asset types for a card's `media` field: `image` and `video` (Meta-style mixed-media); platforms can narrow to image-only or video-only via `allowed_card_media_asset_types`.\n\nThe manifest's `assets.cards` value is an array of card-asset objects. Example: `\"cards\": [{\"asset_type\": \"card\", \"media\": {\"asset_type\": \"image\", \"url\": \"...\"}, \"headline\": \"Buy now\", \"landing_page_url\": {\"asset_type\": \"url\", \"url_type\": \"clickthrough\", \"url\": \"...\"}}, ...]`. Each card-asset validates against the card schema; per-card platform extensions attach via the card's `platform_extensions` field, never via inline non-canonical keys.", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { "slots": { "default": [ - { "asset_group_id": "cards", "asset_type": "object", "required": true, "min": 2, "max": 10 }, + { "asset_group_id": "cards", "asset_type": "card", "required": true, "min": 2, "max": 10 }, { "asset_group_id": "primary_text", "asset_type": "text", "required": false }, { "asset_group_id": "landing_page_url", "asset_type": "url", "required": false } ], - "description": "Default slots for image_carousel. The `cards` slot's value in the manifest is an array of card objects (see `card_shape`); `min` / `max` constrain card count." - }, - "card_shape": { - "type": "object", - "description": "Normative structure of each card object in the manifest's `assets.cards` array. Adopters MUST honor this shape; product narrowing MAY add per-card platform_extension fields but MUST NOT rename or restructure the listed fields. Constraints on per-card values (max chars, max file size, etc.) live on the format declaration's per-card parameters (`card_image_max_file_size_kb`, `card_headline_max_chars`, etc.).", - "properties": { - "media": { - "description": "The card's primary visual asset. Either an `image` or `video` asset matching `allowed_card_asset_types`." - }, - "headline": { - "type": "string", - "description": "Optional per-card headline. Length governed by `card_headline_max_chars` on the format declaration." - }, - "landing_page_url": { - "description": "Optional per-card click-through URL. `url` asset with `url_type: \"clickthrough\"`." - } - } + "description": "Default slots for image_carousel. The `cards` slot's value in the manifest is an array of [card-asset](/schemas/core/assets/card-asset.json) objects; `min` / `max` constrain card count." }, "card_aspect_ratio": { "type": "string", @@ -43,13 +27,18 @@ "type": "integer", "description": "Maximum card count (typical: 6, 10, or 35 depending on platform)." }, - "allowed_card_asset_types": { + "allowed_card_media_asset_types": { "type": "array", "items": { "type": "string", "enum": ["image", "video"] }, - "description": "Asset types each card may carry. Default: ['image']. Polymorphic carousels (Meta) allow ['image', 'video']." + "description": "Asset types each card's `media` field may carry. Default: ['image']. Polymorphic carousels (Meta) allow ['image', 'video']. Renamed from `allowed_card_asset_types` to disambiguate that this constrains the card's media payload, not the card-asset itself (which is always asset_type: \"card\")." + }, + "allowed_card_asset_types": { + "type": "array", + "items": { "type": "string", "enum": ["image", "video"] }, + "description": "DEPRECATED — alias for `allowed_card_media_asset_types`. Kept for back-compat; prefer the new field name. Removed in 5.0." }, "card_image_max_file_size_kb": { "type": "integer", diff --git a/static/schemas/source/formats/canonical/video_hosted.json b/static/schemas/source/formats/canonical/video_hosted.json index 0eb76971ef..62ea49d7d2 100644 --- a/static/schemas/source/formats/canonical/video_hosted.json +++ b/static/schemas/source/formats/canonical/video_hosted.json @@ -34,12 +34,12 @@ "items": { "type": "integer", "minimum": 0 }, "minItems": 2, "maxItems": 2, - "description": "[min, max] duration in milliseconds." + "description": "[min, max] duration in milliseconds. **Precedence**: when both `duration_ms_exact` and `duration_ms_range` ship on the same product, `duration_ms_exact` takes precedence — buyers MUST validate against the exact value and ignore the range. The range is treated as advisory metadata in that case (e.g., for UI display showing the broader product family). SDKs SHOULD lint a warning when both fields ship; producers SHOULD pick one." }, "duration_ms_exact": { "type": "integer", "minimum": 1, - "description": "When set, duration must equal exactly this value." + "description": "When set, duration must equal exactly this value. Takes precedence over `duration_ms_range` when both ship (see `duration_ms_range` description)." }, "video_codecs": { "type": "array", diff --git a/static/schemas/source/formats/canonical/video_vast.json b/static/schemas/source/formats/canonical/video_vast.json index 2e803d8118..67b45c542c 100644 --- a/static/schemas/source/formats/canonical/video_vast.json +++ b/static/schemas/source/formats/canonical/video_vast.json @@ -41,11 +41,13 @@ "type": "array", "items": { "type": "integer", "minimum": 0 }, "minItems": 2, - "maxItems": 2 + "maxItems": 2, + "description": "[min, max] duration in milliseconds. **Precedence**: `duration_ms_exact` takes precedence when both ship. SDKs SHOULD lint a warning when both fields ship." }, "duration_ms_exact": { "type": "integer", - "minimum": 1 + "minimum": 1, + "description": "When set, duration must equal exactly this value. Takes precedence over `duration_ms_range` when both ship." }, "min_width": { "type": "integer", "minimum": 1 }, "max_width": { "type": "integer", "minimum": 1 }, diff --git a/tests/v2-fixture-validation.test.cjs b/tests/v2-fixture-validation.test.cjs index 30b5b23e7d..bc1b39a233 100644 --- a/tests/v2-fixture-validation.test.cjs +++ b/tests/v2-fixture-validation.test.cjs @@ -3,9 +3,20 @@ * v2 Reference Fixture Validation Test * * Validates the reference Product fixtures at static/examples/products/v2/*.json - * against /schemas/core/product.json. These fixtures are the "does it really - * work?" check for the v2 RFC (#3305) — concrete fully-valid Product objects - * that adopters and tooling can validate against. + * against /schemas/core/product.json AND against the per-canonical params schema + * in strict mode (to catch typos in `params` that the product-envelope schema's + * `additionalProperties: true` would otherwise let slip through). + * + * Failure modes that previously slipped silently — surfaced now: + * 1. Schema parse errors during load: log + exit 2 (was: silently ignored). + * 2. Duplicate `$id` registrations: still tolerated (only this is silenced — + * multiple files declaring the same $id is a benign condition during + * partial re-runs after a generated dist/ rebuild). + * 3. Validation errors are reported in full (was: truncated to 10). + * 4. Per-canonical params strict-mode validation runs against each + * format_options[i] entry (was: only the product envelope was validated, + * letting `params: { with: 'a typo' }` validate vacuously under + * additionalProperties: true). * * Run: npm run test:v2-fixtures */ @@ -20,34 +31,70 @@ const FIXTURES_DIR = path.resolve(__dirname, '../static/examples/products/v2'); const RESPONSE_FIXTURES_DIR = path.resolve(__dirname, '../static/examples/get_products_responses/v2'); const RED = '\x1b[31m'; +const YELLOW = '\x1b[33m'; const GREEN = '\x1b[32m'; const RESET = '\x1b[0m'; +/** + * Walk the schemas directory and add every JSON Schema with a $id to the AJV + * instance. Distinguishes three failure modes: + * - JSON parse failures: log + exit non-zero (real bugs, never silent). + * - addSchema() throwing because $id already registered: silently tolerated + * (benign during partial dist/ rebuilds; only this case is swallowed). + * - addSchema() throwing for any other reason: re-throw (real bugs). + */ function loadAllSchemas(ajv) { function walk(dir) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { walk(full); - } else if (entry.name.endsWith('.json')) { - try { - const schema = JSON.parse(fs.readFileSync(full, 'utf8')); - if (schema.$id) { - try { - ajv.addSchema(schema, schema.$id); - } catch (e) { - // already added - } - } - } catch (e) { - // skip non-schema or malformed JSON + continue; + } + if (!entry.name.endsWith('.json')) continue; + let raw; + try { + raw = fs.readFileSync(full, 'utf8'); + } catch (e) { + console.error(`${RED}FAIL${RESET} could not read ${full}: ${e.message}`); + process.exit(2); + } + let schema; + try { + schema = JSON.parse(raw); + } catch (e) { + console.error(`${RED}FAIL${RESET} JSON parse error in ${full}: ${e.message}`); + process.exit(2); + } + if (!schema.$id) continue; + try { + ajv.addSchema(schema, schema.$id); + } catch (e) { + if (/already exists/.test(e.message)) { + // Benign — duplicate $id during partial rebuild. Tolerated. + continue; } + console.error(`${RED}FAIL${RESET} addSchema error for ${full} ($id ${schema.$id}): ${e.message}`); + process.exit(2); } } } walk(SCHEMAS_DIR); } +function reportErrors(label, errors) { + console.log(` ${RED}✗${RESET} ${label}`); + // No truncation — full error list reported. SDK validators (AJV, Pydantic) + // emit one entry per non-matching oneOf branch when validating against the + // 12-branch product-format-declaration; downstream consumers need the full + // set to see WHICH branch (or which `allOf` step) actually failed. + for (const err of errors || []) { + const ip = err.instancePath || '(root)'; + const sp = err.schemaPath || ''; + console.log(` ${ip} ${err.message} [${sp}]`); + } +} + function main() { const ajv = new Ajv({ allErrors: true, @@ -55,6 +102,7 @@ function main() { discriminator: true, }); addFormats(ajv); + ajv.addFormat('uri-template', true); loadAllSchemas(ajv); const validate = ajv.getSchema('/schemas/core/product.json'); @@ -63,6 +111,25 @@ function main() { process.exit(2); } + // Per-canonical strict validators — used to catch typos in `params` that + // the product-envelope schema's additionalProperties:true would let pass. + const STRICT_CANONICALS = [ + 'image', 'html5', 'display_tag', 'image_carousel', + 'video_hosted', 'video_vast', 'audio_hosted', 'audio_daast', + 'sponsored_placement', 'responsive_creative', 'agent_placement', + ]; + const strictAjv = new Ajv({ allErrors: true, strict: false }); + addFormats(strictAjv); + strictAjv.addFormat('uri-template', true); + // Re-load schemas into a fresh AJV configured WITHOUT + // additionalProperties-tolerance, so unknown keys in params surface as errors. + loadAllSchemas(strictAjv); + const strictValidators = {}; + for (const c of STRICT_CANONICALS) { + const v = strictAjv.getSchema(`/schemas/formats/canonical/${c}.json`); + if (v) strictValidators[c] = v; + } + if (!fs.existsSync(FIXTURES_DIR)) { console.error(`${RED}ERROR:${RESET} fixtures directory not found: ${FIXTURES_DIR}`); process.exit(2); @@ -80,27 +147,50 @@ function main() { console.log('v2 Reference Fixture Validation'); console.log('================================'); - console.log(`Schema: /schemas/core/product.json`); + console.log(`Schema: /schemas/core/product.json (envelope) + per-canonical params (strict)`); console.log(`Fixtures: ${FIXTURES_DIR}`); console.log(''); let pass = 0; let fail = 0; + let strictWarnings = 0; for (const f of fixtures) { const full = path.join(FIXTURES_DIR, f); const fixture = JSON.parse(fs.readFileSync(full, 'utf8')); const valid = validate(fixture); - if (valid) { - console.log(` ${GREEN}✓${RESET} ${f}`); - pass++; - } else { - console.log(` ${RED}✗${RESET} ${f}`); - for (const err of (validate.errors || []).slice(0, 10)) { - console.log(` ${err.instancePath || '(root)'}: ${err.message}`); - } + if (!valid) { + reportErrors(f, validate.errors); fail++; + continue; } + + // Envelope-pass. Now strict-mode each format_options[i].params against its + // canonical schema. Custom format_kind is excluded — its params shape is + // governed by the seller's fetched format_schema, not by AdCP-side schema. + const fopts = fixture.format_options || []; + let strictFail = false; + for (let i = 0; i < fopts.length; i++) { + const opt = fopts[i]; + const kind = opt.format_kind; + if (kind === 'custom') continue; + const sv = strictValidators[kind]; + if (!sv) continue; + const sok = sv(opt.params); + if (!sok) { + strictFail = true; + console.log(` ${YELLOW}⚠${RESET} ${f} — format_options[${i}] (${kind}) params strict-mode warnings:`); + for (const err of sv.errors || []) { + const ip = err.instancePath || '(root)'; + console.log(` ${ip} ${err.message}`); + } + strictWarnings++; + } + } + if (!strictFail) { + console.log(` ${GREEN}✓${RESET} ${f}`); + } + pass++; } // Validate get_products response fixtures (with bundled extensions) if present @@ -122,10 +212,7 @@ function main() { console.log(` ${GREEN}✓${RESET} ${f}`); pass++; } else { - console.log(` ${RED}✗${RESET} ${f}`); - for (const err of (responseValidate.errors || []).slice(0, 10)) { - console.log(` ${err.instancePath || '(root)'}: ${err.message}`); - } + reportErrors(f, responseValidate.errors); fail++; } } @@ -134,13 +221,19 @@ function main() { } console.log(''); + if (fail === 0 && strictWarnings === 0) { + console.log(`${GREEN}✅ All ${pass} v2 reference fixtures validate (envelope + per-canonical strict).${RESET}`); + process.exit(0); + } if (fail === 0) { - console.log(`${GREEN}✅ All ${pass} v2 reference fixtures validate.${RESET}`); + console.log(`${YELLOW}⚠ ${pass} fixtures pass envelope but ${strictWarnings} flagged strict-mode params concerns. See above.${RESET}`); + // Strict warnings don't fail the build today — additionalProperties:true is + // load-bearing for platform_extensions and seller-specific narrowings. But + // surfacing them helps fixture authors catch typos before merge. process.exit(0); - } else { - console.log(`${RED}❌ ${fail} fixture(s) failed validation; ${pass} passed.${RESET}`); - process.exit(1); } + console.log(`${RED}❌ ${fail} fixture(s) failed validation; ${pass} passed.${RESET}`); + process.exit(1); } main(); diff --git a/tests/v2-negative-fixtures.test.cjs b/tests/v2-negative-fixtures.test.cjs new file mode 100644 index 0000000000..514f417c0b --- /dev/null +++ b/tests/v2-negative-fixtures.test.cjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node +/** + * v2 Negative-fixture regression tests + * + * Validates that the v2 schemas REJECT specific malformed inputs. Complements + * tests/v2-fixture-validation.test.cjs (which validates that valid fixtures + * pass) with the inverse: that the schema's `allOf` if/then/else, oneOf + * discriminators, and required/not constraints actually reject the shapes + * they're meant to reject. + * + * Filed against PR #3307 review comment R3 (composability of top-level + * discriminator + allOf if/then/else + 12-branch oneOf on + * product-format-declaration.json). Adopters and SDK authors will rely on + * the schema's negative-side behavior; without these tests, regressions to + * silent-pass on malformed declarations would slip through. + * + * Run: npm run test:v2-negative + */ + +const Ajv = require('ajv').default; +const addFormats = require('ajv-formats').default; +const fs = require('fs'); +const path = require('path'); + +const SCHEMAS_DIR = path.resolve(__dirname, '../static/schemas/source'); +const RED = '\x1b[31m'; +const GREEN = '\x1b[32m'; +const RESET = '\x1b[0m'; + +function loadAllSchemas(ajv) { + function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + continue; + } + if (!entry.name.endsWith('.json')) continue; + let schema; + try { + schema = JSON.parse(fs.readFileSync(full, 'utf8')); + } catch (e) { + console.error(`${RED}FAIL${RESET} parse error in ${full}: ${e.message}`); + process.exit(1); + } + if (!schema.$id) continue; + try { + ajv.addSchema(schema, schema.$id); + } catch (e) { + if (!/already exists/.test(e.message)) throw e; + } + } + } + walk(SCHEMAS_DIR); +} + +const ajv = new Ajv({ allErrors: true, strict: false }); +addFormats(ajv); +ajv.addFormat('uri-template', true); +loadAllSchemas(ajv); + +const SHA = 'sha256:0000000000000000000000000000000000000000000000000000000000000000'; + +const NEGATIVE_CASES = { + '/schemas/core/product-format-declaration.json': [ + { + label: 'format_kind=image rejects stray format_schema', + expected: false, + doc: { + format_kind: 'image', + params: { width: 300, height: 250 }, + format_schema: { uri: 'https://x.example/s', digest: SHA }, + }, + }, + { + label: 'format_kind=image rejects stray format_shape', + expected: false, + doc: { + format_kind: 'image', + params: { width: 300, height: 250 }, + format_shape: 'multi_placement_takeover', + }, + }, + { + label: 'format_kind=custom rejects missing format_shape', + expected: false, + doc: { + format_kind: 'custom', + params: {}, + format_schema: { uri: 'https://x.example/s', digest: SHA }, + }, + }, + { + label: 'format_kind=custom rejects missing format_schema', + expected: false, + doc: { + format_kind: 'custom', + params: {}, + format_shape: 'multi_placement_takeover', + }, + }, + { + label: 'bogus format_kind value rejected', + expected: false, + doc: { format_kind: 'banana', params: {} }, + }, + { + label: 'format_kind=custom complete (positive control)', + expected: true, + doc: { + format_kind: 'custom', + params: { foo: 'bar' }, + format_shape: 'multi_placement_takeover', + format_schema: { uri: 'https://x.example/s', digest: SHA }, + }, + }, + { + label: 'format_kind=image clean (positive control)', + expected: true, + doc: { format_kind: 'image', params: { width: 300, height: 250 } }, + }, + ], + '/schemas/creative/validate-input-result.json': [ + { + label: 'validated_pass rejects violations', + expected: false, + doc: { + target: { kind: 'canonical', id: 'image' }, + result_kind: 'validated_pass', + violations: [{ rule: 'x', field: 'y' }], + }, + }, + { + label: 'unvalidatable_nondeterministic rejects violations', + expected: false, + doc: { + target: { kind: 'product', id: 'p1' }, + result_kind: 'unvalidatable_nondeterministic', + violations: [{ rule: 'x', field: 'y' }], + }, + }, + { + label: 'old shape with `ok` boolean rejected (no `result_kind`)', + expected: false, + doc: { target: { kind: 'canonical', id: 'image' }, ok: true }, + }, + { + label: 'validated_fail with violations (positive control)', + expected: true, + doc: { + target: { kind: 'product', id: 'p1' }, + result_kind: 'validated_fail', + violations: [ + { rule: 'duration_ms_range', expected: '3000-90000', predicted: 95000, field: 'assets.video_main.duration_ms' }, + ], + }, + }, + ], +}; + +let pass = 0; +let fail = 0; + +for (const [schemaId, cases] of Object.entries(NEGATIVE_CASES)) { + const validate = ajv.getSchema(schemaId); + if (!validate) { + console.error(`${RED}FAIL${RESET} schema ${schemaId} not loaded`); + fail++; + continue; + } + console.log(`\n${schemaId}`); + for (const c of cases) { + const got = validate(c.doc); + if (got === c.expected) { + console.log(` ${GREEN}✓${RESET} ${c.label}`); + pass++; + } else { + console.log(` ${RED}✗${RESET} ${c.label} — got ${got}, want ${c.expected}`); + if (validate.errors) { + console.log(` errors: ${JSON.stringify(validate.errors.slice(0, 2))}`); + } + fail++; + } + } +} + +console.log(`\n${pass} passed, ${fail} failed`); +if (fail > 0) { + console.log(`${RED}❌ ${fail} negative-fixture regression(s)${RESET}`); + process.exit(1); +} +console.log(`${GREEN}✅ All ${pass} negative fixtures behaved as expected.${RESET}`); From d7faae4ff3194d3747c738111d31386bf727d460 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 16:06:05 +0000 Subject: [PATCH 28/41] feat(schema): add optional display_name to ProductFormatDeclaration Adds seller-controlled human-readable label field for dashboard/catalog UI presentation, per @pkras + @bokelley consensus in PR #3307 thread. Field has no machine semantics; buyer agents continue to route on format_kind and capability_id. https://claude.ai/code/session_01SNqihgM9LyXtFDXHL9QAfW --- static/schemas/source/core/product-format-declaration.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/static/schemas/source/core/product-format-declaration.json b/static/schemas/source/core/product-format-declaration.json index bd8b505ab5..706d595883 100644 --- a/static/schemas/source/core/product-format-declaration.json +++ b/static/schemas/source/core/product-format-declaration.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/product-format-declaration.json", "title": "Product Format Declaration", - "description": "Inline format declaration on a product. The `format_kind` discriminator names which canonical format the product narrows; `params` carries the canonical's parameter schema (slots, dimensions, durations, codecs, character limits, platform_extensions, tracking_extensions, etc.). Optional `capability_id` (stable identifier for routing when a product's `format_options` contains multiple declarations sharing the same `format_kind`) and `applies_to_channels` (subset of the product's declared channels this declaration applies to — lets a multi-channel product carry distinct format_options per channel). Discriminated-union shape generates clean tagged unions in TypeScript and Pydantic codegen. Replaces v1's named-format pattern (where products referenced a separately-defined format file via compound `format_id`). v1 named formats remain supported through the deprecation cycle; v2 product-bound declarations are opt-in.\n\n**Custom format_kind** (`format_kind: \"custom\"`): for adopter-defined shapes that don't fit the 11 canonicals (multi-placement takeover, roadblock, branded content, cross-screen sponsorship, sponsorship lockup, newsletter sponsorship, AR lens, playable, live event sponsorship). When `format_kind` is `custom`, the declaration MUST carry `format_shape` (recognized global pattern from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json)) AND `format_schema` (URI+digest reference to a fetchable schema describing the actual `params` and `slots`). Buyer agents fetch the schema, validate manifests structurally, and reason about manifests without per-seller integration code. See [adcp#3666](https://github.com/adcontextprotocol/adcp/issues/3666) for the canonical promotion queue.", + "description": "Inline format declaration on a product. The `format_kind` discriminator names which canonical format the product narrows; `params` carries the canonical's parameter schema (slots, dimensions, durations, codecs, character limits, platform_extensions, tracking_extensions, etc.). Optional `capability_id` (stable identifier for routing when a product's `format_options` contains multiple declarations sharing the same `format_kind`), `display_name` (seller-controlled human-readable label for dashboard and catalog UIs), and `applies_to_channels` (subset of the product's declared channels this declaration applies to — lets a multi-channel product carry distinct format_options per channel). Discriminated-union shape generates clean tagged unions in TypeScript and Pydantic codegen. Replaces v1's named-format pattern (where products referenced a separately-defined format file via compound `format_id`). v1 named formats remain supported through the deprecation cycle; v2 product-bound declarations are opt-in.\n\n**Custom format_kind** (`format_kind: \"custom\"`): for adopter-defined shapes that don't fit the 11 canonicals (multi-placement takeover, roadblock, branded content, cross-screen sponsorship, sponsorship lockup, newsletter sponsorship, AR lens, playable, live event sponsorship). When `format_kind` is `custom`, the declaration MUST carry `format_shape` (recognized global pattern from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json)) AND `format_schema` (URI+digest reference to a fetchable schema describing the actual `params` and `slots`). Buyer agents fetch the schema, validate manifests structurally, and reason about manifests without per-seller integration code. See [adcp#3666](https://github.com/adcontextprotocol/adcp/issues/3666) for the canonical promotion queue.", "type": "object", "required": ["format_kind", "params"], "discriminator": { "propertyName": "format_kind" }, @@ -11,6 +11,10 @@ "type": "string", "description": "Optional stable identifier for this format declaration. REQUIRED when the parent product's `format_options` contains multiple declarations sharing the same `format_kind` (so buyers can disambiguate which option a manifest targets via `manifest.capability_id`). Recommended for any declaration that may be referenced by capability_id over time. Format-internal (not a URI). Examples: 'flashtalking_image_300x250', 'pmax_responsive_search'." }, + "display_name": { + "type": "string", + "description": "Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `capability_id`. Has no machine semantics — buyer agents route on `format_kind` and `capability_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." + }, "applies_to_channels": { "type": "array", "items": { "$ref": "/schemas/enums/channels.json" }, @@ -223,6 +227,7 @@ "digest": "sha256:e1d4f6a9c2b5e8d1f4a7c0b3e6d9f2a5c8b1e4d7f0a3c6b9e2d5f8a1c4b7e0a3" }, "capability_id": "nytimes_homepage_takeover_premium", + "display_name": "Homepage Takeover — Premium Sponsorship", "applies_to_channels": ["display", "olv"], "params": { "components": [ From 4bbce589365060d633a9ffc960e7749228b3762f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 3 May 2026 13:42:42 -0400 Subject: [PATCH 29/41] =?UTF-8?q?docs(creative):=20drop=20per-canonical=20?= =?UTF-8?q?status=20differentiation=20pre-GA=20=E2=80=94=20every=20canonic?= =?UTF-8?q?al=20is=20preview=20while=20v2=20is=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the meta-level concern surfaced by nastassiafulconis's review (top-level body of PRR_kwDOPPPQu876xAr-): the 3 surface-composed canonicals capture the creative envelope but miss the actual product (amazon_sponsored_products has no negatives / match types / bid mods, PMax collapses 3 surfaces into one). Her recommendation was to treat those as rfc-grade until 2 retail-media nets ship format_schema artifacts. The right resolution is meta, not per-canonical: v2 itself is in preview through the 3.1 beta cycle, so claiming any canonical is `stable` right now is a category confusion. Stable-within-preview is incoherent — nothing in v2 is locked yet. Concrete changes: - canonical _base.json `status` field default changed from `stable` to `preview`. Description rewritten to explain the two phases: (1) while v2 is in preview, every canonical is preview by default — the field has no per-canonical differentiation; (2) at 3.1 GA, the working group promotes individual canonicals to stable based on adopter evidence (2+ adopters, 90 days stable params, defined tracking model — same rubric). - agent_placement and responsive_creative had per-canonical "Stability: preview" carve-outs in their description and status-default-preview overrides in their properties. Both removed — they're no longer special; every canonical is preview until adopter validation at GA promotes specific ones. - v2-overview.mdx 11-canonicals table: dropped the Status column (was `stable` / `preview` differentiated). All 11 canonicals are now in one column with no per-canonical status visible. - v2-overview.mdx replaced the "Stabilization rubric for preview canonicals" paragraph with a "Status: every canonical is preview while v2 is preview" subsection that frames the two phases (preview-window vs at-GA) and lists the 9 IAB-anchored canonicals as expected-to-clear-the-rubric-quickly vs the 3 surface-composed ones (responsive_creative, agent_placement, sponsored_placement) as needing adopter-shipped format_schema evidence before promotion. - v2-overview.mdx "Two stability axes" subsection collapsed into a smaller "runtime_status on each product declaration — separate axis from canonical status" subsection. The runtime_status field is still per-product per-declaration; just less prose ceremony around the canonical-vs-runtime distinction now that the canonical axis is uniform pre-GA. - glossary entry for status reframed accordingly. nastassiafulconis's sponsored_placement concern is now structurally addressed: it's preview like the others, won't promote to stable at GA without 2 retail-media nets shipping format_schema. No special- case demotion needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/creative/v2-overview.mdx | 57 ++++++++----------- .../source/formats/canonical/_base.json | 4 +- .../formats/canonical/agent_placement.json | 5 +- .../canonical/responsive_creative.json | 5 +- 4 files changed, 29 insertions(+), 42 deletions(-) diff --git a/docs/creative/v2-overview.mdx b/docs/creative/v2-overview.mdx index 47b9d31858..af0a826923 100644 --- a/docs/creative/v2-overview.mdx +++ b/docs/creative/v2-overview.mdx @@ -30,7 +30,7 @@ v2 collapses today's separate format registry into product-bound declarations. A | **`provenance_required`** | When true, the product rejects unsigned synthesized assets. Builders attach C2PA-compatible provenance manifests. | | **`platform_extensions`** | URI+digest references to platform-specific extensions narrowing the canonical (pixel ID shapes, conversion event taxonomies). | | **`tracking_extensions`** | Subset of `platform_extensions` specifically scoped to tracking concerns (pixel IDs, viewability vendors, OM-SDK partners). | -| **`status: preview`** | Canonical is shipped for early adoption but parameter shape MAY break in 3.2 once 2-3 adopters land. | +| **`status` on canonicals** | Spec-maturity axis (`stable` / `preview` / `deprecated`). All canonicals are `preview` while v2 itself is in preview; per-canonical promotion to `stable` happens at 3.1 GA based on adopter evidence. | | **`since_version` / `migration_target_version`** | Release-precision lifecycle metadata on canonicals — when introduced, when stabilization or breaking revision is expected. | | **`validate_input`** | Spec-defined dry-run primitive — buyers verify a manifest against canonicals/products without committing to a render. | | **`build_creative`** | Creative-agent surface that produces a manifest from inputs (brief, scenes, brand). Sales agents do NOT expose `build_creative`. | @@ -58,44 +58,37 @@ v2 collapses today's separate format registry into product-bound declarations. A Each canonical lives at `/schemas/formats/canonical/.json`. Tracking model is **format-specific** (split by tracking model is why we have 11 instead of, say, 5). -| Canonical | Status | What it is | Tracking | -|---|---|---|---| -| `image` | stable | Static image, file or hosted URL redirect | Impression pixel + click URL via `universal_macros` | -| `html5` | stable | Interactive HTML5 banner (zip asset) | MRAID + OM-SDK + click-tag macro + backup image | -| `display_tag` | stable | Third-party JS/iframe tag URL | Opaque to seller | -| `image_carousel` | stable | Multi-card swipe (polymorphic image/video items) | Per-card pixels + carousel engagement | -| `video_hosted` | stable | Direct video file, orientation parameter | OM-SDK + external impression/click/quartile trackers | -| `video_vast` | stable | VAST tag (URL or inline XML), VAST 2-4.x | Inherent VAST events | -| `audio_hosted` | stable | Direct audio file (or host-read produced via build_creative) | Standard audio impression/completion | -| `audio_daast` | stable | DAAST tag | Inherent DAAST events | -| `sponsored_placement` | stable | Retail-media catalog-driven (Amazon SP, Criteo SP, CitrusAd SP) | Per-item catalog-keyed events | -| `responsive_creative` | **preview** | Buyer asset pool, surface composes combinations (Google Responsive Display/Search Ads, Performance Max, Demand Gen; Meta Advantage+ creative) | Per-asset performance breakdown | -| `agent_placement` | **preview** | Sponsored placement composed by an AI surface in response to a user query (ChatGPT, Perplexity, voice assistants, sponsored search snippets). Distinct from `si_chat` (brand-owned conversation; user → brand's agent). | Mention-level impression + attribution | - -The two `preview` canonicals (`responsive_creative`, `agent_placement`) carry surfaces whose composition models are still settling — Google PMax / Meta Advantage+ for responsive; ChatGPT / Perplexity / voice assistants for agent_placement. Their parameter shape and tracking model MAY break in 3.2 once 2-3 adopters have built against them. Buyers SHOULD plan for migration; sellers SHOULD treat preview-canonical narrowing as experimental contract surface, not a long-term commitment. The other 9 canonicals are anchored in stable IAB / platform standards (IAB display dimensions, IAB VAST 4.2, IAB DAAST 1.1, retail-media catalog conventions) and are committed. - -**Stabilization rubric for preview canonicals.** A preview canonical is promoted to `stable` when (a) at least 2 production adopters have built against it AND (b) 90 consecutive days have passed without a breaking change to its parameter shape. The default escalation date is the next minor release after both conditions are met — i.e., `responsive_creative` and `agent_placement` re-evaluated for stable status by 3.3 if adopters land in 3.1-3.2. To avoid the coordination problem where `preview` reads as "don't build against this" and therefore never stabilizes, sellers shipping preview canonicals SHOULD also publish a `migration_target_version` (carried on the canonical's `_base.json` `migration_target_version` field) so adopters know when to expect either stabilization or a breaking revision. - -### Two stability axes: canonical `status` vs declaration `runtime_status` +| Canonical | What it is | Tracking | +|---|---|---| +| `image` | Static image, file or hosted URL redirect | Impression pixel + click URL via `universal_macros` | +| `html5` | Interactive HTML5 banner (zip asset) | MRAID + OM-SDK + click-tag macro + backup image | +| `display_tag` | Third-party JS/iframe tag URL | Opaque to seller | +| `image_carousel` | Multi-card swipe (polymorphic image/video items) | Per-card pixels + carousel engagement | +| `video_hosted` | Direct video file, orientation parameter | OM-SDK + external impression/click/quartile trackers | +| `video_vast` | VAST tag (URL or inline XML), VAST 2-4.x | Inherent VAST events | +| `audio_hosted` | Direct audio file (or host-read produced via build_creative) | Standard audio impression/completion | +| `audio_daast` | DAAST tag | Inherent DAAST events | +| `sponsored_placement` | Retail-media catalog-driven (Amazon SP, Criteo SP, CitrusAd SP) | Per-item catalog-keyed events | +| `responsive_creative` | Buyer asset pool, surface composes combinations (Google Responsive Display/Search Ads, Performance Max, Demand Gen; Meta Advantage+ creative) | Per-asset performance breakdown | +| `agent_placement` | Sponsored placement composed by an AI surface in response to a user query (ChatGPT, Perplexity, voice assistants, sponsored search snippets). Distinct from `si_chat` (brand-owned conversation; user → brand's agent). | Mention-level impression + attribution | -Stability splits across two independent axes — the spec-maturity axis on the canonical, and the adopter-runtime axis on each product-format declaration: +### Status: every canonical is preview while v2 is preview -| Axis | Field location | Question it answers | -|---|---|---| -| **Spec maturity** | `params.status` on the canonical (`/schemas/formats/canonical/.json`) | "Has the v2 working group stabilized this format definition?" Values: `stable` / `preview` / `deprecated`. | -| **Adopter runtime** | `runtime_status` on the `ProductFormatDeclaration` (per-product) | "Does THIS seller's runtime actually honor what they declared on THIS product?" Values: `stable` / `preview` / `declared_only`. | +v2 itself is in preview through the 3.1 beta cycle. Until 3.1 GA ships, **every canonical is implicitly preview** — nothing is locked. The `status` field on each canonical schema (`stable | preview | deprecated`) defaults to `preview` during this window; per-canonical differentiation kicks in at GA, not before. Adopters building against the preview branch should treat all canonicals as experimental contract surface that MAY break before GA. -The two vary independently. A `stable` canonical can have `declared_only` adopters (the spec is settled but the seller hasn't wired the runtime path yet — common during v2 migration when adopters port v1 catalog declarations forward but their adapters still implement only the asset-upload path). A `preview` canonical can have `stable` adopters (a seller built a real working runtime against the preview shape and their integration honors what they declared). +**At 3.1 GA**, the working group promotes individual canonicals from `preview` to `stable` based on adopter evidence. The promotion rubric: a canonical promotes to `stable` when (a) at least 2 production adopters have built against it AND (b) 90 consecutive days have passed without a breaking change to its parameter shape. Canonicals lacking adopter validation stay `preview` past GA and re-evaluate on each subsequent minor release. The 9 IAB-anchored canonicals (image, html5, display_tag, image_carousel, video_hosted, video_vast, audio_hosted, audio_daast, sponsored_placement) are expected to clear the rubric quickly because they sit on top of established industry standards. The 3 surface-composed canonicals (responsive_creative, agent_placement, and arguably sponsored_placement given the gaps around match types / negatives / bid mods on retail-media surfaces) need adopter-shipped `format_schema` evidence before promotion. Sellers shipping any canonical SHOULD declare `migration_target_version` so adopters know when to expect either stabilization or a breaking revision. -Why both exist: without `runtime_status`, sellers in mid-migration silently lie about what they support. They declare the shiny new production-source axis (`item_production_model: seller_pre_rendered_from_brief`) on a forward-looking product, but the actual `sync_creatives` runtime is still a buyer-uploaded-bytes loop. Buyers discover the mismatch only at submission time — exactly what v2's canonical-as-contract is supposed to prevent. +### `runtime_status` on each product declaration — separate axis from canonical `status` -The rule of thumb: +`status` on the canonical describes whether the working group has locked the spec definition (currently `preview` for all canonicals; per-canonical promotion at GA per the rubric above). `runtime_status` on each `ProductFormatDeclaration` is a separate axis describing whether THIS seller's runtime actually honors what they declared on THIS product. The two vary independently: a `preview` canonical can have a seller whose runtime fully honors the preview shape (`runtime_status: stable`); a future `stable` canonical can have sellers who declared aspirationally without wiring the runtime yet (`runtime_status: declared_only`). -- Default `stable` (or omit the field). The runtime honors what's declared. -- `preview` — the basic path works; advanced axes (per-item fan-out, brief-driven overrides, advanced `platform_extensions`) may be partial. Buyers should `validate_input` before committing. -- `declared_only` — forward-looking declaration. Runtime not wired yet. Buyers MUST treat as informational; compliance storyboards skip-gate gracefully rather than fail; budget should not flow until upgraded to `preview` or `stable`. +| `runtime_status` value | What it signals | Buyer action | +|---|---|---| +| `stable` (default, or omitted) | Adopter's runtime fully honors the declared format + production source. | Treat the declaration as a serving contract. | +| `preview` | Basic path works; advanced axes (per-item fan-out, brief-driven overrides, advanced `platform_extensions`) may be partial. | `validate_input` or sandbox before committing budget. | +| `declared_only` | Catalog declaration is forward-looking; runtime does NOT yet implement this path. | Treat as informational. Confirm via `validate_input` or a sandbox storyboard before purchase. Compliance storyboards SHOULD skip-gate `declared_only` entries gracefully rather than failing. | -Sellers MUST upgrade the value as the runtime catches up. Buyers cache it like any other capability field; subsequent `get_products` responses surface the new value naturally. +Why this axis exists: without `runtime_status`, sellers in mid-migration silently lie about what they support. They declare the shiny new production-source axis (`item_production_model: seller_pre_rendered_from_brief`) on a forward-looking product, but the actual `sync_creatives` runtime is still a buyer-uploaded-bytes loop. Buyers discover the mismatch only at submission time — exactly what v2's canonical-as-contract is supposed to prevent. Sellers MUST upgrade the value as the runtime catches up; buyers cache it like any other capability field. ## Two axes: composition (per-impression) vs production (who renders) diff --git a/static/schemas/source/formats/canonical/_base.json b/static/schemas/source/formats/canonical/_base.json index e7c479466a..520dd0fccf 100644 --- a/static/schemas/source/formats/canonical/_base.json +++ b/static/schemas/source/formats/canonical/_base.json @@ -8,8 +8,8 @@ "status": { "type": "string", "enum": ["stable", "preview", "deprecated"], - "default": "stable", - "description": "Stability tier for this canonical format. `stable` (default): the schema and tracking model are committed and any breaking changes go through normal major-version deprecation. `preview`: shipped for early adoption but the parameter shape and tracking model MAY break in a subsequent minor release once 2-3 adopters have built against it; buyers should treat preview canonicals as experimental and plan for migration. **Stabilization rubric**: a preview canonical is promoted to `stable` once (a) at least 2 production adopters have built against it AND (b) 90 consecutive days have passed without a breaking change to its parameter shape. Each preview canonical also carries a `migration_target_version` indicating the version by which the working group expects to either stabilize it or surface a breaking revision. `deprecated`: replacement is available; existing adopters supported through the deprecation cycle but new adoption is discouraged. Producers SHOULD include this field on `preview` and `deprecated` canonicals; absence is interpreted as `stable`." + "default": "preview", + "description": "Stability tier for this canonical format.\n\n**While v2 itself is in preview** (PR #3307 / 3.1 beta cycle, current state): every canonical defaults to `preview`. The status field has no per-canonical differentiation during the preview window — nothing in v2 is locked yet, so claiming any canonical is `stable` would be premature. The field exists for future use after 3.1 GA, when the working group promotes individual canonicals to `stable` based on adopter evidence.\n\n**At 3.1 GA and after**: `stable` means the schema and tracking model are committed; breaking changes go through normal major-version deprecation. `preview` means the parameter shape and tracking model MAY break in a subsequent minor release once 2-3 adopters have built against it; buyers should treat preview canonicals as experimental and plan for migration. `deprecated` means a replacement is available; existing adopters supported through the deprecation cycle but new adoption is discouraged.\n\n**Per-canonical promotion at GA** uses an evidence-based rubric: a canonical promotes from preview to stable when (a) at least 2 production adopters have built against it AND (b) 90 consecutive days have passed without a breaking change to its parameter shape. Pair with `migration_target_version` to indicate when the working group expects to either stabilize or surface a breaking revision." }, "since_version": { "type": "string", diff --git a/static/schemas/source/formats/canonical/agent_placement.json b/static/schemas/source/formats/canonical/agent_placement.json index 4d3ada6fcf..e0697791b1 100644 --- a/static/schemas/source/formats/canonical/agent_placement.json +++ b/static/schemas/source/formats/canonical/agent_placement.json @@ -2,12 +2,9 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/formats/canonical/agent_placement.json", "title": "Canonical Format: Agent Placement (AI-surface sponsored placement)", - "description": "Sponsored placement integrated into an AI-surface's response to a user. Buyer supplies a `BrandRef` (resolving brand.json for context), an optional `offering_ref` to focus the mention on a specific offering, and an optional `landing_page_url` the surface MAY attach as a citation. The surface (LLM, voice assistant, sponsored-search ranker) composes a natural-language mention, sponsored card, or audio snippet within its response to a user query. **Composition is algorithmic** — the agent chooses phrasing and presentation. Output asset_type varies by surface: `text` for chat UIs and sponsored search snippets; `audio` (synthesized) for voice assistants; `card` for structured AI-surface result cards. Tracking model: mention-level impression + attribution events; per-mention id keys back to brand and offering. Distinct from `si_chat` (which is the user-converses-with-brand's-agent pattern — brand owns the conversational surface) and from `sponsored_placement` (retail-media catalog-driven). Parallels `sponsored_placement` structurally: both are surface-composed placements; agent_placement is for AI/agentic surfaces, sponsored_placement is for retail media.\n\n**Stability:** preview. The parameter shape and tracking model are still settling as ChatGPT, Perplexity, Gemini, and voice-assistant surfaces ship the first agent_placement integrations. Expect schema breakage in 3.2 once 2-3 adopters have built against this canonical and the surface composition model converges.", + "description": "Sponsored placement integrated into an AI-surface's response to a user. Buyer supplies a `BrandRef` (resolving brand.json for context), an optional `offering_ref` to focus the mention on a specific offering, and an optional `landing_page_url` the surface MAY attach as a citation. The surface (LLM, voice assistant, sponsored-search ranker) composes a natural-language mention, sponsored card, or audio snippet within its response to a user query. **Composition is algorithmic** — the agent chooses phrasing and presentation. Output asset_type varies by surface: `text` for chat UIs and sponsored search snippets; `audio` (synthesized) for voice assistants; `card` for structured AI-surface result cards. Tracking model: mention-level impression + attribution events; per-mention id keys back to brand and offering. Distinct from `si_chat` (which is the user-converses-with-brand's-agent pattern — brand owns the conversational surface) and from `sponsored_placement` (retail-media catalog-driven). Parallels `sponsored_placement` structurally: both are surface-composed placements; agent_placement is for AI/agentic surfaces, sponsored_placement is for retail media.", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { - "status": { - "default": "preview" - }, "slots": { "default": [ { "asset_group_id": "offering_ref", "asset_type": "text", "required": false }, diff --git a/static/schemas/source/formats/canonical/responsive_creative.json b/static/schemas/source/formats/canonical/responsive_creative.json index 8c1f29a2ea..6204e8c2af 100644 --- a/static/schemas/source/formats/canonical/responsive_creative.json +++ b/static/schemas/source/formats/canonical/responsive_creative.json @@ -2,12 +2,9 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/formats/canonical/responsive_creative.json", "title": "Canonical Format: Responsive Creative", - "description": "Buyer supplies a pool of typed assets (multiple headlines, descriptions, images, videos, logos); the surface algorithmically composes combinations per placement. **Composition is algorithmic** — surface picks combinations and reports per-asset performance breakdowns. Covers Google Responsive Display Ads (RDA), Responsive Search Ads (RSA), Performance Max (PMax), Demand Gen, and Meta Advantage+ creative. Industry term: \"Responsive\" (Google) / \"Advantage+ creative\" (Meta) / \"Dynamic Creative\" (older Meta term). Distinct from `sponsored_placement` (catalog-driven, deterministic) and `agent_placement` (AI-surface composition). The structured `slots` field below enumerates expected canonical asset_group_id slots; per-slot count/length narrowing lives in flat parameters (`headlines_min`, `headline_max_chars`, etc.).\n\n**Stability:** preview. Slot vocabulary and per-slot count limits track Google PMax and Meta Advantage+ surfaces, both of which still ship slot/count/policy changes regularly. Expect schema breakage in 3.2 once 2-3 adopters have built against this canonical and the slot vocabulary stabilizes across surfaces.", + "description": "Buyer supplies a pool of typed assets (multiple headlines, descriptions, images, videos, logos); the surface algorithmically composes combinations per placement. **Composition is algorithmic** — surface picks combinations and reports per-asset performance breakdowns. Covers Google Responsive Display Ads (RDA), Responsive Search Ads (RSA), Performance Max (PMax), Demand Gen, and Meta Advantage+ creative. Industry term: \"Responsive\" (Google) / \"Advantage+ creative\" (Meta) / \"Dynamic Creative\" (older Meta term). Distinct from `sponsored_placement` (catalog-driven, deterministic) and `agent_placement` (AI-surface composition). The structured `slots` field below enumerates expected canonical asset_group_id slots; per-slot count/length narrowing lives in flat parameters (`headlines_min`, `headline_max_chars`, etc.).", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { - "status": { - "default": "preview" - }, "slots": { "default": [ { "asset_group_id": "headlines", "asset_type": "text", "required": true, "min": 3, "max": 15 }, From 90ea4bc01a555e2d9cd72d04dcef3f6ab6a53d0a Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 16 May 2026 06:12:05 -0400 Subject: [PATCH 30/41] =?UTF-8?q?refactor(creative):=20rename=20v2=20?= =?UTF-8?q?=E2=86=92=20canonical-formats=20throughout=20(path=20+=20script?= =?UTF-8?q?=20+=20reference=20cleanup)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the naming collision between "v2 creative formats" (this work) and "AdCP v2.x protocol" (legacy protocol version). The sub-system version literal "v2" inside file paths and test scripts read as conflicting with AdCP-the-protocol's own version numbering, especially inside `/schemas/3.1.0/...` where the path "v2" inside a 3.1 directory tree is genuinely confusing. Renames: - docs/creative/v2-overview.mdx → docs/creative/canonical-formats.mdx - docs/creative/v2-migration.mdx → docs/creative/canonical-formats-migration.mdx - static/examples/products/v2/ → static/examples/products/canonical/ - static/examples/get_products_responses/v2/ → static/examples/get_products_responses/canonical/ - tests/v2-fixture-validation.test.cjs → tests/canonical-fixture-validation.test.cjs - tests/v2-negative-fixtures.test.cjs → tests/canonical-negative-fixtures.test.cjs - .changeset/v2-review-feedback-format-options.md → .changeset/canonical-formats-review-feedback.md Internal references updated: - package.json: test:v2-fixtures → test:canonical-fixtures; test:v2-negative → test:canonical-negative - All 6 cross-doc preview banners (asset-types, formats, generative- creative, implementing-creative-agents, key-concepts, specification) updated to point at /docs/creative/canonical-formats and call out "Canonical-formats preview (3.1)" instead of "v2 preview" - Test file headers, log lines, success messages: "v2" → "canonical- formats" where it refers to the path; "v1" untouched where it refers to legacy named-format model - Doc titles: "Creative Formats v2 (preview)" → "Canonical Formats (preview)"; "Creative Formats v1 → v2 Migration" → "Migration to Canonical Formats" - Changeset commit subject + body: same treatment Narrative contrast preserved: schema descriptions that say "v1 path uses format_ids; v2 path uses format_options" are KEPT — that v1↔v2 distinction is a useful disambiguator for the two product-side format- authoring paths. Naming note added at the top of canonical-formats.mdx, canonical-formats-migration.mdx, and the changeset explaining the rename and why the v1↔v2 narrative shorthand still appears in schema descriptions. Validation: all 4 test suites pass under the new names — test:schemas, test:examples, test:canonical-fixtures (13 fixtures), test:canonical-negative (11 fixtures). Branch name kept; rename was scoped to file paths, scripts, doc titles, and prose where the path literal "v2" created the collision. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...d => canonical-formats-review-feedback.md} | 12 ++++++---- docs/creative/asset-types.mdx | 2 +- ...on.mdx => canonical-formats-migration.mdx} | 24 ++++++++++--------- ...{v2-overview.mdx => canonical-formats.mdx} | 18 +++++++------- docs/creative/formats.mdx | 2 +- docs/creative/generative-creative.mdx | 2 +- .../creative/implementing-creative-agents.mdx | 2 +- docs/creative/key-concepts.mdx | 2 +- docs/creative/specification.mdx | 2 +- package.json | 4 ++-- .../meta_with_bundled_extensions.json | 0 .../amazon_sponsored_products.json | 0 .../chatgpt_brand_mention.json | 0 .../{v2 => canonical}/gam_3p_display_tag.json | 0 .../google_performance_max.json | 0 .../{v2 => canonical}/meta_carousel.json | 0 .../{v2 => canonical}/meta_reels_us.json | 0 .../nytimes_homepage_html5.json | 0 .../nytimes_homepage_mrec.json | 0 .../the_daily_30s_host_read.json | 0 .../triton_daast_audio_30s.json | 0 .../veo_generative_video_15s.json | 0 .../youtube_vast_preroll.json | 0 ... => canonical-fixture-validation.test.cjs} | 14 +++++------ ...s => canonical-negative-fixtures.test.cjs} | 12 +++++----- 25 files changed, 51 insertions(+), 45 deletions(-) rename .changeset/{v2-review-feedback-format-options.md => canonical-formats-review-feedback.md} (88%) rename docs/creative/{v2-migration.mdx => canonical-formats-migration.mdx} (96%) rename docs/creative/{v2-overview.mdx => canonical-formats.mdx} (96%) rename static/examples/get_products_responses/{v2 => canonical}/meta_with_bundled_extensions.json (100%) rename static/examples/products/{v2 => canonical}/amazon_sponsored_products.json (100%) rename static/examples/products/{v2 => canonical}/chatgpt_brand_mention.json (100%) rename static/examples/products/{v2 => canonical}/gam_3p_display_tag.json (100%) rename static/examples/products/{v2 => canonical}/google_performance_max.json (100%) rename static/examples/products/{v2 => canonical}/meta_carousel.json (100%) rename static/examples/products/{v2 => canonical}/meta_reels_us.json (100%) rename static/examples/products/{v2 => canonical}/nytimes_homepage_html5.json (100%) rename static/examples/products/{v2 => canonical}/nytimes_homepage_mrec.json (100%) rename static/examples/products/{v2 => canonical}/the_daily_30s_host_read.json (100%) rename static/examples/products/{v2 => canonical}/triton_daast_audio_30s.json (100%) rename static/examples/products/{v2 => canonical}/veo_generative_video_15s.json (100%) rename static/examples/products/{v2 => canonical}/youtube_vast_preroll.json (100%) rename tests/{v2-fixture-validation.test.cjs => canonical-fixture-validation.test.cjs} (95%) rename tests/{v2-negative-fixtures.test.cjs => canonical-negative-fixtures.test.cjs} (92%) diff --git a/.changeset/v2-review-feedback-format-options.md b/.changeset/canonical-formats-review-feedback.md similarity index 88% rename from .changeset/v2-review-feedback-format-options.md rename to .changeset/canonical-formats-review-feedback.md index 0bb87c4a02..a3cbfa1f79 100644 --- a/.changeset/v2-review-feedback-format-options.md +++ b/.changeset/canonical-formats-review-feedback.md @@ -2,9 +2,11 @@ "adcontextprotocol": minor --- -feat(creative): v2 review-feedback round — `format_options` array, canonical `status`, hosting paragraph, third-party creative-agent worked example +feat(creative): canonical-formats review-feedback round — `format_options` array, canonical `status`, hosting paragraph, third-party creative-agent worked example -Addresses external review feedback on RFC #3305 / PR #3307 before the 3.1.0 beta cycle opens. +Addresses external review feedback on RFC #3305 / PR #3307 (canonical formats) before the 3.1.0 beta cycle opens. + +> *Naming note*: This work was drafted as "v2 creative formats" before adoption surfaced the collision with AdCP-the-protocol's version numbering. File paths and surface descriptions now use **canonical formats** terminology; the v1↔v2 narrative contrast still appears in schema descriptions where it disambiguates the two format-authoring paths (`format_ids` legacy vs `format_options` canonical formats). **Schema changes:** @@ -20,7 +22,7 @@ Addresses external review feedback on RFC #3305 / PR #3307 before the 3.1.0 beta **Schema housekeeping:** - Added a description note on `validate-input-response.json` documenting the intent behind the 3-schema split (`request` / `response` / `result`): the `Result` type is split for planned reuse by adjacent async-validation surfaces (per-batch result envelopes on `build_creative` async paths, asynchronous canonical-against-product validation in `sync_creatives`). Producers that only need the synchronous batch shape today MAY treat the split as YAGNI; the schema reuse anchors the violation/retry shape so downstream surfaces don't drift. -- Updated all 12 v2 reference fixtures (`static/examples/products/v2/*.json`) plus the `meta_with_bundled_extensions.json` get_products response fixture to use the new `format_options` array shape. All 13 fixtures still validate via `npm run test:v2-fixtures`. +- Updated all 12 canonical-formats reference fixtures (`static/examples/products/canonical/*.json`) plus the `meta_with_bundled_extensions.json` get_products response fixture to use the new `format_options` array shape. All 13 fixtures still validate via `npm run test:canonical-fixtures`. - Updated `tests/schema-validation.test.cjs` core-required-fields rule to assert `format_options` (not `format`) on the v2 oneOf branch. **Why minor:** structural rename of `product.format` → `product.format_options` is technically breaking for anyone who built against the v2 path during the preview window, but the v2 path was only landed in this PR (#3307) and is not yet released — no published 3.x version carries `format`. The shipping shape is `format_options`. Anyone building against the preview branch should re-pull. The other changes are additive. @@ -41,7 +43,7 @@ Schema fixes: - Veo fixture used `audio_source` / `buyer_audio_acceptance` on a `video_hosted` format. Renamed to `video_source` / `buyer_video_acceptance`. Doc additions: -- v2-overview.mdx glossary covering ~25 v2 terms. +- canonical-formats.mdx glossary covering ~25 canonical-formats terms. - Asset group vocabulary table (was previously only in the JSON schema). - "Two axes" section refined to show the unified 5-value source enum. - Tracker assembly under seller-rendered sources documented (macro-substituted vs sync-creatives tracker block). @@ -79,6 +81,6 @@ This change adds: These are informational fields, not the binding contract — the format's `slots` declaration is the contract. The `*_source` fields let buyers pick products whose production model fits their workflow (in-house pre-rendered vs upstream creative agent vs seller-driven generative). -The v2-overview.mdx narrative now explicitly differentiates the two orthogonal axes — `composition_model` (how the surface composes per-impression: deterministic vs algorithmic) and per-canonical production source (who renders, and when). Conflating them was the gap that left generative DSPs without a clean expression in v2. +The canonical-formats.mdx narrative now explicitly differentiates the two orthogonal axes — `composition_model` (how the surface composes per-impression: deterministic vs algorithmic) and per-canonical production source (who renders, and when). Conflating them was the gap that left generative DSPs without a clean expression in the canonical-formats path. Tracks #3305 (v2 RFC) and #3307 (preview branch). diff --git a/docs/creative/asset-types.mdx b/docs/creative/asset-types.mdx index a5c0d52c8c..cc28753191 100644 --- a/docs/creative/asset-types.mdx +++ b/docs/creative/asset-types.mdx @@ -4,7 +4,7 @@ description: "AdCP asset types define standardized properties for images, video, "og:title": "AdCP — Asset Types" --- -> **v2 readers**: this page describes asset types and their payload shapes — the same in v1 and v2. For how assets map to v2 format slots via `asset_group_id` (canonical vocabulary), see [v2-overview](/docs/creative/v2-overview) and [v2-migration](/docs/creative/v2-migration). v1 uses `asset_id` + `asset_role`; v2 uses `asset_group_id` referencing the canonical vocabulary registry. Both paths use the same asset payload schemas — only the slot-key vocabulary differs. +> **Canonical-formats readers**: this page describes asset types and their payload shapes — the same in v1 and the canonical-formats path. For how assets map to canonical-format slots via `asset_group_id`, see [canonical-formats](/docs/creative/canonical-formats) and the [migration guide](/docs/creative/canonical-formats-migration). v1 uses `asset_id` + `asset_role`; canonical formats use `asset_group_id` referencing the canonical vocabulary registry. Both paths use the same asset payload schemas — only the slot-key vocabulary differs. Creative formats in AdCP use standardized asset types with well-defined properties. Assets are the discrete, typed building blocks used by formats to define requirements and by manifests to supply concrete values. diff --git a/docs/creative/v2-migration.mdx b/docs/creative/canonical-formats-migration.mdx similarity index 96% rename from docs/creative/v2-migration.mdx rename to docs/creative/canonical-formats-migration.mdx index bf0764d0a7..4a6728f972 100644 --- a/docs/creative/v2-migration.mdx +++ b/docs/creative/canonical-formats-migration.mdx @@ -1,15 +1,17 @@ --- -title: Creative Formats v1 → v2 Migration -description: "Concrete migration paths for sellers, creative agents, buyers, and tooling moving from v1 named formats to v2 product-bound declarations." -"og:title": "AdCP — Creative Formats v1 → v2 Migration" +title: Migration to Canonical Formats +description: "Concrete migration paths for sellers, creative agents, buyers, and tooling moving from v1 named formats to canonical-format product-bound declarations." +"og:title": "AdCP — Canonical Formats Migration" testable: true --- -# Migration: v1 → v2 Creative Formats +# Migration: v1 named formats → Canonical Formats -This guide walks through the shift from v1 named formats (`format_id` as `{ agent_url, id }` referencing a separately-defined format file) to v2 product-bound declarations introduced by [RFC #3305](https://github.com/adcontextprotocol/adcp/issues/3305). v1 named formats remain a first-class path through 4.x; v2 is the new path, opt-in indefinitely. +This guide walks through the shift from v1 named formats (`format_id` as `{ agent_url, id }` referencing a separately-defined format file) to canonical-format product-bound declarations introduced by [RFC #3305](https://github.com/adcontextprotocol/adcp/issues/3305). v1 named formats remain a first-class path through 4.x; canonical formats are the new path, opt-in indefinitely. -For the architecture, read [`v2-overview`](/docs/creative/v2-overview) first. This page is just the migration mechanics. +For the architecture, read [`canonical-formats`](/docs/creative/canonical-formats) first. This page is just the migration mechanics. + +> *Naming note*: The v1↔v2 terminology used throughout this page describes the two format-authoring models — `format_ids` (legacy, v1) vs `format_options` (canonical formats, v2). It does NOT refer to AdCP-the-protocol's version (currently 3.x). The "v2" path is also called the canonical-formats path; both phrasings mean the same thing. ## What stays unchanged @@ -99,7 +101,7 @@ Most of AdCP doesn't change. v2 builds on the existing primitives: **Dual emission during the migration window**: Products MAY carry `format_ids`, `format_options`, or BOTH; at least one is required (the schema enforces this via an `anyOf`, not `oneOf`). The recommended seller pattern is to author once and let the SDK project to both wire shapes via the [v1↔v2 canonical mapping registry](https://adcontextprotocol.org/schemas/v3/registries/v1-canonical-mapping.json), so every buyer reads what it knows. When both shapes are present on a product, the two MUST refer to the same underlying format declaration — the `format_options[i]` must narrow the canonical that `format_ids[i]` resolves to via the registry. SDKs that derive both shapes from one source guarantee this invariant; SDKs that hand-author both MUST treat divergence as a build error and refuse to emit. Buyers prefer `format_options` when both are present; treat `format_ids` as fallback for v1-only buyers. Sellers whose v1 named formats have no clean v2 projection ship `format_ids` only for those products until they add an explicit `canonical` declaration on the v1 format (see "v1 → v2 canonical mapping" below) — the SDK MUST NOT emit `format_options` for non-projectable formats. -For 12 fully-validated reference Product fixtures spanning all 11 canonical formats — Meta Reels (`video_hosted` vertical), IAB MREC (`image` 300×250), NYTimes HTML5 (`html5`), GAM 3P display tag (`display_tag`), Meta Carousel (`image_carousel`), YouTube VAST pre-roll (`video_vast`), podcast 30s host-read (`audio_hosted`), Triton DAAST audio (`audio_daast`), Amazon Sponsored Products (`sponsored_placement`), Google PMax (`responsive_creative`), ChatGPT brand mention (`agent_placement`), Veo 15s generative video (`video_hosted` with `synthesis_nondeterministic` + `provenance_required`) — plus 1 `get_products` response fixture exercising bundled extensions, see `static/examples/products/v2/` and `static/examples/get_products_responses/v2/`. The Veo fixture exercises `synthesis_nondeterministic: true` and `provenance_required: true`. Each fixture passes `npm run test:v2-fixtures`. +For 12 fully-validated reference Product fixtures spanning all 11 canonical formats — Meta Reels (`video_hosted` vertical), IAB MREC (`image` 300×250), NYTimes HTML5 (`html5`), GAM 3P display tag (`display_tag`), Meta Carousel (`image_carousel`), YouTube VAST pre-roll (`video_vast`), podcast 30s host-read (`audio_hosted`), Triton DAAST audio (`audio_daast`), Amazon Sponsored Products (`sponsored_placement`), Google PMax (`responsive_creative`), ChatGPT brand mention (`agent_placement`), Veo 15s generative video (`video_hosted` with `synthesis_nondeterministic` + `provenance_required`) — plus 1 `get_products` response fixture exercising bundled extensions, see `static/examples/products/canonical/` and `static/examples/get_products_responses/canonical/`. The Veo fixture exercises `synthesis_nondeterministic: true` and `provenance_required: true`. Each fixture passes `npm run test:canonical-fixtures`. ## v1 → v2 canonical mapping @@ -294,7 +296,7 @@ Override fields take precedence over `brand.json` for that creative. 1. **Inventory**: enumerate your existing v1 named formats. Confirm each maps to one of the 11 v2 canonicals OR to a custom shape (see "Shipping a custom format" below). Composed/coordinated/sponsorship shapes (multi-placement takeover, roadblock, branded content, cross-screen sponsorship, sponsorship lockup, newsletter, AR lens, playable, live event sponsorship) ship as `format_kind: "custom"` with a `format_shape` registry classifier and a `format_schema` URI+digest reference. 2. **Translate**: for each named format, write a v2 `ProductFormatDeclaration` narrowing the canonical with your platform's parameters. For custom shapes, author a JSON Schema describing your format's `params` and `slots`, host it at a stable URI on your subdomain (or via the AAO mirror for walled-garden sellers), and reference it from `format_schema`. 3. **Be honest about runtime readiness**: set `runtime_status` on each declaration. `stable` (default) means your runtime fully honors the declared format and production source. `preview` means the basic path works but advanced axes (per-item fan-out under `item_production_model`, brief-driven overrides, advanced `platform_extensions`) may be partial. `declared_only` means the catalog declaration is forward-looking and your runtime does NOT yet implement the path — common during migration when you port v1 catalog declarations forward but haven't wired the new production-source axis yet. Buyers can filter on this; compliance storyboards skip-gate `declared_only` entries gracefully. Upgrade the value as your runtime catches up. -4. **Test**: validate translated declarations against `/schemas/core/product.json` (use the `npm run test:v2-fixtures` pattern). +4. **Test**: validate translated declarations against `/schemas/core/product.json` (use the `npm run test:canonical-fixtures` pattern). 5. **Publish dual**: keep your v1 named formats and `list_creative_formats` working through 4.x. Add the v2 `format_options` field on products that have it. 6. **Flatten wrapper**: implement a server-side wrapper that derives the v1 `list_creative_formats` shape from v2 product declarations. Lets v1-era dashboards and tooling keep working. 7. **Deprecate timing**: at 5.0, remove v1 `format_ids` references on your products. Until then, both paths coexist. @@ -555,14 +557,14 @@ v2 doesn't replace OpenRTB at the auction layer (where OpenRTB Display, Video, A Run the fixture validation against your translated products: ```bash test=false -npm run test:v2-fixtures +npm run test:canonical-fixtures ``` -The reference fixtures at `static/examples/products/v2/` are validated against `/schemas/core/product.json`. Adopters can drop their own translated products into a sibling directory and reuse the same validator pattern. +The reference fixtures at `static/examples/products/canonical/` are validated against `/schemas/core/product.json`. Adopters can drop their own translated products into a sibling directory and reuse the same validator pattern. ## Related -- [Creative Formats v2 overview](/docs/creative/v2-overview) +- [Canonical Formats overview](/docs/creative/canonical-formats) - [RFC #3305](https://github.com/adcontextprotocol/adcp/issues/3305) — architectural decisions and rationale - [Asset group vocabulary](https://adcontextprotocol.org/schemas/v3/core/asset-group-vocabulary.json) - [BrandRef schema](https://adcontextprotocol.org/schemas/v3/core/brand-ref.json) diff --git a/docs/creative/v2-overview.mdx b/docs/creative/canonical-formats.mdx similarity index 96% rename from docs/creative/v2-overview.mdx rename to docs/creative/canonical-formats.mdx index af0a826923..54a45436da 100644 --- a/docs/creative/v2-overview.mdx +++ b/docs/creative/canonical-formats.mdx @@ -1,15 +1,17 @@ --- -title: Creative Formats v2 (preview) -description: "Canonical formats live on AdCP; sellers' products narrow them inline. Phase 1 + Phase 2 preview against the v2 RFC #3305." -"og:title": "AdCP — Creative Formats v2" +title: Canonical Formats (preview) +description: "Canonical formats live on AdCP; sellers' products narrow them inline. Phase 1 + Phase 2 preview against RFC #3305." +"og:title": "AdCP — Canonical Formats" testable: true --- -# Creative Formats v2 (preview) +# Canonical Formats (preview) -> **Status:** Preview track. The v2 surface is being designed in flight against [RFC #3305](https://github.com/adcontextprotocol/adcp/issues/3305) and the [#3307 implementation branch](https://github.com/adcontextprotocol/adcp/pull/3307). v1 named formats (`format_id` as `{ agent_url, id }`) remain a first-class path through 4.x with a 5.0 sunset; v2 is opt-in and additive at the schema layer. **The typed-tagged-union ergonomics v2's design earns require SDK codegen to deliver — Phase 4 (TypeScript and Python codegen) is the gating dependency for adopter consumption. Until Phase 4 ships, adopters can build against the schemas directly, but the buyer-side mental simplification v2 promises lands fully only with codegen.** +> **Status:** Preview track. The canonical-formats surface is being designed in flight against [RFC #3305](https://github.com/adcontextprotocol/adcp/issues/3305) and the [#3307 implementation branch](https://github.com/adcontextprotocol/adcp/pull/3307). The v1 named-format model (`format_id` as `{ agent_url, id }`) remains a first-class path through 4.x with a 5.0 sunset; canonical formats are opt-in and additive at the schema layer. **The typed-tagged-union ergonomics this design earns require SDK codegen to deliver — Phase 4 (TypeScript and Python codegen) is the gating dependency for adopter consumption. Until Phase 4 ships, adopters can build against the schemas directly, but the buyer-side mental simplification this design promises lands fully only with codegen.** +> +> *Naming note*: This work was originally drafted as "creative formats v2" — the v1↔v2 contrast describes the two format-authoring models (legacy named-format registry vs new canonical formats on products). To avoid collision with AdCP-the-protocol's own version numbering (currently 3.x), file paths and references use **canonical formats** terminology. The v1↔v2 contrast remains useful narrative shorthand inside schema descriptions where it disambiguates the two authoring paths on `Product.format_ids` vs `Product.format_options`. -v2 collapses today's separate format registry into product-bound declarations. AdCP defines a small set of **canonical formats** (universal building blocks); sellers' products carry inline `ProductFormatDeclaration`s that narrow canonicals with platform-specific parameters. Creative agents become transformation services declaring `build_creative` capabilities targeting canonical formats. Most existing concepts (CTAs, destinations, tracking, brand identity) are reused or stay in their current homes — v2 doesn't create a new vocabulary layer for those. +Canonical formats collapse today's separate format registry into product-bound declarations. AdCP defines a small set of **canonical formats** (universal building blocks); sellers' products carry inline `ProductFormatDeclaration`s that narrow canonicals with platform-specific parameters. Creative agents become transformation services declaring `build_creative` capabilities targeting canonical formats. Most existing concepts (CTAs, destinations, tracking, brand identity) are reused or stay in their current homes — canonical formats don't create a new vocabulary layer for those. ## Glossary @@ -823,13 +825,13 @@ The third-party-creative-agent worked example assumes Flashtalking-shaped tools |---|---|---| | Phase 1 | ✅ in #3307 | `asset_group_id` vocabulary registry (canonical entries + audit-grounded aliases), `scenes` schema, `zip` asset type, video/audio doc fixes | | Phase 2 | ✅ in #3307 | 11 canonical format definitions with structured `slots` declaration, `ProductFormatDeclaration` (format_kind discriminator + params), `validate_input` primitive, `creative.supported_formats` on get_adcp_capabilities, `brand_kit_override`, `platform-extension-ref`, typed inline `product_card` / `product_card_detailed`, `format_ids` + `format_options` `anyOf` on Product (dual emission legal during migration per #3765) | -| Phase 3 | ✅ in #3307 | v1↔v2 migration guide, 12 fully-validated reference Product fixtures + 1 get_products response fixture with bundled extensions, fixture-validation test suite (`npm run test:v2-fixtures`) | +| Phase 3 | ✅ in #3307 | v1↔canonical-formats migration guide, 12 fully-validated reference Product fixtures + 1 get_products response fixture with bundled extensions, fixture-validation test suite (`npm run test:canonical-fixtures`) | | Phase 4 | ⚠️ blocking adoption | Reference SDK codegen (TypeScript first, then Python), server-side flatten wrapper reference implementation. Without Phase 4, adopters cannot consume v2 cleanly — the typed-tagged-union ergonomics this PR's design earns require codegen to deliver. v2 is opt-in and additive at the schema layer today; Phase 4 makes it usable. | | Native canonical | TBD | Deferred to 3.2 after TemplateCreative + OpenRTB Native 1.2 audit | ## Related -- [v1 → v2 migration guide](/docs/creative/v2-migration) — concrete migration paths for sellers, creative agents, buyers, and publisher-direct integrations +- [v1 → canonical-formats migration guide](/docs/creative/canonical-formats-migration) — concrete migration paths for sellers, creative agents, buyers, and publisher-direct integrations - [RFC #3305](https://github.com/adcontextprotocol/adcp/issues/3305) — v2 architecture decisions and rationale - [PR #3307](https://github.com/adcontextprotocol/adcp/pull/3307) — Phase 1 + Phase 2 implementation, on hold pending 3.1.0 beta cycle - [Asset group vocabulary](https://adcontextprotocol.org/schemas/v3/core/asset-group-vocabulary.json) — canonical slot-name registry diff --git a/docs/creative/formats.mdx b/docs/creative/formats.mdx index e08435783c..75d3c0b79c 100644 --- a/docs/creative/formats.mdx +++ b/docs/creative/formats.mdx @@ -4,7 +4,7 @@ description: "Creative formats in AdCP define asset requirements, technical cons "og:title": "AdCP — Creative Formats" --- -> **v2 preview**: starting in 3.1, formats are declared inline on products via `format_options` (an array of `ProductFormatDeclaration`s narrowing one of 11 canonical formats). This page describes the v1 format-registry model, which remains a first-class path through 4.x. For the v2 model, see [v2-overview](/docs/creative/v2-overview) and [v2-migration](/docs/creative/v2-migration). +> **Canonical-formats preview (3.1)**: starting in 3.1, formats can be declared inline on products via `format_options` (an array of `ProductFormatDeclaration`s narrowing one of 11 canonical formats). This page describes the v1 format-registry model, which remains a first-class path through 4.x. For the canonical-formats model, see [canonical-formats](/docs/creative/canonical-formats) and the [migration guide](/docs/creative/canonical-formats-migration). Creative formats define the structural and technical requirements used to instantiate advertising creatives. A format specifies: - The asset types required (video, image, text, audio, etc.) via the `assets` array diff --git a/docs/creative/generative-creative.mdx b/docs/creative/generative-creative.mdx index 6d2d728306..cd68a04a7a 100644 --- a/docs/creative/generative-creative.mdx +++ b/docs/creative/generative-creative.mdx @@ -5,7 +5,7 @@ description: "Generative creative in AdCP uses AI to produce ad assets from a br "og:image": /images/walkthrough/diagram-generative-tiers.png --- -> **v2 preview**: in v2, the "generative" category dissolves at the protocol level — production mechanism (generative AI, host recording, transcoding, asset rendering) is invisible to the buyer. Production source is declared per-canonical via `*_source` enums (`audio_source`, `image_source`, `video_source`, `item_production_model`); `synthesis_nondeterministic: true` flags Veo / Sora / Runway-class flows that need post-synthesis QA-loop semantics. See [v2-overview](/docs/creative/v2-overview) §"Two axes" for the v2 model. +> **Canonical-formats preview (3.1)**: in canonical formats, the "generative" category dissolves at the protocol level — production mechanism (generative AI, host recording, transcoding, asset rendering) is invisible to the buyer. Production source is declared per-canonical via `*_source` enums (`audio_source`, `image_source`, `video_source`, `item_production_model`); `synthesis_nondeterministic: true` flags Veo / Sora / Runway-class flows that need post-synthesis QA-loop semantics. See [canonical-formats](/docs/creative/canonical-formats) §"Two axes" for the canonical-formats model. The Creative Protocol enables AI-powered creative generation and asset management for advertising campaigns. This guide will help you create your first creative in 5 minutes. diff --git a/docs/creative/implementing-creative-agents.mdx b/docs/creative/implementing-creative-agents.mdx index 7d7da5bf4e..153ba140e9 100644 --- a/docs/creative/implementing-creative-agents.mdx +++ b/docs/creative/implementing-creative-agents.mdx @@ -4,7 +4,7 @@ description: "How to build an AdCP creative agent that defines formats, validate "og:title": "AdCP — Implementing Creative Agents" --- -> **v2 preview**: in v2, creative agents declare what they can produce via `creative.supported_formats` on `get_adcp_capabilities` (replacing the v1 `list_creative_formats` overload for creative agents). Each entry uses the same `ProductFormatDeclaration` shape as a product's inline `format_options[i]`. See [v2-overview](/docs/creative/v2-overview). +> **Canonical-formats preview (3.1)**: in the canonical-formats path, creative agents declare what they can produce via `creative.supported_formats` on `get_adcp_capabilities` (replacing the v1 `list_creative_formats` overload for creative agents). Each entry uses the same `ProductFormatDeclaration` shape as a product's inline `format_options[i]`. See [canonical-formats](/docs/creative/canonical-formats). This guide explains how to implement a creative agent that defines and manages creative formats. diff --git a/docs/creative/key-concepts.mdx b/docs/creative/key-concepts.mdx index c9bf8df3c2..ec583ded86 100644 --- a/docs/creative/key-concepts.mdx +++ b/docs/creative/key-concepts.mdx @@ -4,7 +4,7 @@ description: "Assets, formats, manifests, and creative agents are the four build "og:title": "AdCP — Creative key concepts" --- -> **v2 preview**: this page describes the v1 model. For the v2 architectural shift (canonical formats, inline `format_options` on products, production-source axis, `validate_input` primitive), see [v2-overview](/docs/creative/v2-overview). +> **Canonical-formats preview (3.1)**: this page describes the v1 model. For the canonical-formats architectural shift (inline `format_options` on products, production-source axis, `validate_input` primitive), see [canonical-formats](/docs/creative/canonical-formats). One upload, every format. This guide explains how creatives work in AdCP, from defining format requirements to assembling and delivering ads. diff --git a/docs/creative/specification.mdx b/docs/creative/specification.mdx index cd8246e362..850d86aaf7 100644 --- a/docs/creative/specification.mdx +++ b/docs/creative/specification.mdx @@ -9,7 +9,7 @@ sidebarTitle: Specification **AdCP 3.0 Proposal** - This specification is under development for AdCP 3.0. Feedback welcome via [GitHub Discussions](https://github.com/adcontextprotocol/adcp/discussions). -> **v2 preview**: this page describes the v1 specification model. For the v2 model (canonical formats, inline `format_options` on products, `validate_input` primitive), see [v2-overview](/docs/creative/v2-overview) and [v2-migration](/docs/creative/v2-migration). +> **Canonical-formats preview (3.1)**: this page describes the v1 specification model. For the canonical-formats model (inline `format_options` on products, `validate_input` primitive), see [canonical-formats](/docs/creative/canonical-formats) and the [migration guide](/docs/creative/canonical-formats-migration). **Status**: Request for Comments **Last Updated**: March 2026 diff --git a/package.json b/package.json index f2ee941dcc..90bc73ed4b 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,8 @@ "test:extension-schemas": "node tests/extension-schemas.test.cjs", "test:snippets": "node tests/snippet-validation.test.cjs", "test:json-schema": "node tests/json-schema-validation.test.cjs", - "test:v2-fixtures": "node tests/v2-fixture-validation.test.cjs", - "test:v2-negative": "node tests/v2-negative-fixtures.test.cjs", + "test:canonical-fixtures": "node tests/canonical-fixture-validation.test.cjs", + "test:canonical-negative": "node tests/canonical-negative-fixtures.test.cjs", "test:error-handling": "node tests/check-error-handling.cjs", "test:composed": "node tests/composed-schema-validation.test.cjs", "test:migrations": "node tests/migration-validation.test.cjs", diff --git a/static/examples/get_products_responses/v2/meta_with_bundled_extensions.json b/static/examples/get_products_responses/canonical/meta_with_bundled_extensions.json similarity index 100% rename from static/examples/get_products_responses/v2/meta_with_bundled_extensions.json rename to static/examples/get_products_responses/canonical/meta_with_bundled_extensions.json diff --git a/static/examples/products/v2/amazon_sponsored_products.json b/static/examples/products/canonical/amazon_sponsored_products.json similarity index 100% rename from static/examples/products/v2/amazon_sponsored_products.json rename to static/examples/products/canonical/amazon_sponsored_products.json diff --git a/static/examples/products/v2/chatgpt_brand_mention.json b/static/examples/products/canonical/chatgpt_brand_mention.json similarity index 100% rename from static/examples/products/v2/chatgpt_brand_mention.json rename to static/examples/products/canonical/chatgpt_brand_mention.json diff --git a/static/examples/products/v2/gam_3p_display_tag.json b/static/examples/products/canonical/gam_3p_display_tag.json similarity index 100% rename from static/examples/products/v2/gam_3p_display_tag.json rename to static/examples/products/canonical/gam_3p_display_tag.json diff --git a/static/examples/products/v2/google_performance_max.json b/static/examples/products/canonical/google_performance_max.json similarity index 100% rename from static/examples/products/v2/google_performance_max.json rename to static/examples/products/canonical/google_performance_max.json diff --git a/static/examples/products/v2/meta_carousel.json b/static/examples/products/canonical/meta_carousel.json similarity index 100% rename from static/examples/products/v2/meta_carousel.json rename to static/examples/products/canonical/meta_carousel.json diff --git a/static/examples/products/v2/meta_reels_us.json b/static/examples/products/canonical/meta_reels_us.json similarity index 100% rename from static/examples/products/v2/meta_reels_us.json rename to static/examples/products/canonical/meta_reels_us.json diff --git a/static/examples/products/v2/nytimes_homepage_html5.json b/static/examples/products/canonical/nytimes_homepage_html5.json similarity index 100% rename from static/examples/products/v2/nytimes_homepage_html5.json rename to static/examples/products/canonical/nytimes_homepage_html5.json diff --git a/static/examples/products/v2/nytimes_homepage_mrec.json b/static/examples/products/canonical/nytimes_homepage_mrec.json similarity index 100% rename from static/examples/products/v2/nytimes_homepage_mrec.json rename to static/examples/products/canonical/nytimes_homepage_mrec.json diff --git a/static/examples/products/v2/the_daily_30s_host_read.json b/static/examples/products/canonical/the_daily_30s_host_read.json similarity index 100% rename from static/examples/products/v2/the_daily_30s_host_read.json rename to static/examples/products/canonical/the_daily_30s_host_read.json diff --git a/static/examples/products/v2/triton_daast_audio_30s.json b/static/examples/products/canonical/triton_daast_audio_30s.json similarity index 100% rename from static/examples/products/v2/triton_daast_audio_30s.json rename to static/examples/products/canonical/triton_daast_audio_30s.json diff --git a/static/examples/products/v2/veo_generative_video_15s.json b/static/examples/products/canonical/veo_generative_video_15s.json similarity index 100% rename from static/examples/products/v2/veo_generative_video_15s.json rename to static/examples/products/canonical/veo_generative_video_15s.json diff --git a/static/examples/products/v2/youtube_vast_preroll.json b/static/examples/products/canonical/youtube_vast_preroll.json similarity index 100% rename from static/examples/products/v2/youtube_vast_preroll.json rename to static/examples/products/canonical/youtube_vast_preroll.json diff --git a/tests/v2-fixture-validation.test.cjs b/tests/canonical-fixture-validation.test.cjs similarity index 95% rename from tests/v2-fixture-validation.test.cjs rename to tests/canonical-fixture-validation.test.cjs index bc1b39a233..41c1207d3e 100644 --- a/tests/v2-fixture-validation.test.cjs +++ b/tests/canonical-fixture-validation.test.cjs @@ -1,8 +1,8 @@ #!/usr/bin/env node /** - * v2 Reference Fixture Validation Test + * Canonical Formats Reference Fixture Validation Test * - * Validates the reference Product fixtures at static/examples/products/v2/*.json + * Validates the reference Product fixtures at static/examples/products/canonical/*.json * against /schemas/core/product.json AND against the per-canonical params schema * in strict mode (to catch typos in `params` that the product-envelope schema's * `additionalProperties: true` would otherwise let slip through). @@ -18,7 +18,7 @@ * letting `params: { with: 'a typo' }` validate vacuously under * additionalProperties: true). * - * Run: npm run test:v2-fixtures + * Run: npm run test:canonical-fixtures */ const Ajv = require('ajv').default; @@ -27,8 +27,8 @@ const fs = require('fs'); const path = require('path'); const SCHEMAS_DIR = path.resolve(__dirname, '../static/schemas/source'); -const FIXTURES_DIR = path.resolve(__dirname, '../static/examples/products/v2'); -const RESPONSE_FIXTURES_DIR = path.resolve(__dirname, '../static/examples/get_products_responses/v2'); +const FIXTURES_DIR = path.resolve(__dirname, '../static/examples/products/canonical'); +const RESPONSE_FIXTURES_DIR = path.resolve(__dirname, '../static/examples/get_products_responses/canonical'); const RED = '\x1b[31m'; const YELLOW = '\x1b[33m'; @@ -145,7 +145,7 @@ function main() { process.exit(2); } - console.log('v2 Reference Fixture Validation'); + console.log('Canonical Formats Reference Fixture Validation'); console.log('================================'); console.log(`Schema: /schemas/core/product.json (envelope) + per-canonical params (strict)`); console.log(`Fixtures: ${FIXTURES_DIR}`); @@ -222,7 +222,7 @@ function main() { console.log(''); if (fail === 0 && strictWarnings === 0) { - console.log(`${GREEN}✅ All ${pass} v2 reference fixtures validate (envelope + per-canonical strict).${RESET}`); + console.log(`${GREEN}✅ All ${pass} canonical-formats reference fixtures validate (envelope + per-canonical strict).${RESET}`); process.exit(0); } if (fail === 0) { diff --git a/tests/v2-negative-fixtures.test.cjs b/tests/canonical-negative-fixtures.test.cjs similarity index 92% rename from tests/v2-negative-fixtures.test.cjs rename to tests/canonical-negative-fixtures.test.cjs index 514f417c0b..29a3c6f04d 100644 --- a/tests/v2-negative-fixtures.test.cjs +++ b/tests/canonical-negative-fixtures.test.cjs @@ -1,11 +1,11 @@ #!/usr/bin/env node /** - * v2 Negative-fixture regression tests + * Canonical Formats Negative-fixture regression tests * - * Validates that the v2 schemas REJECT specific malformed inputs. Complements - * tests/v2-fixture-validation.test.cjs (which validates that valid fixtures - * pass) with the inverse: that the schema's `allOf` if/then/else, oneOf - * discriminators, and required/not constraints actually reject the shapes + * Validates that the canonical-formats schemas REJECT specific malformed inputs. + * Complements tests/canonical-fixture-validation.test.cjs (which validates that + * valid fixtures pass) with the inverse: that the schema's `allOf` if/then/else, + * oneOf discriminators, and required/not constraints actually reject the shapes * they're meant to reject. * * Filed against PR #3307 review comment R3 (composability of top-level @@ -14,7 +14,7 @@ * the schema's negative-side behavior; without these tests, regressions to * silent-pass on malformed declarations would slip through. * - * Run: npm run test:v2-negative + * Run: npm run test:canonical-negative */ const Ajv = require('ajv').default; From de7a6f0cdbb217b2c83fe4c88be0f9584a0974b1 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 16 May 2026 07:20:00 -0400 Subject: [PATCH 31/41] =?UTF-8?q?feat(canonical-formats):=20address=20SDK?= =?UTF-8?q?=20implementor=20review=20on=20PR=20#3307=20=E2=80=94=20inline?= =?UTF-8?q?=20normative=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six inline fixes from the SDK-team critical review (out of nine raised; #4 and #5 filed as follow-ups): - IR1 v2→v1 projection: add `v2_only` boolean on ProductFormatDeclaration (required `true` when `format_kind: "custom"`). Producer + consumer divergence rules normative in canonical-formats.mdx. No synthetic v1 format_id namespace — explicit v2_only is the marker. - IR2 non-projectable v1: SDKs MUST emit structured warning on resolution failure (carrying format_id, product_id, failure reason). Prevents silent v2-only inventory shrinkage. - IR3 format_schema fetch contract: https-only, hard-fail on digest mismatch, ≤5s timeout, $ref sandbox (same-origin / AAO mirror / intra-doc; no file://; depth ≤8), graceful 404 degradation, invalid-schema hard-fail. In both schema description and canonical-formats.mdx. - IR6 codegen vs runtime: doc callout that generated TS/Pydantic types lose if/then narrowing on format_kind:custom + result_kind; Ajv runtime validator is the gate. - IR8 agent_placement: explicit 3.2-track stamp — tracking macro/postback/dedup intentionally underspecified for 3.1; adopters SHOULD ship as runtime_status: preview or declared_only. - IR9 migration math: realistic-coverage paragraph (15 registry entries, ~76% projectable with seller/registry action, 71+ v1-only out of gate, dual-read realistic through 3.3). Negative fixtures expanded: format_kind:custom now rejects without v2_only:true (added 2 new fixtures, total 13 passing). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../canonical-formats-implementor-review.md | 16 +++++ docs/creative/canonical-formats.mdx | 62 +++++++++++++++++++ .../core/product-format-declaration.json | 15 ++++- .../formats/canonical/agent_placement.json | 2 +- .../registries/v1-canonical-mapping.json | 2 +- tests/canonical-negative-fixtures.test.cjs | 22 +++++++ 6 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 .changeset/canonical-formats-implementor-review.md diff --git a/.changeset/canonical-formats-implementor-review.md b/.changeset/canonical-formats-implementor-review.md new file mode 100644 index 0000000000..5a9f49365e --- /dev/null +++ b/.changeset/canonical-formats-implementor-review.md @@ -0,0 +1,16 @@ +--- +"@adcontextprotocol/adcp": minor +--- + +canonical-formats: address SDK-team implementor review on PR #3307 with inline normative tightening. + +- **v2→v1 projection (IR1)**. Add `v2_only` boolean to `ProductFormatDeclaration`; required `true` for `format_kind: "custom"` declarations. Add normative producer/consumer rules for dual-emission (`format_ids` + `format_options`) and divergence detection in canonical-formats.mdx. Protocol does not mint synthetic v1 `format_id`s — explicit `v2_only` is the v2-only marker. +- **Non-projectable v1 SHOULD-warn (IR2)**. v1-canonical-mapping.json now normatively requires SDKs to emit a structured warning (carrying format_id, product_id, resolution-failure reason) when a v1 product can't project to a canonical. Prevents silent inventory shrinkage for v2-only buyers. +- **`format_schema` fetch contract (IR3)**. Pin normative fetch semantics on `format_schema`: https-only transport, hard-fail on digest mismatch, ≤5s timeout, `$ref` sandboxing (same-origin / AAO-mirror / intra-document only; no `file://`; transitive depth ≤8), graceful degradation on 404 / partition, schema-not-valid hard-fail. Documented in both the schema description and canonical-formats.mdx custom-formats section. +- **Codegen-vs-runtime asymmetry (IR6)**. Doc callout that generated TS/Pydantic types lose the `allOf/if/then` conditionals on `format_kind: "custom"` and `result_kind`; runtime Ajv (or equivalent) validation is the gate. Adopters MUST validate at runtime, not rely on the type system. +- **`agent_placement` 3.2-track (IR8)**. Explicit description-level stamp that the tracking macro vocabulary, postback shape, and dedup model are intentionally underspecified for 3.1. Adopters claiming the canonical SHOULD set `runtime_status: 'preview'` or `'declared_only'`. Wire contract for tracking events ships in 3.2. +- **Migration math reality (IR9)**. Realistic-coverage paragraph in canonical-formats.mdx: 15 registry entries at 3.1, ~76% of audited formats fit but require seller or registry action to project, 71+ v1-only out of gate. Dual-read codepath realistic through 3.3; v2-only realistic at 4.x earliest. + +Negative-fixture suite expanded: `format_kind: "custom"` now rejects when `v2_only` is missing or `false`. + +Conformance-storyboard track (IR4) and adopter-contract docs for `sponsored_placement` (IR5) filed as follow-up issues against 3.1 GA. diff --git a/docs/creative/canonical-formats.mdx b/docs/creative/canonical-formats.mdx index 54a45436da..3b73db629e 100644 --- a/docs/creative/canonical-formats.mdx +++ b/docs/creative/canonical-formats.mdx @@ -132,6 +132,7 @@ These shapes are real ad-industry product types — but they're either multi-can "format_options": [ { "format_kind": "custom", + "v2_only": true, "format_shape": "multi_placement_takeover", "format_schema": { "uri": "https://nytimes.adcp/schemas/formats/homepage_takeover_v3", @@ -158,6 +159,19 @@ Three required pieces when `format_kind: "custom"`: 2. **`format_schema`** — URI+digest reference to a fetchable schema describing the shape's actual `params` and `slots`. **Same hosting model as `platform_extensions`**: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://mirror.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching), validate `params` and `slots` against the fetched schema, and reason about manifests structurally. 3. **`params`** — the actual structure, governed by the schema fetched from `format_schema.uri`. AdCP doesn't bake the params shape; the seller's schema does. +### `format_schema` fetch contract (normative) + +`format_schema` gates validation — without the schema, a buyer cannot reason about the custom shape. The fetch contract is tighter than `platform_extensions` (which is informational): + +- **Transport**: `https://` only. `http://`, `file://`, `data:`, and other schemes MUST be rejected. +- **Digest mismatch is a hard fail.** SHA-256 of the fetched body MUST equal `format_schema.digest`. On mismatch, the buyer MUST treat the declaration as unresolvable. No fallback to the unverified body. +- **Timeout** ≤5s recommended. Timeout treated as a 5xx (transient — retry or skip). +- **`$ref` sandboxing**: fetched schemas MAY use `$ref`, but only to (a) same-origin URIs, (b) the AAO mirror namespace, or (c) intra-document JSON Pointer refs. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected. Transitive `$ref` depth bounded (SDKs SHOULD cap at 8). +- **Cache** by `uri@digest`, immutable. On 404 / partition / persistent failure: skip the declaration for this session, surface a structured warning, do NOT fail the whole `get_products` response. +- **Schema validity**: fetched body must be a valid JSON Schema (Draft 07 or 2020-12). Invalid schema → same as digest mismatch (unresolvable, warning, skip). + +These rules apply specifically to `format_schema`. `platform_extensions` keep their looser informational contract; `format_schema` is the load-bearing one. + ### Why custom + format_schema instead of `ext` A buyer agent calling `get_products` and seeing a format with interesting structure buried in `ext` has no spec-level definition to reason against. There's no schema, no required fields, no defined semantics — the agent can see the blob but can't interpret it reliably. A human has to step in to evaluate whether the format fits the campaign brief, what assets are needed, how it tracks, what the impression contract is, whether the price makes sense. @@ -775,6 +789,40 @@ The two paths share the digest-pinned cache and graceful-degradation semantics. Buyer's SDK caches by URI@digest. Subsequent `get_products` responses can reference by digest alone if the buyer has the extension cached. Direct URI fetch is supported for tooling but the primary path is bundled-in-`get_products`. +## Dual emission and v2↔v1 projection (normative) + +Products MAY carry both `format_ids` (v1) and `format_options` (v2) during the migration window. When both ship, the two MUST refer to the same underlying format declaration — divergent shapes are a contract violation. + +### Producer rules + +- SDKs that derive both shapes from a single source guarantee the invariant. Hand-authored products MUST be reviewed for agreement. +- A producer that cannot guarantee agreement MUST emit one shape only. +- For `format_kind: "custom"` declarations, producers MUST set `v2_only: true` and MUST NOT synthesize a v1 `format_id`. The protocol does NOT mint synthetic format_ids (an `aao-synth/*` namespace was considered and rejected — adopters would index on identifiers with no stable identity). +- For `format_options` declarations whose canonical/parameter shape has no clean v1 named-format equivalent (e.g., a structural shape not in `v1-canonical-mapping.json` and not declared on any v1 file), producers SHOULD set `v2_only: true` rather than emit only one of the two shapes silently. + +### Consumer rules (v1→v2) + +When reading a product on the v1 path, SDKs project `format_ids` to `format_options` using the resolution order from `v1-canonical-mapping.json`: + +1. Explicit `canonical` field on the v1 format declaration (seller-declared, highest priority). +2. `format_id_glob` match in the registry. +3. Structural match in the registry. +4. **Fail closed**: SDK MUST NOT synthesize a `format_options` entry. SDKs SHOULD emit a structured warning (telemetry / logger) so adopters can spot non-projectable inventory; absence is silent loss of inventory visibility for v2-only buyers. + +### Consumer rules (divergence detection) + +When a product carries BOTH `format_ids` and `format_options` and the two disagree (different canonical, different dimensions, different orientation, etc.): + +- SDKs MUST treat this as a producer contract violation. +- SDKs SHOULD prefer `format_options` (v2 is the richer surface) and emit a structured warning identifying the divergent product. Hard-failing the entire `get_products` response is discouraged — it punishes downstream buyers for a producer bug. +- SDKs MUST NOT silently pick one shape and discard the other without surfacing the divergence to the calling agent. + +The schema cannot enforce agreement (no cross-field constraint expresses "the v1 mapped form of `format_ids[i]` must equal `format_options[j]`"). Consumer-side detection is the only line of defense; SDK conformance suites SHOULD include divergence fixtures. + +### v2-only declarations on the wire + +A buyer reading a product on the v1 wire path sees `format_options` entries with `v2_only: true` absent from `format_ids`. This is intentional and not a producer error. v2-aware buyers reading `format_options` see them. v1-only buyers see fewer options on `format_ids` than a v2-aware buyer sees on `format_options` for the same product — the v1 surface is a strict subset on these products until v1 sunset (5.0). + ## What's NOT in v2 By design, v2 doesn't introduce new vocabulary for things AdCP already handles or that belong elsewhere: @@ -807,6 +855,16 @@ The `*_source` enums (including `seller_pre_rendered_from_brief` and `agent_synt The third-party-creative-agent worked example assumes Flashtalking-shaped tools serve buyers via `build_creative` and let the buyer ship the produced manifest to the seller. Operators reading this should not infer that v2 strips creative agents of their hosting / serving / tracking revenue. Production happens at `build_creative`; the produced manifest can include hosted asset URLs on the creative agent's CDN (Flashtalking-hosted asset URLs in the example), and platform extensions can attach creative-agent-specific tracking (Flashtalking pixel IDs, viewability vendor configurations) that the seller honors at serve time. The v2 disaggregation is conceptual (the spec separates production from serving from tracking) — the operational integration path lets creative agents continue to host and instrument their produced creatives. v2 doesn't dictate where the asset bytes live or whose tracking JS runs; it only formalizes the production-vs-serving boundary that already exists implicitly. +### Codegen vs runtime: the validator is the gate + +`product-format-declaration.json` carries an `allOf/if/then/else` that conditionally requires `format_shape`, `format_schema`, and `v2_only` only when `format_kind === "custom"`. The same pattern applies to `validate-input-result.json`'s `result_kind` discriminator with conditional `violations`. JSON Schema captures these conditionals cleanly, but most codegen pipelines (`json-schema-to-typescript`, `datamodel-codegen`) strip `if/then/else` before emitting types because conditional narrowing doesn't map to TypeScript's structural type system or to Pydantic's class model. The generated types are therefore strictly more permissive than the schema: + +- Generated TS / Python types accept a `format_kind: "custom"` declaration that omits `format_shape` or `format_schema` — the type system has no way to narrow on the discriminator and require the conditional fields. +- The Ajv (or equivalent) runtime validator IS the gate. SDKs MUST run the JSON Schema validator before trusting a `ProductFormatDeclaration` parsed from the wire; the codegenned type is a convenience layer, not a contract. +- Buyer-agent authors writing v2 in TypeScript SHOULD treat the generated types as a starting point and add their own runtime validation step — same pattern adopters already use for any JSON-Schema-validated API. Adopters who skip runtime validation will get type-system success on declarations that the schema rejects, and discover the gap only when their declarations hit a strict downstream validator. + +This is a doc concern, not a schema concern. The schema is more strict than the codegenned types; runtime validation closes the gap. + ## Migration | Adopter | Cost | Realistic timeline | @@ -819,6 +877,10 @@ The third-party-creative-agent worked example assumes Flashtalking-shaped tools **v1 stays first-class.** v1 named formats remain supported; sellers SHOULD provide server-side flatten wrappers that derive the v1 `list_creative_formats` shape from v2 product format declarations through 4.0. v2 is the *new* path, not the only path. +### Realistic 3.1 coverage + +`v1-canonical-mapping.json` ships with ~15 unambiguous entries at 3.1 (IAB display sizes, VAST 4.x, DAAST 1.x). The full v1 audit catalogued 86 formats across 12 platforms; ~76% of those (≈65 formats) fit existing canonicals structurally but **only project automatically when either (a) the seller adds an explicit `canonical` field to their v1 format file, or (b) someone files a registry PR adding the `format_id_glob` or structural match.** Out of the gate at 3.1, 71+ of the audited formats are v1-only — they don't lose support, but a v2-only buyer agent doesn't see them on `format_options` until a seller or AAO contributor closes the gap. Through 3.x, expect most product traffic to remain v1 wire shape with opt-in `format_options` from early-adopter sellers (Meta, NYTimes, AudioStack, generative-DSP, retail-media). Buyer agents planning v2-only consumption in 3.x will see meaningfully thinner inventory than v1-aware agents; planning the codepath as dual-read through at least 3.3 is realistic. The 5.0 sunset for v1 is the floor on dual-emission, not the expected switchover date — anyone planning v2-only should pencil that in for 4.x at the earliest. + ## Phase status | Phase | Status | What's in it | diff --git a/static/schemas/source/core/product-format-declaration.json b/static/schemas/source/core/product-format-declaration.json index 706d595883..45a3decea5 100644 --- a/static/schemas/source/core/product-format-declaration.json +++ b/static/schemas/source/core/product-format-declaration.json @@ -21,6 +21,11 @@ "uniqueItems": true, "description": "Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." }, + "v2_only": { + "type": "boolean", + "default": false, + "description": "When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: \"custom\"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `v2_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence." + }, "runtime_status": { "type": "string", "enum": ["stable", "preview", "declared_only"], @@ -33,7 +38,7 @@ }, "format_schema": { "$ref": "/schemas/core/platform-extension-ref.json", - "description": "REQUIRED when `format_kind: \"custom\"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape's actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://mirror.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that's why the schema is required, not optional." + "description": "REQUIRED when `format_kind: \"custom\"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape's actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://mirror.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that's why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational), so the fetch semantics are tighter:\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK's discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri`, OR (b) hosted under the AAO mirror namespace (`https://mirror.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`). Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded (SDKs SHOULD cap at depth 8) and each fetched component MUST be digest-pinned by the parent schema (publishers SHOULD inline rather than $ref where possible; cross-document $ref is supported but a smaller surface).\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface a structured warning) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface a structured warning. Validators MUST NOT attempt partial validation against an invalid schema." } }, "allOf": [ @@ -43,7 +48,10 @@ "required": ["format_kind"] }, "then": { - "required": ["format_shape", "format_schema"] + "required": ["format_shape", "format_schema", "v2_only"], + "properties": { + "v2_only": { "const": true } + } }, "else": { "not": { "anyOf": [ @@ -218,9 +226,10 @@ } }, { - "description": "NYTimes Homepage Takeover — custom format_kind, classified against the multi_placement_takeover format_shape, with format_schema pointing at NYTimes's hosted schema. Buyer agents fetch the schema by uri@digest (cached, immutable) and validate the manifest structurally.", + "description": "NYTimes Homepage Takeover — custom format_kind, classified against the multi_placement_takeover format_shape, with format_schema pointing at NYTimes's hosted schema. Buyer agents fetch the schema by uri@digest (cached, immutable) and validate the manifest structurally. `v2_only: true` is required for custom declarations — no v1 named format can express the multi-placement shape.", "data": { "format_kind": "custom", + "v2_only": true, "format_shape": "multi_placement_takeover", "format_schema": { "uri": "https://nytimes.adcp/schemas/formats/homepage_takeover_v3", diff --git a/static/schemas/source/formats/canonical/agent_placement.json b/static/schemas/source/formats/canonical/agent_placement.json index e0697791b1..ccb4b48062 100644 --- a/static/schemas/source/formats/canonical/agent_placement.json +++ b/static/schemas/source/formats/canonical/agent_placement.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/formats/canonical/agent_placement.json", "title": "Canonical Format: Agent Placement (AI-surface sponsored placement)", - "description": "Sponsored placement integrated into an AI-surface's response to a user. Buyer supplies a `BrandRef` (resolving brand.json for context), an optional `offering_ref` to focus the mention on a specific offering, and an optional `landing_page_url` the surface MAY attach as a citation. The surface (LLM, voice assistant, sponsored-search ranker) composes a natural-language mention, sponsored card, or audio snippet within its response to a user query. **Composition is algorithmic** — the agent chooses phrasing and presentation. Output asset_type varies by surface: `text` for chat UIs and sponsored search snippets; `audio` (synthesized) for voice assistants; `card` for structured AI-surface result cards. Tracking model: mention-level impression + attribution events; per-mention id keys back to brand and offering. Distinct from `si_chat` (which is the user-converses-with-brand's-agent pattern — brand owns the conversational surface) and from `sponsored_placement` (retail-media catalog-driven). Parallels `sponsored_placement` structurally: both are surface-composed placements; agent_placement is for AI/agentic surfaces, sponsored_placement is for retail media.", + "description": "**3.2-track canonical.** The structural shape (algorithmic composition + brand-context input + optional offering/landing_page) is captured here so adopters can declare against it in 3.1 catalogs, but the **mention-level tracking contract is intentionally underspecified for 3.1**: no normative macro vocabulary, no postback shape, no cross-surface dedup model. Adopters claiming `agent_placement` in 3.1 ship private tracking integrations and SHOULD set `runtime_status: 'preview'` or `'declared_only'` on the declaration; buyer agents MUST treat agent_placement attribution as adapter-defined until the 3.2 tracking-macro spec lands. The canonical promotes to a normatively-buyer-callable surface in 3.2 (or later) once the tracking contract is specified.\n\nSponsored placement integrated into an AI-surface's response to a user. Buyer supplies a `BrandRef` (resolving brand.json for context), an optional `offering_ref` to focus the mention on a specific offering, and an optional `landing_page_url` the surface MAY attach as a citation. The surface (LLM, voice assistant, sponsored-search ranker) composes a natural-language mention, sponsored card, or audio snippet within its response to a user query. **Composition is algorithmic** — the agent chooses phrasing and presentation. Output asset_type varies by surface: `text` for chat UIs and sponsored search snippets; `audio` (synthesized) for voice assistants; `card` for structured AI-surface result cards. Tracking model: mention-level impression + attribution events; per-mention id keys back to brand and offering — but see the 3.2-track note above; the wire shape of these events is not yet specified. Distinct from `si_chat` (which is the user-converses-with-brand's-agent pattern — brand owns the conversational surface) and from `sponsored_placement` (retail-media catalog-driven). Parallels `sponsored_placement` structurally: both are surface-composed placements; agent_placement is for AI/agentic surfaces, sponsored_placement is for retail media.", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { "slots": { diff --git a/static/schemas/source/registries/v1-canonical-mapping.json b/static/schemas/source/registries/v1-canonical-mapping.json index 323fd0d2b0..6656ffaf28 100644 --- a/static/schemas/source/registries/v1-canonical-mapping.json +++ b/static/schemas/source/registries/v1-canonical-mapping.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/registries/v1-canonical-mapping.json", "title": "v1 → v2 Canonical Format Mapping Registry", - "description": "Authoritative AAO-published mapping from v1 named formats to v2 canonical declarations. Used by SDKs to project between wire shapes during the migration window: v1 format on the wire → v2 canonical in memory (or vice versa).\n\n**Resolution order** (per RFC #3305 amendment #3767, normative):\n1. If the v1 format declaration carries an explicit `canonical` field, use it (seller-declared, highest priority).\n2. Else, look up `format_id` in this registry's `format_id_glob` entries.\n3. Else, attempt structural match against this registry's `structural` entries.\n4. Else, fail closed: SDK MUST NOT emit `format_options` for products carrying this format. Surface a validation warning suggesting the seller add an explicit `canonical` field or file a registry entry against this file.\n\n**Match modes:**\n- `format_id_glob` — exact / glob match against the v1 `format_id.id` (the named-format identifier). Covers IAB-conventional sizes, named platform formats, common publisher conventions. Glob syntax: `*` matches any segment.\n- `structural` — match against the format's slot shape, asset types, and version constraints. Catches custom v1 formats that are structurally a standard format under a different name (e.g., `acme_homepage_300x250` is structurally an IAB MREC).\n\n**Governance**: same vocabulary-governance rules as `asset-group-vocabulary.json` and `format-shape-vocabulary.json` — additions land via PR with rationale + ≥1 reference adopter; AAO maintainer review; versioned + content-digested. Entries are additive; once published they are not removed (they may be marked `deprecated: true` if superseded).\n\n**Initial scope (3.1)**: ~15 unambiguous entries covering IAB display sizes, VAST 4.x, DAAST 1.x. Subsequent PRs expand coverage as adopter feedback surfaces patterns. The full v1-format audit dataset (~76% of formats from the 12-platform / 86-format audit in #3305) seeds the long-term roadmap.\n\nDigest the file content (sha256) when emitting in capabilities responses or referencing from SDK output. Buyers cache by `version` + `digest`.", + "description": "Authoritative AAO-published mapping from v1 named formats to v2 canonical declarations. Used by SDKs to project between wire shapes during the migration window: v1 format on the wire → v2 canonical in memory (or vice versa).\n\n**Resolution order** (per RFC #3305 amendment #3767, normative):\n1. If the v1 format declaration carries an explicit `canonical` field, use it (seller-declared, highest priority).\n2. Else, look up `format_id` in this registry's `format_id_glob` entries.\n3. Else, attempt structural match against this registry's `structural` entries.\n4. Else, fail closed: SDK MUST NOT emit `format_options` for products carrying this format. SDKs MUST surface a structured warning (logger / telemetry signal) carrying the v1 `format_id`, the product_id, and the resolution failure reason (`no_explicit_canonical`, `no_registry_match`, `no_structural_match`). Without the warning, v2-only buyers silently see less inventory than they should — that's a 6-month support case waiting to happen. The warning is the consumer-side counterpart to the producer SHOULD that sellers add an explicit `canonical` field or file a registry PR.\n\n**Match modes:**\n- `format_id_glob` — exact / glob match against the v1 `format_id.id` (the named-format identifier). Covers IAB-conventional sizes, named platform formats, common publisher conventions. Glob syntax: `*` matches any segment.\n- `structural` — match against the format's slot shape, asset types, and version constraints. Catches custom v1 formats that are structurally a standard format under a different name (e.g., `acme_homepage_300x250` is structurally an IAB MREC).\n\n**Governance**: same vocabulary-governance rules as `asset-group-vocabulary.json` and `format-shape-vocabulary.json` — additions land via PR with rationale + ≥1 reference adopter; AAO maintainer review; versioned + content-digested. Entries are additive; once published they are not removed (they may be marked `deprecated: true` if superseded).\n\n**Initial scope (3.1)**: ~15 unambiguous entries covering IAB display sizes, VAST 4.x, DAAST 1.x. Subsequent PRs expand coverage as adopter feedback surfaces patterns. The full v1-format audit dataset (~76% of formats from the 12-platform / 86-format audit in #3305) seeds the long-term roadmap.\n\nDigest the file content (sha256) when emitting in capabilities responses or referencing from SDK output. Buyers cache by `version` + `digest`.", "version": "1.0.0", "last_updated": "2026-05-01", "type": "object", diff --git a/tests/canonical-negative-fixtures.test.cjs b/tests/canonical-negative-fixtures.test.cjs index 29a3c6f04d..1791855d08 100644 --- a/tests/canonical-negative-fixtures.test.cjs +++ b/tests/canonical-negative-fixtures.test.cjs @@ -109,6 +109,28 @@ const NEGATIVE_CASES = { expected: true, doc: { format_kind: 'custom', + v2_only: true, + params: { foo: 'bar' }, + format_shape: 'multi_placement_takeover', + format_schema: { uri: 'https://x.example/s', digest: SHA }, + }, + }, + { + label: 'format_kind=custom rejects missing v2_only', + expected: false, + doc: { + format_kind: 'custom', + params: { foo: 'bar' }, + format_shape: 'multi_placement_takeover', + format_schema: { uri: 'https://x.example/s', digest: SHA }, + }, + }, + { + label: 'format_kind=custom rejects v2_only=false', + expected: false, + doc: { + format_kind: 'custom', + v2_only: false, params: { foo: 'bar' }, format_shape: 'multi_placement_takeover', format_schema: { uri: 'https://x.example/s', digest: SHA }, From 501df3fa9e6c8d089744ebb447a6e18be06d64e8 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 16 May 2026 08:25:02 -0400 Subject: [PATCH 32/41] =?UTF-8?q?refactor(canonical-formats):=20rename=20v?= =?UTF-8?q?2=5Fonly=20=E2=86=92=20canonical=5Fformats=5Fonly=20on=20Produc?= =?UTF-8?q?tFormatDeclaration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field name should follow the canonical-formats naming convention established by the v2 → canonical-formats rename. v2_only perpetuated the v2 terminology that we agreed to retire from machine-readable identifiers (path: file paths, field names, registry identifiers use canonical-formats; v1↔v2 contrast remains only as narrative shorthand inside schema descriptions). Updates field name + schema description + if/then required-list + example in product-format-declaration.json; mirrors the rename in canonical-formats.mdx, negative-fixture test, and changeset. All 13 positive + 13 negative fixtures still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/canonical-formats-implementor-review.md | 4 ++-- docs/creative/canonical-formats.mdx | 10 +++++----- .../source/core/product-format-declaration.json | 12 ++++++------ tests/canonical-negative-fixtures.test.cjs | 8 ++++---- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.changeset/canonical-formats-implementor-review.md b/.changeset/canonical-formats-implementor-review.md index 5a9f49365e..0d2a8c7d2e 100644 --- a/.changeset/canonical-formats-implementor-review.md +++ b/.changeset/canonical-formats-implementor-review.md @@ -4,13 +4,13 @@ canonical-formats: address SDK-team implementor review on PR #3307 with inline normative tightening. -- **v2→v1 projection (IR1)**. Add `v2_only` boolean to `ProductFormatDeclaration`; required `true` for `format_kind: "custom"` declarations. Add normative producer/consumer rules for dual-emission (`format_ids` + `format_options`) and divergence detection in canonical-formats.mdx. Protocol does not mint synthetic v1 `format_id`s — explicit `v2_only` is the v2-only marker. +- **v2→v1 projection (IR1)**. Add `canonical_formats_only` boolean to `ProductFormatDeclaration`; required `true` for `format_kind: "custom"` declarations. Add normative producer/consumer rules for dual-emission (`format_ids` + `format_options`) and divergence detection in canonical-formats.mdx. Protocol does not mint synthetic v1 `format_id`s — explicit `canonical_formats_only` is the v2-only marker. - **Non-projectable v1 SHOULD-warn (IR2)**. v1-canonical-mapping.json now normatively requires SDKs to emit a structured warning (carrying format_id, product_id, resolution-failure reason) when a v1 product can't project to a canonical. Prevents silent inventory shrinkage for v2-only buyers. - **`format_schema` fetch contract (IR3)**. Pin normative fetch semantics on `format_schema`: https-only transport, hard-fail on digest mismatch, ≤5s timeout, `$ref` sandboxing (same-origin / AAO-mirror / intra-document only; no `file://`; transitive depth ≤8), graceful degradation on 404 / partition, schema-not-valid hard-fail. Documented in both the schema description and canonical-formats.mdx custom-formats section. - **Codegen-vs-runtime asymmetry (IR6)**. Doc callout that generated TS/Pydantic types lose the `allOf/if/then` conditionals on `format_kind: "custom"` and `result_kind`; runtime Ajv (or equivalent) validation is the gate. Adopters MUST validate at runtime, not rely on the type system. - **`agent_placement` 3.2-track (IR8)**. Explicit description-level stamp that the tracking macro vocabulary, postback shape, and dedup model are intentionally underspecified for 3.1. Adopters claiming the canonical SHOULD set `runtime_status: 'preview'` or `'declared_only'`. Wire contract for tracking events ships in 3.2. - **Migration math reality (IR9)**. Realistic-coverage paragraph in canonical-formats.mdx: 15 registry entries at 3.1, ~76% of audited formats fit but require seller or registry action to project, 71+ v1-only out of gate. Dual-read codepath realistic through 3.3; v2-only realistic at 4.x earliest. -Negative-fixture suite expanded: `format_kind: "custom"` now rejects when `v2_only` is missing or `false`. +Negative-fixture suite expanded: `format_kind: "custom"` now rejects when `canonical_formats_only` is missing or `false`. Conformance-storyboard track (IR4) and adopter-contract docs for `sponsored_placement` (IR5) filed as follow-up issues against 3.1 GA. diff --git a/docs/creative/canonical-formats.mdx b/docs/creative/canonical-formats.mdx index 3b73db629e..6de1758c22 100644 --- a/docs/creative/canonical-formats.mdx +++ b/docs/creative/canonical-formats.mdx @@ -132,7 +132,7 @@ These shapes are real ad-industry product types — but they're either multi-can "format_options": [ { "format_kind": "custom", - "v2_only": true, + "canonical_formats_only": true, "format_shape": "multi_placement_takeover", "format_schema": { "uri": "https://nytimes.adcp/schemas/formats/homepage_takeover_v3", @@ -797,8 +797,8 @@ Products MAY carry both `format_ids` (v1) and `format_options` (v2) during the m - SDKs that derive both shapes from a single source guarantee the invariant. Hand-authored products MUST be reviewed for agreement. - A producer that cannot guarantee agreement MUST emit one shape only. -- For `format_kind: "custom"` declarations, producers MUST set `v2_only: true` and MUST NOT synthesize a v1 `format_id`. The protocol does NOT mint synthetic format_ids (an `aao-synth/*` namespace was considered and rejected — adopters would index on identifiers with no stable identity). -- For `format_options` declarations whose canonical/parameter shape has no clean v1 named-format equivalent (e.g., a structural shape not in `v1-canonical-mapping.json` and not declared on any v1 file), producers SHOULD set `v2_only: true` rather than emit only one of the two shapes silently. +- For `format_kind: "custom"` declarations, producers MUST set `canonical_formats_only: true` and MUST NOT synthesize a v1 `format_id`. The protocol does NOT mint synthetic format_ids (an `aao-synth/*` namespace was considered and rejected — adopters would index on identifiers with no stable identity). +- For `format_options` declarations whose canonical/parameter shape has no clean v1 named-format equivalent (e.g., a structural shape not in `v1-canonical-mapping.json` and not declared on any v1 file), producers SHOULD set `canonical_formats_only: true` rather than emit only one of the two shapes silently. ### Consumer rules (v1→v2) @@ -821,7 +821,7 @@ The schema cannot enforce agreement (no cross-field constraint expresses "the v1 ### v2-only declarations on the wire -A buyer reading a product on the v1 wire path sees `format_options` entries with `v2_only: true` absent from `format_ids`. This is intentional and not a producer error. v2-aware buyers reading `format_options` see them. v1-only buyers see fewer options on `format_ids` than a v2-aware buyer sees on `format_options` for the same product — the v1 surface is a strict subset on these products until v1 sunset (5.0). +A buyer reading a product on the v1 wire path sees `format_options` entries with `canonical_formats_only: true` absent from `format_ids`. This is intentional and not a producer error. v2-aware buyers reading `format_options` see them. v1-only buyers see fewer options on `format_ids` than a v2-aware buyer sees on `format_options` for the same product — the v1 surface is a strict subset on these products until v1 sunset (5.0). ## What's NOT in v2 @@ -857,7 +857,7 @@ The third-party-creative-agent worked example assumes Flashtalking-shaped tools ### Codegen vs runtime: the validator is the gate -`product-format-declaration.json` carries an `allOf/if/then/else` that conditionally requires `format_shape`, `format_schema`, and `v2_only` only when `format_kind === "custom"`. The same pattern applies to `validate-input-result.json`'s `result_kind` discriminator with conditional `violations`. JSON Schema captures these conditionals cleanly, but most codegen pipelines (`json-schema-to-typescript`, `datamodel-codegen`) strip `if/then/else` before emitting types because conditional narrowing doesn't map to TypeScript's structural type system or to Pydantic's class model. The generated types are therefore strictly more permissive than the schema: +`product-format-declaration.json` carries an `allOf/if/then/else` that conditionally requires `format_shape`, `format_schema`, and `canonical_formats_only` only when `format_kind === "custom"`. The same pattern applies to `validate-input-result.json`'s `result_kind` discriminator with conditional `violations`. JSON Schema captures these conditionals cleanly, but most codegen pipelines (`json-schema-to-typescript`, `datamodel-codegen`) strip `if/then/else` before emitting types because conditional narrowing doesn't map to TypeScript's structural type system or to Pydantic's class model. The generated types are therefore strictly more permissive than the schema: - Generated TS / Python types accept a `format_kind: "custom"` declaration that omits `format_shape` or `format_schema` — the type system has no way to narrow on the discriminator and require the conditional fields. - The Ajv (or equivalent) runtime validator IS the gate. SDKs MUST run the JSON Schema validator before trusting a `ProductFormatDeclaration` parsed from the wire; the codegenned type is a convenience layer, not a contract. diff --git a/static/schemas/source/core/product-format-declaration.json b/static/schemas/source/core/product-format-declaration.json index 45a3decea5..0296cf0722 100644 --- a/static/schemas/source/core/product-format-declaration.json +++ b/static/schemas/source/core/product-format-declaration.json @@ -21,10 +21,10 @@ "uniqueItems": true, "description": "Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." }, - "v2_only": { + "canonical_formats_only": { "type": "boolean", "default": false, - "description": "When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: \"custom\"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `v2_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence." + "description": "When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: \"custom\"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence." }, "runtime_status": { "type": "string", @@ -48,9 +48,9 @@ "required": ["format_kind"] }, "then": { - "required": ["format_shape", "format_schema", "v2_only"], + "required": ["format_shape", "format_schema", "canonical_formats_only"], "properties": { - "v2_only": { "const": true } + "canonical_formats_only": { "const": true } } }, "else": { @@ -226,10 +226,10 @@ } }, { - "description": "NYTimes Homepage Takeover — custom format_kind, classified against the multi_placement_takeover format_shape, with format_schema pointing at NYTimes's hosted schema. Buyer agents fetch the schema by uri@digest (cached, immutable) and validate the manifest structurally. `v2_only: true` is required for custom declarations — no v1 named format can express the multi-placement shape.", + "description": "NYTimes Homepage Takeover — custom format_kind, classified against the multi_placement_takeover format_shape, with format_schema pointing at NYTimes's hosted schema. Buyer agents fetch the schema by uri@digest (cached, immutable) and validate the manifest structurally. `canonical_formats_only: true` is required for custom declarations — no v1 named format can express the multi-placement shape.", "data": { "format_kind": "custom", - "v2_only": true, + "canonical_formats_only": true, "format_shape": "multi_placement_takeover", "format_schema": { "uri": "https://nytimes.adcp/schemas/formats/homepage_takeover_v3", diff --git a/tests/canonical-negative-fixtures.test.cjs b/tests/canonical-negative-fixtures.test.cjs index 1791855d08..2ec4dbe173 100644 --- a/tests/canonical-negative-fixtures.test.cjs +++ b/tests/canonical-negative-fixtures.test.cjs @@ -109,14 +109,14 @@ const NEGATIVE_CASES = { expected: true, doc: { format_kind: 'custom', - v2_only: true, + canonical_formats_only: true, params: { foo: 'bar' }, format_shape: 'multi_placement_takeover', format_schema: { uri: 'https://x.example/s', digest: SHA }, }, }, { - label: 'format_kind=custom rejects missing v2_only', + label: 'format_kind=custom rejects missing canonical_formats_only', expected: false, doc: { format_kind: 'custom', @@ -126,11 +126,11 @@ const NEGATIVE_CASES = { }, }, { - label: 'format_kind=custom rejects v2_only=false', + label: 'format_kind=custom rejects canonical_formats_only=false', expected: false, doc: { format_kind: 'custom', - v2_only: false, + canonical_formats_only: false, params: { foo: 'bar' }, format_shape: 'multi_placement_takeover', format_schema: { uri: 'https://x.example/s', digest: SHA }, From d46f83cca56124e21f4d764afec05c97d270ff8e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 16 May 2026 08:51:35 -0400 Subject: [PATCH 33/41] =?UTF-8?q?feat(canonical-formats):=20second=20exper?= =?UTF-8?q?t-review=20pass=20on=20PR=20#3307=20=E2=80=94=20wire-level,=20s?= =?UTF-8?q?ecurity,=20narrowing,=20auto-promote-stable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triaged from 5 expert reviews + a second SDK-implementor review. 4 blocks of inline fixes; 3 follow-ups filed (#4591 scope extended, #4592, #4599). Block A — security hardening on format_schema fetch: - SSRF deny-list (RFC 1918, loopback, link-local, CGNAT, RFC 6761 names; cloud metadata IPs); DNS rebinding defense via IP pinning - No HTTP redirects; 1 MiB response cap; schema-compile DoS controls (keyword count, $ref count, regex timeout, validation budget) - Digest format pinned (sha256: + 64 lowercase hex); $ref sandbox normalized per RFC 3986 - Transport rules apply to BOTH format_schema (load-bearing) AND platform_extensions (informational); platform-extension-ref.json uri pattern: ^https:// Block B — wire-level load-bearing concerns: - B1: per-slot consumed_for_production boolean on _base.json#slots — dispatch hint for build_creative and v1↔v2 translators - B2: SHOULD-warn surfaces via errors[] envelope (not logger-only); 2 new error codes (FORMAT_PROJECTION_FAILED, FORMAT_DECLARATION_DIVERGENT) - B3: open-enum guidance on canonical-format-kind.json — consumers MUST treat as open; unknown values retained + treated as runtime_status: declared_only - B4: validate_input request renamed to discriminated targets[] mirroring response shape; eliminates wire-shape collision with Product.format_ids Block C — auto-promote-stable + correctness + narrowing semantics: - C1: 6 canonicals to status: stable at GA (image, display_tag, video_hosted, video_vast, audio_hosted, audio_daast — Track-A v1-translatable); 5 stay preview (html5, image_carousel, sponsored_placement, responsive_creative, agent_placement) - C2: negative-test AJV now uses discriminator: true matching positive suite - C3: new format_kind: custom fixture (nytimes_homepage_takeover_custom.json) for the riskiest oneOf branch - C5: positive controls for canonical_formats_only on non-custom branches - C6 (N1): formal "narrows" definition — parameter-by-parameter subsumption rules for dual-emission divergence detection - C7 (N3): canonical_parameters drift contract — SDKs SHOULD lint-time check, producers SHOULD prefer derived over authored - C8 (N7): asset_group_id alias collision precedence — declaration order authoritative, collisions surfaced via FORMAT_PROJECTION_FAILED - C9 (N8): declared_only SHOULD-filter by default on buyer SDKs (opt-in to surface) Block D — docs structural pass (selective): - TL;DR block at top of canonical-formats.mdx — 6/11 stable at GA, 71+ v1-only out of gate, Phase 4 codegen is gating dep - Codegen-vs-runtime promoted from H3-buried to its own H2 - "canonical formats" used as primary body term; v1↔v2 reserved for schema-description shorthand Tests: 14 positive + 15 negative fixtures all green under discriminator: true strict mode. Follow-ups: #4591 (storyboard matrix scope extended), #4592 (sponsored_placement adapter docs), #4599 (synthetic v1 format_id docs) Co-Authored-By: Claude Opus 4.7 (1M context) --- ...canonical-formats-expert-review-round-2.md | 53 +++++++++++++++ docs/creative/canonical-formats.mdx | 50 ++++++++++---- .../nytimes_homepage_takeover_custom.json | 67 +++++++++++++++++++ .../source/core/canonical-format-kind.json | 2 +- static/schemas/source/core/format.json | 2 +- .../source/core/platform-extension-ref.json | 3 +- .../core/product-format-declaration.json | 4 +- .../creative/validate-input-request.json | 64 ++++++++++++++---- static/schemas/source/enums/error-code.json | 16 ++++- .../source/formats/canonical/_base.json | 7 +- .../source/formats/canonical/audio_daast.json | 4 ++ .../formats/canonical/audio_hosted.json | 4 ++ .../source/formats/canonical/display_tag.json | 4 ++ .../source/formats/canonical/image.json | 4 ++ .../formats/canonical/video_hosted.json | 4 ++ .../source/formats/canonical/video_vast.json | 4 ++ .../registries/v1-canonical-mapping.json | 2 +- tests/canonical-negative-fixtures.test.cjs | 12 +++- 18 files changed, 271 insertions(+), 35 deletions(-) create mode 100644 .changeset/canonical-formats-expert-review-round-2.md create mode 100644 static/examples/products/canonical/nytimes_homepage_takeover_custom.json diff --git a/.changeset/canonical-formats-expert-review-round-2.md b/.changeset/canonical-formats-expert-review-round-2.md new file mode 100644 index 0000000000..1573ebfab9 --- /dev/null +++ b/.changeset/canonical-formats-expert-review-round-2.md @@ -0,0 +1,53 @@ +--- +"@adcontextprotocol/adcp": minor +--- + +canonical-formats: second expert-review pass on PR #3307 — wire-level concerns, security hardening, auto-promote-stable, and narrowing semantics. + +Triaged from 5 expert reviews (ad-tech-protocol-expert, adtech-product-expert, code-reviewer, docs-expert, security-reviewer) plus a second SDK-implementor review. Items merged into 4 blocks of inline fixes; 3 follow-up issues filed (#4591 scope extended, #4592, #4599). + +## Block A — security hardening on `format_schema` fetch + +- **SSRF deny-list** (normative): RFC 1918 / loopback / link-local / CGNAT / RFC 6761 special-use rejected; cloud metadata endpoints (169.254.169.254, metadata.google.internal, kubernetes.default.svc) explicitly forbidden; resolved IP pinning to defeat DNS rebinding. +- **No HTTP redirects** on these fetches — open redirects on same-origin paths are a free SSRF primitive. +- **1 MiB response cap**, enforced during streaming. +- **Schema-compile DoS controls**: keyword count ≤10 000, `$ref` count ≤256, `pattern` regex via re2 or per-pattern timeout, per-manifest validation budget ≤250 ms. +- **Digest format pinned**: `sha256:` + 64 lowercase hex. +- **`$ref` sandbox**: same-origin (after RFC 3986 §6 normalization) / AAO mirror / intra-document JSON Pointer only; cross-origin and `file://` rejected. +- **Transport rules apply to BOTH** `format_schema` (load-bearing) AND `platform_extensions` (informational) — shared fetch path can't drop to the weakest bar. `platform-extension-ref.json` `uri` gets `pattern: "^https://"` for defense-in-depth. + +## Block B — wire-level load-bearing concerns + +- **B1 per-slot `consumed_for_production` boolean** on `_base.json#slots`: dispatch hint for build_creative and v1↔v2 translators so they know which slots are render-verbatim vs production-consumed (host-read script vs MP3 file, brief vs generative video, catalog feed vs SKU placement). Without this the dispatch table lives in adopter code and every SDK gets it slightly different. +- **B2 SHOULD-warn surfaces via `errors[]` envelope**, not logger-only. Two new error codes added to `error-code.json`: `FORMAT_PROJECTION_FAILED` (v1 format can't project to canonical) and `FORMAT_DECLARATION_DIVERGENT` (dual-emitted format_ids + format_options disagree). Both non-fatal — sellers don't flip transport failure markers. +- **B3 open-enum guidance** on `canonical-format-kind.json`: consumers MUST treat the enum as open; unknown `format_kind` values MUST be retained on the in-memory object and SHOULD be treated as `runtime_status: declared_only` for routing. Prevents every future canonical from being a breaking change for older SDKs. +- **B4 `validate_input` request renamed to discriminated `targets[]`**, mirroring the response shape. Eliminates wire-shape collision between request `format_ids: string[]` and `Product.format_ids: FormatId[]`. + +## Block C — auto-promote-stable + correctness + narrowing semantics + +- **C1 6-canonical Track-A promotion to `stable` at 3.1 GA**: `image`, `display_tag`, `video_hosted`, `video_vast`, `audio_hosted`, `audio_daast` — re-encodings of IAB/VAST/DAAST that round-trip cleanly through `v1-canonical-mapping.json`. 5 canonicals stay Track-B preview (`html5` lossy round-trip, `image_carousel` no v1 mapping entry, `sponsored_placement` adapter-contract dependency #4592, `responsive_creative` algorithmic, `agent_placement` 3.2-track per IR8). Each Track-A canonical carries `default: "stable"` on its own `status` field overriding `_base.json`'s `default: "preview"`. +- **C2 negative-test AJV** now constructed with `discriminator: true` matching the positive suite's strict-mode config. +- **C3 new `format_kind: "custom"` fixture** (`nytimes_homepage_takeover_custom.json`) covers the riskiest oneOf branch with `canonical_formats_only: true` + `format_shape` + `format_schema`. +- **C5 positive controls** added for `canonical_formats_only` on non-custom branches (Track-B canonicals MAY ship as v2-only). +- **C6 (N1) formal "narrows" definition** in canonical-formats.mdx: parameter-by-parameter subsumption rules (scalar containment, enum subset, range inclusion, asymmetric narrowing, conflict detection) so SDKs implementing dual-emission divergence detection don't each invent their own. +- **C7 (N3) `canonical_parameters` drift contract**: hand-authored values MUST satisfy the narrows relation against v1 `requirements`; SDKs SHOULD lint-time check and emit `FORMAT_PROJECTION_FAILED` on divergence. Producers SHOULD prefer to omit and let SDKs derive the v2 shape from v1 `requirements` + `assets[*]` rather than hand-author both shapes. +- **C8 (N7) `asset_group_id` alias collision precedence**: v1 `assets[*]` declaration order is authoritative; first slot wins; subsequent collisions dropped and surfaced via `FORMAT_PROJECTION_FAILED` with structured `error.details`. +- **C9 (N8) `declared_only` SHOULD-filter by default** on buyer SDKs (opt-in to surface); without the default filter the value is a doc string adopters ignore. + +## Block D — docs structural pass (selective) + +- **TL;DR block hoisted to top of canonical-formats.mdx**: 6/11 stable at GA, v1 stays first-class, 71+ v1-only at GA, Phase 4 codegen is the gating dep. Cold-read reader gets the load-bearing facts in the first 60 seconds instead of buried at line 880. +- **Codegen-vs-runtime promoted to H2** (was buried as H3 under Migration). +- **Naming-note tightened**: doc body uses "canonical formats" consistently; "v1↔v2" reserved as schema-description shorthand only. + +## Follow-up issues filed (GA-blocking but not 3.1-beta-merge-blocking) + +- **#4591 extended** with the (canonical × production_source) storyboard matrix scope (N5 from second implementor review). +- **#4592** sponsored_placement adapter-contract docs (IR5 from first round). +- **#4599** v2→v1 demotion synthetic format_id docs note (N2 from second implementor review). + +## Test coverage + +- 14 positive canonical-formats fixtures (added 1 custom-shape fixture) +- 15 negative product-format-declaration fixtures (added 2 for `canonical_formats_only` semantics on non-custom) +- All schema tests pass under `discriminator: true` strict mode diff --git a/docs/creative/canonical-formats.mdx b/docs/creative/canonical-formats.mdx index 6de1758c22..6e29f00249 100644 --- a/docs/creative/canonical-formats.mdx +++ b/docs/creative/canonical-formats.mdx @@ -7,9 +7,15 @@ testable: true # Canonical Formats (preview) -> **Status:** Preview track. The canonical-formats surface is being designed in flight against [RFC #3305](https://github.com/adcontextprotocol/adcp/issues/3305) and the [#3307 implementation branch](https://github.com/adcontextprotocol/adcp/pull/3307). The v1 named-format model (`format_id` as `{ agent_url, id }`) remains a first-class path through 4.x with a 5.0 sunset; canonical formats are opt-in and additive at the schema layer. **The typed-tagged-union ergonomics this design earns require SDK codegen to deliver — Phase 4 (TypeScript and Python codegen) is the gating dependency for adopter consumption. Until Phase 4 ships, adopters can build against the schemas directly, but the buyer-side mental simplification this design promises lands fully only with codegen.** +> **TL;DR for adopters reading cold:** +> - **6 of 11 canonical formats ship `stable` at 3.1 GA** (`image`, `display_tag`, `video_hosted`, `video_vast`, `audio_hosted`, `audio_daast` — all re-encodings of IAB/VAST/DAAST). 5 stay `preview` past GA (`html5`, `image_carousel`, `sponsored_placement`, `responsive_creative`, `agent_placement`). +> - **v1 named formats stay first-class** through 4.x with a 5.0 sunset floor. Dual-emission is the migration mode; SDKs translate either direction. Realistic v2-only buyer-agent timing is 4.x at the earliest. +> - **15 v1→v2 mapping registry entries at GA, 71+ of audited v1 formats are v1-only** out of the gate (need seller `canonical` field or registry PR to project to v2). v2-aware buyers see meaningfully thinner inventory than v1-aware ones through 3.x. Dual-read codepath realistic through 3.3. +> - **Phase 4 (SDK codegen) is the gating dependency for adopter consumption.** Schemas are shippable today; the typed-tagged-union ergonomics this design earns land fully only with codegen. The runtime Ajv validator is the load-bearing gate — generated TS/Pydantic types lose `if/then` narrowing on `format_kind: "custom"` and `result_kind`. + +> **Status:** Preview track. The canonical-formats surface is being designed in flight against [RFC #3305](https://github.com/adcontextprotocol/adcp/issues/3305) and the [#3307 implementation branch](https://github.com/adcontextprotocol/adcp/pull/3307). > -> *Naming note*: This work was originally drafted as "creative formats v2" — the v1↔v2 contrast describes the two format-authoring models (legacy named-format registry vs new canonical formats on products). To avoid collision with AdCP-the-protocol's own version numbering (currently 3.x), file paths and references use **canonical formats** terminology. The v1↔v2 contrast remains useful narrative shorthand inside schema descriptions where it disambiguates the two authoring paths on `Product.format_ids` vs `Product.format_options`. +> *Naming note*: This work was originally drafted as "creative formats v2" — the v1↔v2 contrast describes the two format-authoring models (legacy named-format registry vs new canonical formats on products). To avoid collision with AdCP-the-protocol's own version numbering (currently 3.x), file paths, identifiers, and the body of this doc use **canonical formats** terminology. The v1↔v2 contrast is reserved as schema-description shorthand where it disambiguates the two authoring paths on `Product.format_ids` vs `Product.format_options`. Canonical formats collapse today's separate format registry into product-bound declarations. AdCP defines a small set of **canonical formats** (universal building blocks); sellers' products carry inline `ProductFormatDeclaration`s that narrow canonicals with platform-specific parameters. Creative agents become transformation services declaring `build_creative` capabilities targeting canonical formats. Most existing concepts (CTAs, destinations, tracking, brand identity) are reused or stay in their current homes — canonical formats don't create a new vocabulary layer for those. @@ -161,16 +167,19 @@ Three required pieces when `format_kind: "custom"`: ### `format_schema` fetch contract (normative) -`format_schema` gates validation — without the schema, a buyer cannot reason about the custom shape. The fetch contract is tighter than `platform_extensions` (which is informational): +`format_schema` gates validation — without the schema, a buyer cannot reason about the custom shape. The **transport** rules below apply identically to BOTH `format_schema` and `platform_extensions` (any SDK fetching a `platform-extension-ref.json` URI applies the same rules — a shared fetch path that drops to the weakest bar undermines `format_schema`'s hardening). The **consumption** distinction (`format_schema` is load-bearing, `platform_extensions` is informational) is about *what the body means*, not about how it's fetched. - **Transport**: `https://` only. `http://`, `file://`, `data:`, and other schemes MUST be rejected. -- **Digest mismatch is a hard fail.** SHA-256 of the fetched body MUST equal `format_schema.digest`. On mismatch, the buyer MUST treat the declaration as unresolvable. No fallback to the unverified body. +- **SSRF protection**: resolved hostname MUST NOT land on RFC 1918 (10/8, 172.16/12, 192.168/16), loopback (127/8, ::1), link-local (169.254/16, fe80::/10), CGNAT (100.64/10), or RFC 6761 special-use names (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Connection MUST be pinned to the resolved IP (or re-resolved and re-validated per request) to defeat DNS rebinding. +- **No redirects.** HTTP redirects MUST be disabled on these fetches. Open redirects on same-origin paths are otherwise a free SSRF primitive. +- **1 MiB response cap.** Enforce during streaming. Over-cap = hard fail. +- **Digest mismatch is a hard fail.** SHA-256 of the body MUST equal `format_schema.digest` (`sha256:` + 64 lowercase hex). On mismatch, the buyer MUST treat the declaration as unresolvable. No fallback to the unverified body. Sustained mismatch (vs network flap) MUST be distinguishable in telemetry — it's a substitution-attack signal. - **Timeout** ≤5s recommended. Timeout treated as a 5xx (transient — retry or skip). -- **`$ref` sandboxing**: fetched schemas MAY use `$ref`, but only to (a) same-origin URIs, (b) the AAO mirror namespace, or (c) intra-document JSON Pointer refs. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected. Transitive `$ref` depth bounded (SDKs SHOULD cap at 8). -- **Cache** by `uri@digest`, immutable. On 404 / partition / persistent failure: skip the declaration for this session, surface a structured warning, do NOT fail the whole `get_products` response. -- **Schema validity**: fetched body must be a valid JSON Schema (Draft 07 or 2020-12). Invalid schema → same as digest mismatch (unresolvable, warning, skip). - -These rules apply specifically to `format_schema`. `platform_extensions` keep their looser informational contract; `format_schema` is the load-bearing one. +- **`$ref` sandboxing**: fetched schemas MAY use `$ref`, but only to (a) same-origin URIs after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, no userinfo), (b) the AAO mirror namespace, or (c) intra-document JSON Pointer refs bounded to the parent document. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected. Transitive `$ref` depth ≤8 AND total `$ref` count ≤256 across the resolved tree (depth alone is not enough — depth 8 × breadth 100 = 10^16 nodes). +- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with `re2` OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded → invalid + telemetry signal). Without these, a "valid" schema with catastrophic regex backtracking pins a CPU forever. +- **Cache** by `uri@digest`, immutable. On 404 / partition / persistent failure: skip the declaration for this session, surface via `errors[]`, do NOT fail the whole `get_products` response. +- **Schema validity**: fetched body must be a valid JSON Schema (Draft 07 or 2020-12). Invalid schema → same as digest mismatch (unresolvable, surface via `errors[]`, skip). +- **AAO mirror**: `https://mirror.adcontextprotocol.org/*` is a single trust anchor in the allowlist; compromise of the mirror or its CA compromises every buyer agent. Mirror-served bodies are digest-pinned identically to origin fetches. Signed-body + transparency-log hardening is tracked as a 3.2 follow-up. ### Why custom + format_schema instead of `ext` @@ -807,18 +816,35 @@ When reading a product on the v1 path, SDKs project `format_ids` to `format_opti 1. Explicit `canonical` field on the v1 format declaration (seller-declared, highest priority). 2. `format_id_glob` match in the registry. 3. Structural match in the registry. -4. **Fail closed**: SDK MUST NOT synthesize a `format_options` entry. SDKs SHOULD emit a structured warning (telemetry / logger) so adopters can spot non-projectable inventory; absence is silent loss of inventory visibility for v2-only buyers. +4. **Fail closed**: SDK MUST NOT synthesize a `format_options` entry. SDKs MUST surface the failure via the `errors[]` array on the `get_products` response envelope with `code: FORMAT_PROJECTION_FAILED` (NOT logger-only — a buyer that silently sees N fewer products has no remediation path; the warning needs to reach the response, not just the log file). The advisory is non-fatal: the response stays 200/success, the product is still valid on the v1 path, only the v2 `format_options` projection is absent. ### Consumer rules (divergence detection) When a product carries BOTH `format_ids` and `format_options` and the two disagree (different canonical, different dimensions, different orientation, etc.): - SDKs MUST treat this as a producer contract violation. -- SDKs SHOULD prefer `format_options` (v2 is the richer surface) and emit a structured warning identifying the divergent product. Hard-failing the entire `get_products` response is discouraged — it punishes downstream buyers for a producer bug. +- SDKs SHOULD prefer `format_options` (canonical formats are the richer surface) and SHOULD surface the divergent product via the response's `errors[]` array with `code: FORMAT_DECLARATION_DIVERGENT` (NOT logger-only — operators need wire-visible signal). Hard-failing the entire `get_products` response is discouraged — it punishes downstream buyers for a producer bug. - SDKs MUST NOT silently pick one shape and discard the other without surfacing the divergence to the calling agent. The schema cannot enforce agreement (no cross-field constraint expresses "the v1 mapped form of `format_ids[i]` must equal `format_options[j]`"). Consumer-side detection is the only line of defense; SDK conformance suites SHOULD include divergence fixtures. +### "Narrows" — formal definition (normative) + +When the spec says a v2 `format_options` entry MUST refer to the same underlying declaration as a v1 `format_ids` entry on the same product (dual-emission invariant), or when an SDK compares an authored v2 declaration against the registry-projected one to detect divergence, the comparison MUST follow this definition: + +**v2.params *narrows* the v1-projected baseline** when every parameter present on v2.params is structurally a subset of the equivalent v1 requirement after registry expansion. Specifically: + +- **Scalar constraints**: a v2 scalar value `narrows` a v1 range when the v2 value is contained within the v1 range. `v2.width: 300` narrows `v1.width_range: [200, 400]`; `v2.duration_ms_exact: 30000` narrows `v1.min_duration_ms: 3000`. +- **Enum constraints**: v2.enum_value is a `narrowing` if it appears in v1.allowed_values (or v1.allowed_values is absent — open enum). `v2.image_formats: ["jpg", "png"]` narrows `v1.image_formats: ["jpg", "png", "gif", "webp"]`. +- **Range constraints**: v2.range narrows v1.range when v2's lower bound ≥ v1's lower bound AND v2's upper bound ≤ v1's upper bound. `v2.duration_ms_range: [5000, 30000]` narrows `v1.duration_ms_range: [3000, 90000]`. +- **Absent v2 parameter**: when v2.params omits a parameter that v1 specified, v2 inherits v1's value (no narrowing constraint added). Producers SHOULD omit rather than restate v1 defaults. +- **Asymmetric narrowing**: when v1 says nothing about a parameter but v2 specifies one (e.g., v1 has no `image_formats` constraint, v2 declares `image_formats: ["jpg"]`), v2 is `narrowing` against the implicit "any value" v1 baseline. This is the expected v2-tightens-v1 pattern. +- **Conflict**: any v2 parameter value that falls outside the corresponding v1 constraint is a *conflict*, not narrowing. SDKs MUST treat conflict between dual-emitted shapes as divergence and surface via `FORMAT_DECLARATION_DIVERGENT`. + +The narrows relation is one-directional: v2 narrows v1 (v2 is the stricter shape). The reverse (v1 narrows v2) is NOT how the dual-emission contract is checked. + +SDK authors implementing the narrowing check SHOULD apply parameter-by-parameter subsumption per the rules above. Edge cases (composite parameters, platform_extensions, slot vocabulary changes) are not yet specified; SDKs MAY treat them as "unknown — pass" for 3.1 and surface a structured warning, with the working group tightening them per adopter feedback through 3.x. + ### v2-only declarations on the wire A buyer reading a product on the v1 wire path sees `format_options` entries with `canonical_formats_only: true` absent from `format_ids`. This is intentional and not a producer error. v2-aware buyers reading `format_options` see them. v1-only buyers see fewer options on `format_ids` than a v2-aware buyer sees on `format_options` for the same product — the v1 surface is a strict subset on these products until v1 sunset (5.0). @@ -855,7 +881,7 @@ The `*_source` enums (including `seller_pre_rendered_from_brief` and `agent_synt The third-party-creative-agent worked example assumes Flashtalking-shaped tools serve buyers via `build_creative` and let the buyer ship the produced manifest to the seller. Operators reading this should not infer that v2 strips creative agents of their hosting / serving / tracking revenue. Production happens at `build_creative`; the produced manifest can include hosted asset URLs on the creative agent's CDN (Flashtalking-hosted asset URLs in the example), and platform extensions can attach creative-agent-specific tracking (Flashtalking pixel IDs, viewability vendor configurations) that the seller honors at serve time. The v2 disaggregation is conceptual (the spec separates production from serving from tracking) — the operational integration path lets creative agents continue to host and instrument their produced creatives. v2 doesn't dictate where the asset bytes live or whose tracking JS runs; it only formalizes the production-vs-serving boundary that already exists implicitly. -### Codegen vs runtime: the validator is the gate +## Codegen vs runtime: the validator is the gate `product-format-declaration.json` carries an `allOf/if/then/else` that conditionally requires `format_shape`, `format_schema`, and `canonical_formats_only` only when `format_kind === "custom"`. The same pattern applies to `validate-input-result.json`'s `result_kind` discriminator with conditional `violations`. JSON Schema captures these conditionals cleanly, but most codegen pipelines (`json-schema-to-typescript`, `datamodel-codegen`) strip `if/then/else` before emitting types because conditional narrowing doesn't map to TypeScript's structural type system or to Pydantic's class model. The generated types are therefore strictly more permissive than the schema: diff --git a/static/examples/products/canonical/nytimes_homepage_takeover_custom.json b/static/examples/products/canonical/nytimes_homepage_takeover_custom.json new file mode 100644 index 0000000000..78870854f7 --- /dev/null +++ b/static/examples/products/canonical/nytimes_homepage_takeover_custom.json @@ -0,0 +1,67 @@ +{ + "$schema": "/schemas/core/product.json", + "product_id": "nytimes_homepage_takeover_premium", + "name": "NYTimes.com Homepage Takeover (Multi-Placement Sponsorship)", + "description": "24-hour multi-placement homepage takeover on NYTimes.com — bundles a homepage skin, a preroll video on homepage video assets, and a sponsorship-lockup banner adjacent to top-of-page content. Sold as a unit (exclusivity_window_hours: 24). Demonstrates `format_kind: \"custom\"` with `format_shape: multi_placement_takeover` and a `format_schema` URI+digest reference pointing at NYTimes's hosted schema describing the takeover's components. Required `canonical_formats_only: true` — no v1 named format can express the multi-component shape, so SDKs MUST NOT synthesize a v1 `format_id` for this declaration.", + "publisher_properties": [ + { + "publisher_domain": "nytimes.com", + "selection_type": "by_id", + "property_ids": [ + "homepage_above_fold" + ] + } + ], + "channels": [ + "display", + "olv" + ], + "delivery_type": "guaranteed", + "pricing_options": [ + { + "pricing_option_id": "flat_takeover_24h", + "pricing_model": "flat_rate", + "currency": "USD", + "fixed_price": 250000 + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": [ + "daily" + ], + "expected_delay_minutes": 240, + "timezone": "America/New_York", + "supports_webhooks": false, + "available_metrics": [ + "impressions", + "clicks", + "spend", + "ctr", + "viewability" + ], + "date_range_support": "date_range" + }, + "format_options": [ + { + "format_kind": "custom", + "canonical_formats_only": true, + "format_shape": "multi_placement_takeover", + "format_schema": { + "uri": "https://nytimes.adcp/schemas/formats/homepage_takeover_v3", + "digest": "sha256:e1d4f6a9c2b5e8d1f4a7c0b3e6d9f2a5c8b1e4d7f0a3c6b9e2d5f8a1c4b7e0a3" + }, + "capability_id": "nytimes_homepage_takeover_premium", + "display_name": "Homepage Takeover — Premium Sponsorship", + "applies_to_channels": ["display", "olv"], + "params": { + "components": [ + { "placement_type": "homepage_skin", "required": true }, + { "placement_type": "preroll_video", "required": true }, + { "placement_type": "sponsorship_lockup", "required": true } + ], + "exclusivity_window_hours": 24, + "ssl_required": true + } + } + ] +} diff --git a/static/schemas/source/core/canonical-format-kind.json b/static/schemas/source/core/canonical-format-kind.json index 1d669254ac..ae75c15d6a 100644 --- a/static/schemas/source/core/canonical-format-kind.json +++ b/static/schemas/source/core/canonical-format-kind.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/canonical-format-kind.json", "title": "Canonical Format Kind", - "description": "Discriminator value naming one of the 11 canonical creative formats — plus `custom` for adopter-defined shapes that don't fit the canonicals (multi-placement takeover, roadblock, branded content, cross-screen sponsorship, AR lens, etc.). Used by `product-format-declaration.json` (the product's inline format declaration), `creative-manifest.json` (the buyer's v2 manifest path), and any other surface that needs to identify which canonical a payload targets.\n\nWhen `format_kind: \"custom\"`, the declaration MUST also carry `format_shape` (referencing the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) — recognized global pattern this custom shape is an instance of) and `format_schema` (URI+digest reference to a fetchable schema describing the shape's actual `params` and `slots`). Buyer agents fetch the schema, validate manifests structurally, and reason about manifests without per-seller integration code — same mechanic as `platform_extensions`. See [adcp#3666](https://github.com/adcontextprotocol/adcp/issues/3666) for the canonical promotion queue.\n\nThe canonical enum mirrors the `oneOf` branches in `product-format-declaration.json`; keep them in sync.", + "description": "Discriminator value naming one of the 11 canonical creative formats — plus `custom` for adopter-defined shapes that don't fit the canonicals (multi-placement takeover, roadblock, branded content, cross-screen sponsorship, AR lens, etc.). Used by `product-format-declaration.json` (the product's inline format declaration), `creative-manifest.json` (the buyer's v2 manifest path), and any other surface that needs to identify which canonical a payload targets.\n\nWhen `format_kind: \"custom\"`, the declaration MUST also carry `format_shape` (referencing the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) — recognized global pattern this custom shape is an instance of) and `format_schema` (URI+digest reference to a fetchable schema describing the shape's actual `params` and `slots`). Buyer agents fetch the schema, validate manifests structurally, and reason about manifests without per-seller integration code — same mechanic as `platform_extensions`. See [adcp#3666](https://github.com/adcontextprotocol/adcp/issues/3666) for the canonical promotion queue.\n\nThe canonical enum mirrors the `oneOf` branches in `product-format-declaration.json`; keep them in sync.\n\n**Open-enum semantics (normative).** The enum list above is the AdCP 3.1 baseline. New canonical formats promoted from `format_shape` entries (see [#3666](https://github.com/adcontextprotocol/adcp/issues/3666)) extend this enum in subsequent minor releases — adding a value is non-breaking by design. Consumer SDKs MUST treat this enum as **open** at parse time: an unknown `format_kind` value MUST be retained as-is on the in-memory object (not silently dropped or rewritten to `\"custom\"`) and MUST NOT cause the surrounding payload to fail validation. Buyers reading a declaration with an unknown `format_kind` SHOULD treat the declaration as `runtime_status: declared_only` for routing purposes (filter out of default product views; opt-in to surface) so adopters on older SDK versions don't crash on newer canonical values, and adopters on newer SDK versions get a graceful unknown-value handling story. The producer-side enum stays closed (sellers MUST NOT mint ad-hoc `format_kind` values — use `format_kind: \"custom\"` with `format_shape` + `format_schema` for non-registered shapes); the consumer-side enum stays open for forward compatibility.", "type": "string", "enum": [ "image", diff --git a/static/schemas/source/core/format.json b/static/schemas/source/core/format.json index e2d0cd3fde..6579ba1072 100644 --- a/static/schemas/source/core/format.json +++ b/static/schemas/source/core/format.json @@ -678,7 +678,7 @@ }, "canonical_parameters": { "$ref": "/schemas/core/product-format-declaration.json", - "description": "Optional. When `canonical` is set, this field carries the full ProductFormatDeclaration that the SDK projects this v1 format into. Strict-typed via `$ref` to product-format-declaration so the discriminator + canonical-specific params validate consistently. The `format_kind` MUST equal the `canonical` field value (validators enforce). When set, this is the authoritative source for SDK v1→v2 projection — the registry's structural-match parameter inference is bypassed.\n\nUse case: a custom seller format whose v2 narrowing isn't representable purely from registry structural-match (e.g., a takeover that bundles multi-canonical components — set `canonical: \"custom\"` and provide `canonical_parameters` with a fully-formed ProductFormatDeclaration including `format_shape` and `format_schema`)." + "description": "Optional. When `canonical` is set, this field carries the full ProductFormatDeclaration that the SDK projects this v1 format into. Strict-typed via `$ref` to product-format-declaration so the discriminator + canonical-specific params validate consistently. The `format_kind` MUST equal the `canonical` field value (validators enforce). When set, this is the authoritative source for SDK v1→v2 projection — the registry's structural-match parameter inference is bypassed.\n\nUse case: a custom seller format whose v2 narrowing isn't representable purely from registry structural-match (e.g., a takeover that bundles multi-canonical components — set `canonical: \"custom\"` and provide `canonical_parameters` with a fully-formed ProductFormatDeclaration including `format_shape` and `format_schema`).\n\n**Drift contract (normative).** Hand-authored `canonical_parameters` MUST satisfy the *narrows* relation against this v1 format's `requirements` and `assets[*]` shape (see canonical-formats.mdx 'Narrows — formal definition'). Concretely: every parameter value on `canonical_parameters.params` must be a subset of (or compatible with) the corresponding `requirements.*` value on this v1 format. A v1 file that says `requirements: { min_duration_ms: 3000 }` and a `canonical_parameters: { duration_ms_range: [5000, 30000] }` is a drift bug — a v1-side buyer ships a 4s creative that's valid against v1 but the v2-side rejects. SDKs that read this v1 file SHOULD lint-time check the equivalence at build/load and emit `FORMAT_PROJECTION_FAILED` if the two disagree; producers SHOULD prefer to leave `canonical_parameters` absent and let the SDK derive the v2 shape from `requirements` + `assets[*]` rather than hand-author both shapes (the derived path is unambiguously consistent by construction). When the v1 format genuinely needs custom v2-side narrowing (e.g., multi-component takeovers), authoring is unavoidable — in that case the seller MUST keep the two surfaces in sync and SHOULD treat any divergence as a build error in CI." } }, "required": [ diff --git a/static/schemas/source/core/platform-extension-ref.json b/static/schemas/source/core/platform-extension-ref.json index 8d9f5eb591..7e82fe2244 100644 --- a/static/schemas/source/core/platform-extension-ref.json +++ b/static/schemas/source/core/platform-extension-ref.json @@ -9,7 +9,8 @@ "uri": { "type": "string", "format": "uri", - "description": "URL identifying the extension. The URI base is the owning agent's URL; the path identifies the extension within that agent. Example: 'https://meta.adcp/extensions/meta_pixel'." + "pattern": "^https://", + "description": "HTTPS URL identifying the extension. `https://` is mandatory — `http://`, `file://`, `data:`, and other schemes are rejected at the schema layer (defense-in-depth on top of the fetch-contract normative rules). The URI base is the owning agent's URL; the path identifies the extension within that agent. Example: 'https://meta.adcp/extensions/meta_pixel'. The full fetch contract — SSRF allowlist, response-size cap, $ref sandbox, schema-compile bounds — is documented on `product-format-declaration.json#format_schema` and applies to ALL fetches of this reference shape regardless of whether the field is named `format_schema` (load-bearing for validation) or `platform_extensions` (informational); the *transport* rules are identical, only the *consumption* semantics differ." }, "digest": { "type": "string", diff --git a/static/schemas/source/core/product-format-declaration.json b/static/schemas/source/core/product-format-declaration.json index 0296cf0722..43f341853c 100644 --- a/static/schemas/source/core/product-format-declaration.json +++ b/static/schemas/source/core/product-format-declaration.json @@ -30,7 +30,7 @@ "type": "string", "enum": ["stable", "preview", "declared_only"], "default": "stable", - "description": "Adopter-runtime readiness for this product-format declaration. **Distinct from the canonical's `status` field** (which describes whether the v2 working group has stabilized the format definition itself). `runtime_status` describes whether THIS seller's runtime actually honors what they declared on THIS product.\n\n- `stable` (default) — adopter's runtime fully honors the declared format + production source. Buyers can rely on the declaration as a serving contract.\n- `preview` — runtime supports the basic path; some axes (e.g., per-item fan-out under `item_production_model`, brief-driven overrides, advanced `platform_extensions`) may be partial. Buyers SHOULD validate via `validate_input` or sandbox before committing budget.\n- `declared_only` — catalog declaration is forward-looking; runtime does NOT yet implement this path. Buyers MUST treat as informational and confirm via `validate_input` or a sandbox storyboard before purchase. Compliance storyboards SHOULD skip-gate `declared_only` entries gracefully rather than failing.\n\nThe two axes vary independently: a `stable` canonical can have `declared_only` adopters (canonical is settled in spec but adopter hasn't wired runtime yet), and a `preview` canonical can have `stable` adopters (adopter built against the preview shape and their runtime fully honors it). Producers SHOULD set this when their product declaration is aspirational; absence is interpreted as `stable`. Sellers MUST upgrade the value as the runtime catches up; buyers cache it like any other capability field." + "description": "Adopter-runtime readiness for this product-format declaration. **Distinct from the canonical's `status` field** (which describes whether the v2 working group has stabilized the format definition itself). `runtime_status` describes whether THIS seller's runtime actually honors what they declared on THIS product.\n\n- `stable` (default) — adopter's runtime fully honors the declared format + production source. Buyers can rely on the declaration as a serving contract.\n- `preview` — runtime supports the basic path; some axes (e.g., per-item fan-out under `item_production_model`, brief-driven overrides, advanced `platform_extensions`) may be partial. Buyers SHOULD validate via `validate_input` or sandbox before committing budget.\n- `declared_only` — catalog declaration is forward-looking; runtime does NOT yet implement this path. Buyers MUST treat as informational and confirm via `validate_input` or a sandbox storyboard before purchase. Compliance storyboards SHOULD skip-gate `declared_only` entries gracefully rather than failing. **Buyer SDKs SHOULD filter `declared_only` declarations from default product views by default** (opt-in flag to include them) — without the SDK-default filter, `declared_only` is a doc string adopters ignore. With the default filter, sellers get a useful pre-runtime advertising signal (catalog presence) without buyer agents accidentally routing budget against an unimplemented runtime.\n\nThe two axes vary independently: a `stable` canonical can have `declared_only` adopters (canonical is settled in spec but adopter hasn't wired runtime yet), and a `preview` canonical can have `stable` adopters (adopter built against the preview shape and their runtime fully honors it). Producers SHOULD set this when their product declaration is aspirational; absence is interpreted as `stable`. Sellers MUST upgrade the value as the runtime catches up; buyers cache it like any other capability field." }, "format_shape": { "type": "string", @@ -38,7 +38,7 @@ }, "format_schema": { "$ref": "/schemas/core/platform-extension-ref.json", - "description": "REQUIRED when `format_kind: \"custom\"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape's actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://mirror.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that's why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational), so the fetch semantics are tighter:\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK's discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri`, OR (b) hosted under the AAO mirror namespace (`https://mirror.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`). Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded (SDKs SHOULD cap at depth 8) and each fetched component MUST be digest-pinned by the parent schema (publishers SHOULD inline rather than $ref where possible; cross-document $ref is supported but a smaller surface).\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface a structured warning) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface a structured warning. Validators MUST NOT attempt partial validation against an invalid schema." + "description": "REQUIRED when `format_kind: \"custom\"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape's actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://mirror.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that's why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK's discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO mirror namespace (`https://mirror.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document's parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a 'valid' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO mirror trust**: `https://mirror.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the mirror or its CA compromises every buyer agent. Mirror-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the mirror response). Future hardening (signed bodies, transparency log) is tracked separately." } }, "allOf": [ diff --git a/static/schemas/source/creative/validate-input-request.json b/static/schemas/source/creative/validate-input-request.json index 35bb22da80..305fe6e382 100644 --- a/static/schemas/source/creative/validate-input-request.json +++ b/static/schemas/source/creative/validate-input-request.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/creative/validate-input-request.json", "title": "Validate Input Request", - "description": "Request payload for the validate_input task. Lets buyers dry-run a creative manifest against canonical formats and/or specific products before committing to a render. Cheaper than preview_creative (no synthesis cost). Used by build_creative internally to validate inputs before producing output. For genuinely nondeterministic generative platforms (Veo/Sora/Runway-class) where predictive validation is impossible, the platform's own post-synthesis QA loop applies — validate_input is the predictable-case primitive.", + "description": "Request payload for the validate_input task. Lets buyers dry-run a creative manifest against canonical formats and/or specific products before committing to a render. Cheaper than preview_creative (no synthesis cost). Used by build_creative internally to validate inputs before producing output. For genuinely nondeterministic generative platforms (Veo/Sora/Runway-class) where predictive validation is impossible, the platform's own post-synthesis QA loop applies — validate_input is the predictable-case primitive.\n\nThe `targets[]` array is a discriminated list of validation targets, mirroring the response shape on `validate-input-result.json#target`. Each entry has a `kind` (canonical | product | third_party_format) plus a kind-specific identifier. Discriminated-by-kind on both sides eliminates a wire-shape mismatch: a previous draft used `format_ids: string[]` for canonical names alongside `product_ids: string[]`, which collided with `Product.format_ids: FormatId[]` ({agent_url, id}) — codegen would emit the same field name with two different types. Discriminated `targets[]` makes the intent explicit and codegen-clean.", "type": "object", "required": ["manifest"], "properties": { @@ -10,24 +10,60 @@ "$ref": "/schemas/core/creative-manifest.json", "description": "Creative manifest to validate." }, - "format_ids": { + "targets": { "type": "array", - "items": { "type": "string" }, - "description": "Canonical format names to validate against (e.g., ['video_hosted', 'video_vast']). Each is a canonical format identifier or a third-party URI form. Multi-format support enables universal-creative scenarios where one manifest targets multiple sellers' format declarations." - }, - "product_ids": { - "type": "array", - "items": { "type": "string" }, - "description": "Specific product IDs to validate against. The seller validates the manifest against each product's inline ProductFormatDeclaration narrowing of the canonical." + "minItems": 1, + "description": "Discriminated list of validation targets. Each entry mirrors the `target` shape on `validate-input-result.json` so the request/response wire shapes match exactly. Multi-target requests enable universal-creative scenarios where one manifest targets multiple sellers' format declarations in a single round-trip; the response carries one result per target in the same order.", + "items": { + "type": "object", + "required": ["kind", "id"], + "discriminator": { "propertyName": "kind" }, + "oneOf": [ + { + "title": "Canonical Format Target", + "properties": { + "kind": { "type": "string", "const": "canonical" }, + "id": { + "type": "string", + "description": "Canonical format name from `canonical-format-kind.json` (e.g., `image`, `video_hosted`, `audio_daast`). Validators check the manifest against the canonical's parameter schema." + } + }, + "required": ["kind", "id"] + }, + { + "title": "Product Target", + "properties": { + "kind": { "type": "string", "const": "product" }, + "id": { + "type": "string", + "description": "Product ID. Validators check the manifest against the product's inline `ProductFormatDeclaration` narrowing of the canonical (parameter constraints, slot requirements, platform extensions)." + } + }, + "required": ["kind", "id"] + }, + { + "title": "Third-Party Format Target", + "properties": { + "kind": { "type": "string", "const": "third_party_format" }, + "id": { + "type": "string", + "format": "uri", + "description": "URI-form format identifier referencing a third-party format definition (e.g., `https://flashtalking.adcp/formats/image_300x250@sha256:...`). Validators fetch the definition (digest-pinned, cached) and validate the manifest against it." + } + }, + "required": ["kind", "id"] + } + ] + } } }, "additionalProperties": true, "examples": [ { - "description": "Dry-run a video manifest against canonical video_vertical and a specific Meta product", + "description": "Dry-run a video manifest against canonical video_hosted and a specific Meta product in one round-trip", "data": { "manifest": { - "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_30s" }, + "format_kind": "video_hosted", "assets": { "video_main": { "asset_type": "video", @@ -39,8 +75,10 @@ }, "brand": { "domain": "acme.example" } }, - "format_ids": ["video_hosted"], - "product_ids": ["meta_reels_us"] + "targets": [ + { "kind": "canonical", "id": "video_hosted" }, + { "kind": "product", "id": "meta_reels_us" } + ] } } ] diff --git a/static/schemas/source/enums/error-code.json b/static/schemas/source/enums/error-code.json index e2a91725e1..402c3b187b 100644 --- a/static/schemas/source/enums/error-code.json +++ b/static/schemas/source/enums/error-code.json @@ -72,7 +72,9 @@ "AGENT_SUSPENDED", "AGENT_BLOCKED", "CREDENTIAL_IN_ARGS", - "ACTION_NOT_ALLOWED" + "ACTION_NOT_ALLOWED", + "FORMAT_PROJECTION_FAILED", + "FORMAT_DECLARATION_DIVERGENT" ], "enumDescriptions": { "INVALID_REQUEST": "Request is malformed, missing required fields, or violates schema constraints. Recovery: correctable (check request parameters and fix).", @@ -142,7 +144,9 @@ "AGENT_SUSPENDED": "The calling buyer agent's commercial relationship with the seller is temporarily paused — the agent is onboarded but currently suspended. Sibling to `ACCOUNT_SUSPENDED` (account-wide) and `CAMPAIGN_SUSPENDED` (per-plan) but scoped to the agent-relationship axis (orthogonal to any specific account on that agent). The code itself is the discriminator — it does NOT carry an `error.details` payload (mirroring `BILLING_NOT_PERMITTED_FOR_AGENT`'s discriminator-by-code pattern), and MUST NOT carry per-agent commercial state (rate cards, payment terms, credit limit, billing entity, contact channels) since full disclosure of per-agent state in a single probe is a per-agent oracle. Cross-tenant onboarding oracle clamp + channel-coverage requirements (response shape, HTTP/A2A/MCP status, headers, side effects, observability, latency parity, retry-counter side channel) are normative in error-handling.mdx Per-Agent Authorization Gate; this description does not restate them to avoid drift. Recovery: terminal (re-onboarding may resolve the suspension; the agent MUST surface to a human at the buyer rather than auto-retrying — the agent cannot unilaterally lift a suspension, and re-attempts only reinforce the gate).", "AGENT_BLOCKED": "The calling buyer agent's commercial relationship with the seller is permanently denied — the agent is blocked. Sibling to `AGENT_SUSPENDED` on the agent-relationship axis but with no recovery path (a suspension may lift via re-onboarding; a block does not). The code itself is the discriminator — same posture as `AGENT_SUSPENDED`: no `error.details` payload, no per-agent commercial state, cross-tenant onboarding oracle clamp + channel-coverage requirements normative in error-handling.mdx Per-Agent Authorization Gate. Recovery: terminal (no autonomous recovery — the agent MUST surface to a human at the buyer; relationships are reinstated only through offline operator action with the seller, not via any seller-callable AdCP task).", "CREDENTIAL_IN_ARGS": "The seller detected a buyer-principal credential placed in request args (top-level, in `context`, in `ext`, or any other nested location in the task payload) instead of arriving on the transport's authentication channel. Buyer-principal credentials MUST arrive on the transport's authentication channel (`Authorization: Bearer` per RFC 6750 §2 for HTTP, RFC 9421 signature headers for signed requests, MCP/A2A authentication framing per RFC 9728 §3) and MUST NOT travel inside the task payload. Distinct from `AUTH_REQUIRED` (no credentials presented or presented credentials rejected on the transport channel) and `PERMISSION_DENIED` (authenticated caller not authorized for the action). Distinct from the receiver-side credentials carried in `push_notification_config.authentication.credentials`, which configure the seller's webhook callback authentication and are not buyer-principal credentials — those are an explicit carve-out and MUST NOT trigger this code. Sellers SHOULD reject credential-in-args under AdCP 3.1; the requirement upgrades to MUST 90 days after the 3.1 publication date. Recovery: terminal — the agent MUST NOT auto-retry. Auto-retry against this code re-logs the credential on each attempt across the seller's request logs, observability stack, and any LLM-context surfaces in the buyer-side recovery loop, exactly the prompt-injection exfiltration surface that motivated the rule. Wire placement. Sellers MUST flip transport-level failure markers (HTTP 4xx, MCP `isError: true`, A2A `failed`) and populate both layers per the two-layer model in `error-handling.mdx#envelope-vs-payload-errors-the-two-layer-model`. The code itself is the discriminator; no `error.details` shape is defined, and `error.field` MUST NOT echo the offending credential value or any prefix of it (e.g., `\"Bearer ey...\"`) — the path that triggered detection is sufficient (e.g., `request.access_token`, `request.context.credentials`, `request.ext.api_key`). `error.message` MUST be generic and MUST NOT contain credential material. Sellers MUST drop the smuggled credential from logs, audit rows, and observability spans before persisting the rejection — the rejection itself is otherwise an exfiltration surface.", - "ACTION_NOT_ALLOWED": "The requested mutation maps to an action that is not currently available on this media buy. Sellers MUST populate `error.details` with `attempted_action` (the `media_buy_valid_action` value the request maps to), `reason` (an `action-not-allowed-reason` value: `wrong_status`, `not_supported_on_product`, `not_supported_on_buy`, or `mode_mismatch`), and `currently_available_actions` (echo of the buy's resolved `available_actions[]` so the buyer SDK can offer recovery without a separate get_media_buys round-trip). Recovery: correctable when `reason` is `wrong_status` (wait for or transition to an allowed status) or `mode_mismatch` (re-issue through the appropriate flow). Terminal-for-this-buy when `reason` is `not_supported_on_product` or `not_supported_on_buy` — buyers select a different product or renegotiate buy terms." + "ACTION_NOT_ALLOWED": "The requested mutation maps to an action that is not currently available on this media buy. Sellers MUST populate `error.details` with `attempted_action` (the `media_buy_valid_action` value the request maps to), `reason` (an `action-not-allowed-reason` value: `wrong_status`, `not_supported_on_product`, `not_supported_on_buy`, or `mode_mismatch`), and `currently_available_actions` (echo of the buy's resolved `available_actions[]` so the buyer SDK can offer recovery without a separate get_media_buys round-trip). Recovery: correctable when `reason` is `wrong_status` (wait for or transition to an allowed status) or `mode_mismatch` (re-issue through the appropriate flow). Terminal-for-this-buy when `reason` is `not_supported_on_product` or `not_supported_on_buy` — buyers select a different product or renegotiate buy terms.", + "FORMAT_PROJECTION_FAILED": "Non-fatal advisory emitted by sellers (in `get_products` `errors[]`) and by SDKs (locally) when a v1 named format on a product cannot be projected to a canonical-formats `ProductFormatDeclaration` via the resolution order in `v1-canonical-mapping.json` (explicit `canonical` field → format_id_glob → structural match → fail-closed). The product is still valid on the v1 path; only the v2 `format_options` projection failed. `error.field` MUST point at the offending product (e.g., `products[3].format_ids[0]`); `error.details` SHOULD carry `{ format_id, product_id, resolution_failure: \"no_explicit_canonical\" | \"no_registry_match\" | \"no_structural_match\" }` so buyer SDKs can route remediation (suggest the seller add an explicit `canonical` field, or file a registry PR). Wire placement: this is a non-fatal advisory — sellers MUST NOT flip transport-level failure markers; the response is still a 200/success. Buyers SHOULD surface the warning to operators (CI logs, dashboard) so silent v2-inventory-shrinkage gets noticed. Recovery: correctable (seller-side action — add explicit `canonical` field on the v1 format file, or contribute a registry entry). See canonical-formats.mdx 'Dual emission and v2↔v1 projection' for the producer/consumer rules.", + "FORMAT_DECLARATION_DIVERGENT": "Non-fatal advisory emitted by consumer SDKs when a product carries BOTH `format_ids` (v1) AND `format_options` (v2) and the two disagree (different canonical, different dimensions, different orientation) after projection. The producer's contract is that both shapes MUST refer to the same underlying declaration; divergence is a producer bug. SDKs MUST prefer `format_options` (the richer surface) and SHOULD emit this code so the divergent product is observable rather than silently picked-one-and-dropped-other. Hard-failing the entire `get_products` response is discouraged — it punishes downstream buyers for the producer bug. `error.field` MUST point at the offending product; `error.details` SHOULD carry `{ product_id, format_ids, format_options_summary, divergence_reason }` so buyer SDKs can flag the producer for follow-up. Wire placement: non-fatal — sellers SHOULD NOT emit this on their own response (a seller emitting their own divergence is just-fix-it territory); it's primarily a consumer-side SDK signal surfaced in client-side `errors[]` collection. Recovery: correctable but seller-side — buyer can't fix divergent declarations, only flag them." }, "enumMetadata": { "$comment": "Structured recovery classification and remediation hints for each error code. SDKs MUST consume this block instead of parsing 'Recovery: X' from enumDescriptions prose. Each entry is { recovery, suggestion }. recovery is one of: correctable (caller can fix and retry), transient (retry with backoff), terminal (no autonomous recovery - operator intervention required). enumDescriptions is retained for human readability and will continue to carry the canonical narrative; the recovery classification embedded in that prose is normative and MUST match the value here.", @@ -417,6 +421,14 @@ "ACTION_NOT_ALLOWED": { "recovery": "correctable", "suggestion": "branch on error.details.reason: for wrong_status, wait for or transition to a status listed under the action's allowed_statuses; for mode_mismatch, this is a flow switch (not a retry against update_media_buy) — re-issue through the flow named in available_actions[].mode (call create_proposal/finalize_proposal for requires_proposal; await the seller's webhook for requires_approval); for not_supported_on_product or not_supported_on_buy, do not retry — the action is unavailable on this buy and buyer must select a different product or renegotiate" + }, + "FORMAT_PROJECTION_FAILED": { + "recovery": "correctable", + "suggestion": "advisory — seller-side fix needed: ask the seller to add an explicit `canonical` field on the v1 format declaration, or contribute a registry entry (format_id_glob or structural match) to v1-canonical-mapping.json. Do not auto-retry; the product is still valid on the v1 path" + }, + "FORMAT_DECLARATION_DIVERGENT": { + "recovery": "correctable", + "suggestion": "advisory — seller-side fix needed: producer is emitting disagreeing format_ids and format_options for the same product. Buyer SDK SHOULD prefer format_options and surface the divergent product to operators; do not auto-retry" } } } \ No newline at end of file diff --git a/static/schemas/source/formats/canonical/_base.json b/static/schemas/source/formats/canonical/_base.json index 520dd0fccf..9624ff8ef0 100644 --- a/static/schemas/source/formats/canonical/_base.json +++ b/static/schemas/source/formats/canonical/_base.json @@ -9,7 +9,7 @@ "type": "string", "enum": ["stable", "preview", "deprecated"], "default": "preview", - "description": "Stability tier for this canonical format.\n\n**While v2 itself is in preview** (PR #3307 / 3.1 beta cycle, current state): every canonical defaults to `preview`. The status field has no per-canonical differentiation during the preview window — nothing in v2 is locked yet, so claiming any canonical is `stable` would be premature. The field exists for future use after 3.1 GA, when the working group promotes individual canonicals to `stable` based on adopter evidence.\n\n**At 3.1 GA and after**: `stable` means the schema and tracking model are committed; breaking changes go through normal major-version deprecation. `preview` means the parameter shape and tracking model MAY break in a subsequent minor release once 2-3 adopters have built against it; buyers should treat preview canonicals as experimental and plan for migration. `deprecated` means a replacement is available; existing adopters supported through the deprecation cycle but new adoption is discouraged.\n\n**Per-canonical promotion at GA** uses an evidence-based rubric: a canonical promotes from preview to stable when (a) at least 2 production adopters have built against it AND (b) 90 consecutive days have passed without a breaking change to its parameter shape. Pair with `migration_target_version` to indicate when the working group expects to either stabilize or surface a breaking revision." + "description": "Stability tier for this canonical format.\n\n- `stable` — schema and tracking model are committed; breaking changes go through normal major-version deprecation.\n- `preview` — parameter shape and tracking model MAY break in a subsequent minor release once 2-3 adopters have built against it; buyers should treat preview canonicals as experimental and plan for migration.\n- `deprecated` — replacement is available; existing adopters supported through the deprecation cycle but new adoption is discouraged.\n\n**3.1 GA promotion rubric** uses a two-track rule:\n\n*Track A — v1-translatable canonicals promote to `stable` at GA.* Canonicals whose parameter shape round-trips cleanly through `v1-canonical-mapping.json` are re-encodings of IAB/VAST/DAAST specs that have been the same for ≥20 years — calling them `preview` would be misleading (the schema isn't experimental, only the v2 wire shape is). The 6 Track-A canonicals are: `image`, `display_tag`, `video_hosted`, `video_vast`, `audio_hosted`, `audio_daast`. Each carries `default: \"stable\"` on its own `status` field, overriding this base default. The commitment: their parameter names are locked at GA and follow normal major-version deprecation. Track A is exception, not the rule — promoting v2 wrapper fields (`runtime_status`, `canonical_formats_only`, `platform_extensions` shape) is governed by the standard major-version process.\n\n*Track B — non-v1-translatable canonicals stay `preview` past GA and use the evidence-based rubric.* A canonical promotes from preview to stable when (a) at least 2 production adopters have built against it AND (b) 90 consecutive days have passed without a breaking change to its parameter shape. The 5 Track-B canonicals at GA: `html5` (round-trip lossy — OM-SDK / clickTag specifics demote to `platform_extensions`), `image_carousel` (no v1 mapping entry — needs adopter validation), `sponsored_placement` (4 different retail-media adapter contracts under one canonical — gates promotion on the IR5 #4592 adapter-docs work), `responsive_creative` (algorithmic composition, no clean v1 equivalent), `agent_placement` (3.2-track; tracking macro/postback/dedup intentionally underspecified for 3.1). `custom` is preview by default and stays preview indefinitely (it's the meta-shape).\n\nPair `preview` canonicals with `migration_target_version` to indicate when the working group expects stabilization or a breaking revision." }, "since_version": { "type": "string", @@ -87,6 +87,11 @@ "description": { "type": "string", "description": "Human-readable description of what the slot expects from the buyer." + }, + "consumed_for_production": { + "type": "boolean", + "default": false, + "description": "Dispatch hint for `build_creative` and v1↔v2 wire translators: when `true`, the slot's value is consumed as INPUT to a production step (host-read script, brief copy fed to generative synthesis, catalog feed driving per-SKU rendering) and is not rendered verbatim. When `false` (default), the slot's value is rendered verbatim on the placement (image bytes, video file, display tag).\n\nMotivates the v1↔v2 dispatch table: pre-v2 buyers shipped production-consumed inputs separately in a `inputs` map on the build_creative request; v2 collapses inputs and rendered assets into a single `assets` map keyed by `asset_group_id`. SDK translators between v1 and v2 use this flag per canonical to know which assets in the v2 manifest map back to v1 `inputs` vs v1 `assets`. Without the per-slot flag the dispatch table lives in adopter code and every SDK gets it slightly different.\n\nProducers SHOULD set this explicitly on slots whose consumption pattern isn't obvious (host-read scripts on `audio_hosted`, briefs on generative `video_hosted`, catalog feeds on `sponsored_placement`). For canonicals where every slot is render-verbatim (`image`, `display_tag`, `video_vast`), the default `false` is sufficient and the flag MAY be omitted." } }, "additionalProperties": true diff --git a/static/schemas/source/formats/canonical/audio_daast.json b/static/schemas/source/formats/canonical/audio_daast.json index f008149932..643e204152 100644 --- a/static/schemas/source/formats/canonical/audio_daast.json +++ b/static/schemas/source/formats/canonical/audio_daast.json @@ -5,6 +5,10 @@ "description": "DAAST-tag-delivered audio creative (audio analog of VAST). Slot: `daast_tag` (daast asset, URL or inline XML). Tracking model: DAAST events inherent to the spec — `impression`, `firstQuartile`, `midpoint`, `thirdQuartile`, `complete`, `start`, `pause`, `resume`, `mute`, `unmute`, `clickTracking`, `error`. Distinct from `audio_hosted` (direct file with external tracking).", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { + "status": { + "default": "stable", + "description": "Track A (v1-translatable): default `stable` at 3.1 GA. DAAST is an IAB standard (the audio analog of VAST); canonical re-encodes the established spec and is locked at GA. See `_base.json#status` for the rubric." + }, "slots": { "default": [ { "asset_group_id": "daast_tag", "asset_type": "daast", "required": true }, diff --git a/static/schemas/source/formats/canonical/audio_hosted.json b/static/schemas/source/formats/canonical/audio_hosted.json index d9dff0edf3..c89f0b2496 100644 --- a/static/schemas/source/formats/canonical/audio_hosted.json +++ b/static/schemas/source/formats/canonical/audio_hosted.json @@ -5,6 +5,10 @@ "description": "Direct audio creative — buyer ships an `audio` asset (mp3/aac/wav) for asset-driven products, or ships a `script` / `creative_brief` text asset for products where the seller produces audio internally (podcast host-reads, TTS synthesis). Optional companion slots: `companion_image`, `brand_name`, `landing_page_url`. Tracking model: standard impression + completion + companion-image-click pixels via universal_macros. Distinct from `audio_daast` (DAAST tag, inherent DAAST event tracking). For host-reads and synthesized audio, the format declares `audio_source: 'publisher_host_recorded'` or `'agent_synthesized'` plus `buyer_audio_acceptance: 'rejected'`; the format's `slots` declaration enumerates which assets the buyer ships (e.g., `script` text asset for host-reads). The seller decides how to consume each asset (render verbatim vs produce audio from text) — there is no separate manifest 'inputs' map; everything the buyer ships goes in `assets`.", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { + "status": { + "default": "stable", + "description": "Track A (v1-translatable): default `stable` at 3.1 GA. Hosted audio with companion-image trackers is a settled pattern; the parameter shape is locked at GA. See `_base.json#status` for the rubric." + }, "slots": { "default": [ { "asset_group_id": "audio_main", "asset_type": "audio", "required": true }, diff --git a/static/schemas/source/formats/canonical/display_tag.json b/static/schemas/source/formats/canonical/display_tag.json index 3899a81cd9..dbbf8172f1 100644 --- a/static/schemas/source/formats/canonical/display_tag.json +++ b/static/schemas/source/formats/canonical/display_tag.json @@ -5,6 +5,10 @@ "description": "Third-party-served display tag (JS, iframe, or 1×1 redirect). The buyer's adserver hosts the creative; the seller calls the tag URL at impression time. Slot: `tag_url` (url asset with appropriate `url_type`). Tracking model: opaque to seller — third party serves and measures. Click tracking via redirect URL substitution using universal_macros. Distinct from `image` (static asset hosted by seller) and `html5` (zip bundle hosted by seller).", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { + "status": { + "default": "stable", + "description": "Track A (v1-translatable): default `stable` at 3.1 GA. Third-party display-tag delivery is a settled industry pattern; the parameter shape is locked at GA. See `_base.json#status` for the rubric." + }, "slots": { "default": [ { "asset_group_id": "tag_url", "asset_type": "url", "required": true }, diff --git a/static/schemas/source/formats/canonical/image.json b/static/schemas/source/formats/canonical/image.json index 12ebf29ac5..5496844349 100644 --- a/static/schemas/source/formats/canonical/image.json +++ b/static/schemas/source/formats/canonical/image.json @@ -5,6 +5,10 @@ "description": "Static image creative format. Slots: `image_main` (image asset, file or hosted URL), optional `headline` (text), `body_text` (text), `cta` (text/enum), `landing_page_url` (url). Tracking model: impression pixel + click URL via universal_macros, with optional viewability pixel. Distinct from `html5` (interactive bundles) and `display_tag` (third-party served). AR/dimensions narrow to specific sizes via product parameters — covers IAB display sizes (300x250, 728x90, 970x250, etc.) without a separate iab_size enum.", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { + "status": { + "default": "stable", + "description": "Track A (v1-translatable): default `stable` at 3.1 GA. This canonical re-encodes IAB display sizes that have been the same for 20 years; the parameter shape is locked at GA. See `_base.json#status` for the rubric." + }, "slots": { "default": [ { "asset_group_id": "image_main", "asset_type": "image", "required": true }, diff --git a/static/schemas/source/formats/canonical/video_hosted.json b/static/schemas/source/formats/canonical/video_hosted.json index 62ea49d7d2..24efc27a00 100644 --- a/static/schemas/source/formats/canonical/video_hosted.json +++ b/static/schemas/source/formats/canonical/video_hosted.json @@ -5,6 +5,10 @@ "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" }], "properties": { + "status": { + "default": "stable", + "description": "Track A (v1-translatable): default `stable` at 3.1 GA. Hosted video with OM-SDK + external tracker pixels is a settled pattern; the parameter shape is locked at GA. See `_base.json#status` for the rubric." + }, "slots": { "default": [ { "asset_group_id": "video_main", "asset_type": "video", "required": true }, diff --git a/static/schemas/source/formats/canonical/video_vast.json b/static/schemas/source/formats/canonical/video_vast.json index 67b45c542c..a7b4ac8ebb 100644 --- a/static/schemas/source/formats/canonical/video_vast.json +++ b/static/schemas/source/formats/canonical/video_vast.json @@ -5,6 +5,10 @@ "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": { + "status": { + "default": "stable", + "description": "Track A (v1-translatable): default `stable` at 3.1 GA. VAST is an IAB standard since 2008; canonical re-encodes the established spec and is locked at GA. See `_base.json#status` for the rubric." + }, "slots": { "default": [ { "asset_group_id": "vast_tag", "asset_type": "vast", "required": true }, diff --git a/static/schemas/source/registries/v1-canonical-mapping.json b/static/schemas/source/registries/v1-canonical-mapping.json index 6656ffaf28..c5a08b465a 100644 --- a/static/schemas/source/registries/v1-canonical-mapping.json +++ b/static/schemas/source/registries/v1-canonical-mapping.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/registries/v1-canonical-mapping.json", "title": "v1 → v2 Canonical Format Mapping Registry", - "description": "Authoritative AAO-published mapping from v1 named formats to v2 canonical declarations. Used by SDKs to project between wire shapes during the migration window: v1 format on the wire → v2 canonical in memory (or vice versa).\n\n**Resolution order** (per RFC #3305 amendment #3767, normative):\n1. If the v1 format declaration carries an explicit `canonical` field, use it (seller-declared, highest priority).\n2. Else, look up `format_id` in this registry's `format_id_glob` entries.\n3. Else, attempt structural match against this registry's `structural` entries.\n4. Else, fail closed: SDK MUST NOT emit `format_options` for products carrying this format. SDKs MUST surface a structured warning (logger / telemetry signal) carrying the v1 `format_id`, the product_id, and the resolution failure reason (`no_explicit_canonical`, `no_registry_match`, `no_structural_match`). Without the warning, v2-only buyers silently see less inventory than they should — that's a 6-month support case waiting to happen. The warning is the consumer-side counterpart to the producer SHOULD that sellers add an explicit `canonical` field or file a registry PR.\n\n**Match modes:**\n- `format_id_glob` — exact / glob match against the v1 `format_id.id` (the named-format identifier). Covers IAB-conventional sizes, named platform formats, common publisher conventions. Glob syntax: `*` matches any segment.\n- `structural` — match against the format's slot shape, asset types, and version constraints. Catches custom v1 formats that are structurally a standard format under a different name (e.g., `acme_homepage_300x250` is structurally an IAB MREC).\n\n**Governance**: same vocabulary-governance rules as `asset-group-vocabulary.json` and `format-shape-vocabulary.json` — additions land via PR with rationale + ≥1 reference adopter; AAO maintainer review; versioned + content-digested. Entries are additive; once published they are not removed (they may be marked `deprecated: true` if superseded).\n\n**Initial scope (3.1)**: ~15 unambiguous entries covering IAB display sizes, VAST 4.x, DAAST 1.x. Subsequent PRs expand coverage as adopter feedback surfaces patterns. The full v1-format audit dataset (~76% of formats from the 12-platform / 86-format audit in #3305) seeds the long-term roadmap.\n\nDigest the file content (sha256) when emitting in capabilities responses or referencing from SDK output. Buyers cache by `version` + `digest`.", + "description": "Authoritative AAO-published mapping from v1 named formats to v2 canonical declarations. Used by SDKs to project between wire shapes during the migration window: v1 format on the wire → v2 canonical in memory (or vice versa).\n\n**Resolution order** (per RFC #3305 amendment #3767, normative):\n1. If the v1 format declaration carries an explicit `canonical` field, use it (seller-declared, highest priority).\n2. Else, look up `format_id` in this registry's `format_id_glob` entries.\n3. Else, attempt structural match against this registry's `structural` entries.\n4. Else, fail closed: SDK MUST NOT emit `format_options` for products carrying this format. SDKs MUST surface the resolution failure via the `errors[]` array on the `get_products` response envelope (NOT logger-only) with `code: FORMAT_PROJECTION_FAILED`, `field: \"products[N].format_ids[K]\"`, and `error.details: { format_id, product_id, resolution_failure: \"no_explicit_canonical\" | \"no_registry_match\" | \"no_structural_match\" }`. Logger-only warnings die in DEBUG and the buyer that silently sees N fewer products has no remediation path — wire-level surfacing in `errors[]` is the only place the warning durably reaches an operator. The advisory is non-fatal: the response stays 200/success, the product is still valid on the v1 path, only the v2 `format_options` projection is absent. Consumer-side counterpart to the producer SHOULD (sellers should add an explicit `canonical` field or file a registry PR).\n\n**Match modes:**\n- `format_id_glob` — exact / glob match against the v1 `format_id.id` (the named-format identifier). Covers IAB-conventional sizes, named platform formats, common publisher conventions. Glob syntax: `*` matches any segment.\n- `structural` — match against the format's slot shape, asset types, and version constraints. Catches custom v1 formats that are structurally a standard format under a different name (e.g., `acme_homepage_300x250` is structurally an IAB MREC).\n\n**Alias collision precedence (normative).** When a v1 format's `assets[i]` carries multiple `asset_group_id` aliases that resolve to the same canonical asset_group (e.g., two slots both aliasing to `landing_page_url`), the SDK MUST resolve deterministically: the v1 format's `assets[*]` array order is authoritative — the first slot in declaration order wins, subsequent collisions are dropped from the projected v2 manifest and surfaced via `FORMAT_PROJECTION_FAILED` with `error.details: { collision_kind: \"asset_group_id_alias\", asset_group_id, winning_slot_id, dropped_slot_ids }`. SDKs MUST NOT silently pick one and discard the other without surfacing — silent picking creates inter-SDK divergence. Producers SHOULD avoid the collision by deduplicating aliased slots or using distinct `asset_group_id` values when both slots are semantically meaningful.\n\n**Governance**: same vocabulary-governance rules as `asset-group-vocabulary.json` and `format-shape-vocabulary.json` — additions land via PR with rationale + ≥1 reference adopter; AAO maintainer review; versioned + content-digested. Entries are additive; once published they are not removed (they may be marked `deprecated: true` if superseded).\n\n**Initial scope (3.1)**: ~15 unambiguous entries covering IAB display sizes, VAST 4.x, DAAST 1.x. Subsequent PRs expand coverage as adopter feedback surfaces patterns. The full v1-format audit dataset (~76% of formats from the 12-platform / 86-format audit in #3305) seeds the long-term roadmap.\n\nDigest the file content (sha256) when emitting in capabilities responses or referencing from SDK output. Buyers cache by `version` + `digest`.", "version": "1.0.0", "last_updated": "2026-05-01", "type": "object", diff --git a/tests/canonical-negative-fixtures.test.cjs b/tests/canonical-negative-fixtures.test.cjs index 2ec4dbe173..4133a40757 100644 --- a/tests/canonical-negative-fixtures.test.cjs +++ b/tests/canonical-negative-fixtures.test.cjs @@ -54,7 +54,7 @@ function loadAllSchemas(ajv) { walk(SCHEMAS_DIR); } -const ajv = new Ajv({ allErrors: true, strict: false }); +const ajv = new Ajv({ allErrors: true, strict: false, discriminator: true }); addFormats(ajv); ajv.addFormat('uri-template', true); loadAllSchemas(ajv); @@ -141,6 +141,16 @@ const NEGATIVE_CASES = { expected: true, doc: { format_kind: 'image', params: { width: 300, height: 250 } }, }, + { + label: 'format_kind=image with canonical_formats_only=true accepted (Track-B canonical without v1 mapping)', + expected: true, + doc: { format_kind: 'image', canonical_formats_only: true, params: { width: 300, height: 250 } }, + }, + { + label: 'format_kind=image with canonical_formats_only=false accepted', + expected: true, + doc: { format_kind: 'image', canonical_formats_only: false, params: { width: 300, height: 250 } }, + }, ], '/schemas/creative/validate-input-result.json': [ { From f29effdaf35613798b813094d96441432c940ff7 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 16 May 2026 09:33:41 -0400 Subject: [PATCH 34/41] docs(canonical-formats): v2-native seller v1-emission options (closes #4599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugs a coherence hole on PR #3307: the spec rejected an aao-synth/* synthetic namespace but didn't say what a v2-native seller actually does when they need to emit format_ids for v1-only buyers. Adopters would hit this on day 1. Two acceptable answers, both documented inline: 1. Default — canonical_formats_only: true, omit from format_ids. Product is functionally invisible to v1-only buyers but the v1 surface stays clean. 2. Synthesize seller-scoped IDs like acme.adcp/canonical_image_300x250 when the seller wants v1 reach. Constraints normative: - MUST be seller-scoped (never aao-synth/* or cross-seller namespace) - MUST be declared in the seller's published format catalog - Buyers MUST NOT pattern-match on the convention (catalog is authoritative) - Synthetic id + format_options entry must satisfy dual-emission narrowing Closes #4599 (filed earlier this session as a follow-up; small enough to roll in rather than carry as a separate PR). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/creative/canonical-formats.mdx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/creative/canonical-formats.mdx b/docs/creative/canonical-formats.mdx index 6e29f00249..264886e57d 100644 --- a/docs/creative/canonical-formats.mdx +++ b/docs/creative/canonical-formats.mdx @@ -849,6 +849,19 @@ SDK authors implementing the narrowing check SHOULD apply parameter-by-parameter A buyer reading a product on the v1 wire path sees `format_options` entries with `canonical_formats_only: true` absent from `format_ids`. This is intentional and not a producer error. v2-aware buyers reading `format_options` see them. v1-only buyers see fewer options on `format_ids` than a v2-aware buyer sees on `format_options` for the same product — the v1 surface is a strict subset on these products until v1 sunset (5.0). +### v2-native sellers emitting to v1 buyers + +For v2-native sellers whose products do NOT carry preexisting v1 named formats (e.g., a seller that came up after canonical-formats stabilized and authored only `format_options`), the question of what to put in `format_ids` for v1-only buyers has two acceptable answers: + +1. **Default — set `canonical_formats_only: true`, omit from `format_ids`.** v1 buyers see no `format_ids` entry for these declarations. The product is functionally invisible to v1-only buyers, but the v1 surface stays clean (no synthetic identifiers polluting buyer-side allowlists). +2. **Synthesize seller-scoped IDs.** When a seller wants v1-only buyer reach, they MAY mint `format_ids` like `/canonical__` (e.g., `acme.adcp/canonical_image_300x250`). When synthesizing: + - The IDs MUST be seller-scoped (under the seller's own `agent_url`), never under `aao-synth/*` or any cross-seller namespace — the AAO-mirror-style synthetic namespace was considered and rejected because adopters would index on identifiers with no stable identity. + - The IDs MUST be declared in the seller's published format catalog (the static format file referenced by their adcp-resource manifest, the same place `list_creative_formats` reads from) so v1 buyers see consistent identifiers between `Product.format_ids` and the seller's format directory. + - The `format_kind_` convention is a recommendation, not a normative requirement — sellers MAY use any naming convention scoped to their own `agent_url` namespace. Buyers MUST NOT pattern-match on the convention for routing (the seller's catalog is authoritative). + - The synthetic `format_ids` entry and the corresponding `format_options` entry MUST satisfy the same dual-emission narrowing contract as any other dual-emitted product (see the projection rules above). + +Sellers SHOULD pick option (1) as the default and only opt into (2) when they have a concrete v1-buyer relationship to preserve. Option (2) carries the catalog-sync burden indefinitely; option (1) accepts thinner v1 reach as the cost of a cleaner surface. + ## What's NOT in v2 By design, v2 doesn't introduce new vocabulary for things AdCP already handles or that belong elsewhere: From 47a99951ff3f468fd3d404545314b708ec476aaf Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 16 May 2026 09:42:26 -0400 Subject: [PATCH 35/41] fix(ci): correct changeset package name + add error-code drift dispositions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI failures on PR #3307: 1. Changeset CLI rejected both canonical-formats changesets because they declared package `@adcontextprotocol/adcp` which is not the workspace name. The actual package.json `name` is `adcontextprotocol`. Both changesets fixed. 2. error-code-drift lint rejected FORMAT_PROJECTION_FAILED and FORMAT_DECLARATION_DIVERGENT (added on this PR) because they're present in source but absent from origin/3.0.x and had no dispositions entry. Added both with `held-for-next-minor` + target_version=3.1 — this is wire change territory for the canonical-formats v1↔v2 surface; 3.0.x stays clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/canonical-formats-expert-review-round-2.md | 2 +- .changeset/canonical-formats-implementor-review.md | 2 +- scripts/error-code-drift-dispositions.json | 10 ++++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.changeset/canonical-formats-expert-review-round-2.md b/.changeset/canonical-formats-expert-review-round-2.md index 1573ebfab9..8f24e4e905 100644 --- a/.changeset/canonical-formats-expert-review-round-2.md +++ b/.changeset/canonical-formats-expert-review-round-2.md @@ -1,5 +1,5 @@ --- -"@adcontextprotocol/adcp": minor +"adcontextprotocol": minor --- canonical-formats: second expert-review pass on PR #3307 — wire-level concerns, security hardening, auto-promote-stable, and narrowing semantics. diff --git a/.changeset/canonical-formats-implementor-review.md b/.changeset/canonical-formats-implementor-review.md index 0d2a8c7d2e..ffb6189db6 100644 --- a/.changeset/canonical-formats-implementor-review.md +++ b/.changeset/canonical-formats-implementor-review.md @@ -1,5 +1,5 @@ --- -"@adcontextprotocol/adcp": minor +"adcontextprotocol": minor --- canonical-formats: address SDK-team implementor review on PR #3307 with inline normative tightening. diff --git a/scripts/error-code-drift-dispositions.json b/scripts/error-code-drift-dispositions.json index f27654c841..e42b21473c 100644 --- a/scripts/error-code-drift-dispositions.json +++ b/scripts/error-code-drift-dispositions.json @@ -115,6 +115,16 @@ "disposition": "held-for-next-minor", "target_version": "3.1", "note": "Part of agent-permission-denied scope codes (#3887). Held for 3.1." + }, + "FORMAT_PROJECTION_FAILED": { + "disposition": "held-for-next-minor", + "target_version": "3.1", + "note": "Canonical-formats v1→v2 projection advisory (#3307). Non-fatal; surfaces on get_products errors[] when an SDK can't project a v1 named format to a v2 canonical via the registry. Wire change — held for 3.1." + }, + "FORMAT_DECLARATION_DIVERGENT": { + "disposition": "held-for-next-minor", + "target_version": "3.1", + "note": "Canonical-formats dual-emission divergence advisory (#3307). Non-fatal; surfaces when a product carries both format_ids (v1) and format_options (v2) that disagree after projection. Wire change — held for 3.1." } } } \ No newline at end of file From de074fd46e1251c550cff02c0273acdb2b3cac30 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 16 May 2026 09:53:45 -0400 Subject: [PATCH 36/41] refactor(creative): move brand_kit_override from CreativeManifest onto BrandRef MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses nas review on PR #3307: brand_kit_override on creative-manifest.json duplicated a pattern BrandRef already establishes for inline overrides (industries, data_subject_contestation). Move the field to BrandRef so all per-call brand context lives in one place, eliminates the parallel vocabulary on CreativeManifest, and adopters don't have to learn two override patterns. Shape changes: - brand-ref.json: add brand_kit_override property (logo, colors, voice, tagline) next to industries and data_subject_contestation. Same precedence rules: brand.json is canonical, inline override takes precedence for fields present. - creative-manifest.json: drop brand_kit_override; the brand description points at BrandRef's inline override. - docs (canonical-formats.mdx + canonical-formats-migration.mdx): updated the worked example to show brand_kit_override nested inside brand: { domain, brand_kit_override }. Out-of-subset fields (voice_attributes, prohibited_terms, etc.) still require publishing a different brand.json — the inline override is intentionally narrow to a small high-traffic subset. No fixture changes needed (no existing fixture used the old shape). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/creative/canonical-formats-migration.mdx | 16 +++++----- docs/creative/canonical-formats.mdx | 24 ++++++++------- static/schemas/source/core/brand-ref.json | 29 ++++++++++++++++++ .../source/core/creative-manifest.json | 30 +------------------ 4 files changed, 52 insertions(+), 47 deletions(-) diff --git a/docs/creative/canonical-formats-migration.mdx b/docs/creative/canonical-formats-migration.mdx index 4a6728f972..276ee3ea9d 100644 --- a/docs/creative/canonical-formats-migration.mdx +++ b/docs/creative/canonical-formats-migration.mdx @@ -261,22 +261,24 @@ Note the v2 manifest has no separate `inputs` map — the buyer ships the brief v1 formats sometimes redeclared `brand_logo`, `brand_colors`, `brand_voice`, `brand_tagline` as explicit slots. v2 formats don't. When the manifest carries a [`BrandRef`](https://adcontextprotocol.org/schemas/v3/core/brand-ref.json) (`brand: { domain: "acme.com" }`, optionally with `brand_id` for house-of-brands), the seller fetches `brand.json` for context automatically. -For the case where `brand.json` is missing or stale, the manifest carries `brand_kit_override`: +For the case where `brand.json` is missing or stale, the BrandRef itself carries an inline `brand_kit_override` (same inline-override pattern BrandRef already uses for `industries` and `data_subject_contestation`): ```json test=false { "format_id": { "agent_url": "...", "id": "..." }, "assets": { ... }, - "brand": { "domain": "acme.example" }, - "brand_kit_override": { - "logo": { "asset_type": "image", "url": "https://cdn.acme.example/logo-2026.png", "width": 200, "height": 100 }, - "colors": { "primary": "#0066CC", "accent": "#FF6600" }, - "tagline": "Spring savings, all season" + "brand": { + "domain": "acme.example", + "brand_kit_override": { + "logo": { "asset_type": "image", "url": "https://cdn.acme.example/logo-2026.png", "width": 200, "height": 100 }, + "colors": { "primary": "#0066CC", "accent": "#FF6600" }, + "tagline": "Spring savings, all season" + } } } ``` -Override fields take precedence over `brand.json` for that creative. +Override fields take precedence over `brand.json` for the call carrying this BrandRef. ## Tools — what's new vs unchanged diff --git a/docs/creative/canonical-formats.mdx b/docs/creative/canonical-formats.mdx index 264886e57d..40fe957af6 100644 --- a/docs/creative/canonical-formats.mdx +++ b/docs/creative/canonical-formats.mdx @@ -44,7 +44,7 @@ Canonical formats collapse today's separate format registry into product-bound d | **`build_creative`** | Creative-agent surface that produces a manifest from inputs (brief, scenes, brand). Sales agents do NOT expose `build_creative`. | | **`creative.supported_formats`** | Capabilities-response field on creative agents declaring which canonicals they can produce via `build_creative`. | | **`BrandRef`** | `{domain, brand_id?}` reference. Resolves brand context (logos, colors, voice) from `brand.json` automatically. | -| **`brand_kit_override`** | Per-creative override for the case where `brand.json` is missing, stale, or inappropriate. | +| **`brand_kit_override`** | Inline override on `BrandRef` for per-call brand-kit tweaks (logo, colors, voice, tagline) where `brand.json` is missing, stale, or inappropriate. Same pattern as `industries` and `data_subject_contestation` on BrandRef. | | **`fanout_mode`** | On `sponsored_placement`: how items map to delivery — `per_item`, `multi_item_in_creative`, `single_item`. | | **`item_production_model`** | On `sponsored_placement`: how each per-item creative is produced. Captures multi-output generative (1 brief × N items → N creatives). | | **`format_kind: "custom"`** | Adopter-defined shape that doesn't fit the 11 canonicals (multi-placement takeover, branded content, AR lens, etc.). Requires `format_shape` (registry classifier) and `format_schema` (URI+digest reference to a fetchable schema). | @@ -60,7 +60,7 @@ Canonical formats collapse today's separate format registry into product-bound d | Format submission contract | Each platform publishes a parallel set of `*_generated_*` format files for AI-produced creative alongside the asset-upload version (~30 duplicate files in agentic-adapters) | The format declares a single `slots` array enumerating everything the buyer ships in the manifest's `assets` map, each entry a canonical `asset_group_id` paired with an `asset_type` (image / video / audio for direct rendering; text / brief / object / url for content the seller consumes for production). Buyer mental model is uniform — one `assets` map, no separate "inputs" concept. **Whether the seller's internal production is generative AI, host recording, transcoding, or asset rendering is invisible to the buyer.** No "generative" category at the protocol level; the production mechanism is implementation detail. | | Discovery | `list_creative_formats` (overloaded — used by both sales and creative agents) | `creative.supported_formats` on `get_adcp_capabilities` (uniform replacement, same `ProductFormatDeclaration` shape regardless of agent role); sales agents additionally expose `get_products` for product-level detail with `format` inline | | Tracking | Mixed across asset types and format definitions | Baked into each canonical format (VAST events for `video_vast`, MRAID+OM-SDK for `html5`, impression pixel for `image`) | -| Brand identity | Sometimes redeclared as format slots | Implicit via `brand` (a [`BrandRef`](https://adcontextprotocol.org/schemas/v3/core/brand-ref.json) — `domain` plus optional `brand_id` for house-of-brands) resolving brand.json; explicit override via `brand_kit_override` on the manifest | +| Brand identity | Sometimes redeclared as format slots | Implicit via `brand` (a [`BrandRef`](https://adcontextprotocol.org/schemas/v3/core/brand-ref.json) — `domain` plus optional `brand_id` for house-of-brands) resolving brand.json; explicit override via `brand_kit_override` inline on the BrandRef itself | ## The 11 canonical formats @@ -739,24 +739,26 @@ The buyer can iterate on shipped assets and inspect previews before committing t v2 formats no longer redeclare `brand_logo`, `brand_colors`, `brand_voice`, `brand_tagline` as explicit slots. When a manifest carries a [`BrandRef`](https://adcontextprotocol.org/schemas/v3/core/brand-ref.json) like `brand: { domain: "acme.example" }` (or with `brand_id` for house-of-brands), the seller fetches `https://acme.example/.well-known/brand.json` for brand context. -For the case where brand.json is missing or stale, the manifest includes `brand_kit_override`: +For the case where brand.json is missing or stale, the BrandRef itself carries an inline `brand_kit_override`: ```json test=false { - "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "image_300x250" }, + "format_kind": "image", "assets": { "image_main": { "asset_type": "image", "url": "https://cdn.acme.example/banner.jpg", "width": 300, "height": 250 } }, - "brand": { "domain": "acme.example" }, - "brand_kit_override": { - "logo": { "asset_type": "image", "url": "https://cdn.acme.example/logo-2026.png", "width": 200, "height": 100 }, - "colors": { "primary": "#0066CC", "accent": "#FF6600" }, - "tagline": "Spring savings, all season" + "brand": { + "domain": "acme.example", + "brand_kit_override": { + "logo": { "asset_type": "image", "url": "https://cdn.acme.example/logo-2026.png", "width": 200, "height": 100 }, + "colors": { "primary": "#0066CC", "accent": "#FF6600" }, + "tagline": "Spring savings, all season" + } } } ``` -Override fields take precedence over `brand.json` for that creative. +Override fields take precedence over `brand.json` for the call carrying this BrandRef. The pattern matches BrandRef's existing inline overrides (`industries`, `data_subject_contestation`) — brand.json is canonical; inline overrides are per-call. Adopters needing to override brand-kit fields outside this subset (`voice_attributes`, `prohibited_terms`) MUST publish a different brand.json and reference it via a different `domain`. ## Platform extensions — distribution @@ -925,7 +927,7 @@ This is a doc concern, not a schema concern. The schema is more strict than the | Phase | Status | What's in it | |---|---|---| | Phase 1 | ✅ in #3307 | `asset_group_id` vocabulary registry (canonical entries + audit-grounded aliases), `scenes` schema, `zip` asset type, video/audio doc fixes | -| Phase 2 | ✅ in #3307 | 11 canonical format definitions with structured `slots` declaration, `ProductFormatDeclaration` (format_kind discriminator + params), `validate_input` primitive, `creative.supported_formats` on get_adcp_capabilities, `brand_kit_override`, `platform-extension-ref`, typed inline `product_card` / `product_card_detailed`, `format_ids` + `format_options` `anyOf` on Product (dual emission legal during migration per #3765) | +| Phase 2 | ✅ in #3307 | 11 canonical format definitions with structured `slots` declaration, `ProductFormatDeclaration` (format_kind discriminator + params), `validate_input` primitive, `creative.supported_formats` on get_adcp_capabilities, `brand_kit_override` inline on `BrandRef`, `platform-extension-ref`, typed inline `product_card` / `product_card_detailed`, `format_ids` + `format_options` `anyOf` on Product (dual emission legal during migration per #3765) | | Phase 3 | ✅ in #3307 | v1↔canonical-formats migration guide, 12 fully-validated reference Product fixtures + 1 get_products response fixture with bundled extensions, fixture-validation test suite (`npm run test:canonical-fixtures`) | | Phase 4 | ⚠️ blocking adoption | Reference SDK codegen (TypeScript first, then Python), server-side flatten wrapper reference implementation. Without Phase 4, adopters cannot consume v2 cleanly — the typed-tagged-union ergonomics this PR's design earns require codegen to deliver. v2 is opt-in and additive at the schema layer today; Phase 4 makes it usable. | | Native canonical | TBD | Deferred to 3.2 after TemplateCreative + OpenRTB Native 1.2 audit | diff --git a/static/schemas/source/core/brand-ref.json b/static/schemas/source/core/brand-ref.json index 16a686a2a3..df6b7e9637 100644 --- a/static/schemas/source/core/brand-ref.json +++ b/static/schemas/source/core/brand-ref.json @@ -32,6 +32,35 @@ { "required": ["email"] } ], "additionalProperties": false + }, + "brand_kit_override": { + "type": "object", + "description": "Inline override for brand-kit fields normally resolved from `/.well-known/brand.json` on `domain` (logo, colors, voice, tagline). Use when brand.json is missing, stale, or inappropriate for this specific call — e.g., a campaign-scoped tagline, a co-branded creative, a freshly-rebranded color palette the brand.json hasn't shipped yet. Fields present here take precedence over brand.json for the call carrying this BrandRef; absent fields fall back to brand.json. Same inline-override pattern as `industries` and `data_subject_contestation` above: brand.json is canonical, the override is per-call. Adopters needing to override fields outside this subset (`voice_attributes`, `prohibited_terms`, etc.) MUST publish a different brand.json and reference it via a different `domain` — the inline override is intentionally narrow to a small high-traffic subset.", + "properties": { + "logo": { + "$ref": "/schemas/core/assets/image-asset.json", + "description": "Override logo asset." + }, + "colors": { + "type": "object", + "description": "Override brand colors (hex strings).", + "properties": { + "primary": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" }, + "secondary": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" }, + "accent": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" } + }, + "additionalProperties": true + }, + "voice": { + "type": "string", + "description": "Override brand-voice description for surface-composed text/audio output." + }, + "tagline": { + "type": "string", + "description": "Override tagline." + } + }, + "additionalProperties": true } }, "required": ["domain"], diff --git a/static/schemas/source/core/creative-manifest.json b/static/schemas/source/core/creative-manifest.json index 10ef1d38c1..fc4568e79f 100644 --- a/static/schemas/source/core/creative-manifest.json +++ b/static/schemas/source/core/creative-manifest.json @@ -36,35 +36,7 @@ }, "brand": { "$ref": "/schemas/core/brand-ref.json", - "description": "Brand identity reference (BrandRef — `domain` plus optional `brand_id` for house-of-brands). When present, the seller pulls brand context (logos, colors, voice, taglines) from the brand's brand.json automatically. v2 formats no longer redeclare brand_logo / brand_colors / brand_voice as explicit slots — brand identity is implicit context." - }, - "brand_kit_override": { - "type": "object", - "description": "Explicit brand-kit override for the case where brand.json is missing, stale, or inappropriate for this specific creative. When present, takes precedence over brand.json lookups for the supplied fields. Sellers use brand.json as the default and the override as the per-creative authoritative source.", - "properties": { - "logo": { - "$ref": "/schemas/core/assets/image-asset.json", - "description": "Override logo asset for this creative." - }, - "colors": { - "type": "object", - "description": "Override brand colors (hex strings).", - "properties": { - "primary": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" }, - "secondary": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" }, - "accent": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" } - }, - "additionalProperties": true - }, - "voice": { - "type": "string", - "description": "Override brand-voice description for surface-composed text/audio output." - }, - "tagline": { - "type": "string" - } - }, - "additionalProperties": true + "description": "Brand identity reference (BrandRef — `domain` plus optional `brand_id` for house-of-brands; plus optional inline `brand_kit_override` for per-creative tweaks where brand.json is missing/stale). When present, the seller pulls brand context (logos, colors, voice, taglines) from the brand's brand.json automatically; any `brand_kit_override` fields on the BrandRef take precedence. v2 formats no longer redeclare brand_logo / brand_colors / brand_voice as explicit slots — brand identity is implicit context." }, "rights": { "type": "array", From 3bb819ee9fe9d5a32c6322117dbc3d72c109449c Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 16 May 2026 11:01:23 -0400 Subject: [PATCH 37/41] feat(canonical-formats): four inline tightenings from python-implementor review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses gaps surfaced by a third SDK-implementor review (Python/Pydantic angle). Four small normative additions; three reviewer items filed separately as follow-ups. 1. Platform-extension collision precedence (_base.json#platform_extensions). When two platform_extensions[] entries extend the same target with overlapping field names, array order is authoritative — later entries override earlier ones per-field. SDKs surface via errors[] with structured code. Same flavor as the asset_group_id alias collision rule we just landed. 2. brand_kit_override field-level merge (brand-ref.json#brand_kit_override). Now explicit: merge is field-level, not whole-object replacement. Composite fields (colors.primary, colors.secondary, colors.accent) merge one level deeper. SDKs MUST NOT treat a present override.colors as wiping brand.json's colors block entirely; only per-slot fields present in the override take precedence. 3. validate_input scope clarification (validate-input-result.json). validate_input validates manifest STRUCTURE, not output rendering. For composition_model: algorithmic formats (responsive_creative, agent_placement), the asset pool is still structurally validatable (counts/sizes/lengths) → validated_pass / validated_fail. unvalidatable_nondeterministic is reserved for synthesis_nondeterministic: true cases where the production pipeline itself can't be evaluated upfront. Buyers calling validate_input on an algorithmic-composition target SHOULD expect a structural verdict, not a rendering preview. 4. SDK canonical-catalog version negotiation (get-adcp-capabilities-response). Optional canonical_catalog_version (semver-shaped) on capabilities.creative. Lets codegenned SDKs detect canonical-catalog skew between their generated types and the seller's actual support. Pairs with the open-enum semantics on canonical-format-kind.json (B3) — skew is soft-warn, not hard error. All tests green: 7 schema + 14 positive fixtures + 15 negative fixtures + error-code drift. Co-Authored-By: Claude Opus 4.7 (1M context) --- static/schemas/source/core/brand-ref.json | 2 +- static/schemas/source/creative/validate-input-result.json | 2 +- static/schemas/source/formats/canonical/_base.json | 2 +- .../source/protocol/get-adcp-capabilities-response.json | 5 +++++ 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/static/schemas/source/core/brand-ref.json b/static/schemas/source/core/brand-ref.json index df6b7e9637..a56e2440d1 100644 --- a/static/schemas/source/core/brand-ref.json +++ b/static/schemas/source/core/brand-ref.json @@ -35,7 +35,7 @@ }, "brand_kit_override": { "type": "object", - "description": "Inline override for brand-kit fields normally resolved from `/.well-known/brand.json` on `domain` (logo, colors, voice, tagline). Use when brand.json is missing, stale, or inappropriate for this specific call — e.g., a campaign-scoped tagline, a co-branded creative, a freshly-rebranded color palette the brand.json hasn't shipped yet. Fields present here take precedence over brand.json for the call carrying this BrandRef; absent fields fall back to brand.json. Same inline-override pattern as `industries` and `data_subject_contestation` above: brand.json is canonical, the override is per-call. Adopters needing to override fields outside this subset (`voice_attributes`, `prohibited_terms`, etc.) MUST publish a different brand.json and reference it via a different `domain` — the inline override is intentionally narrow to a small high-traffic subset.", + "description": "Inline override for brand-kit fields normally resolved from `/.well-known/brand.json` on `domain` (logo, colors, voice, tagline). Use when brand.json is missing, stale, or inappropriate for this specific call — e.g., a campaign-scoped tagline, a co-branded creative, a freshly-rebranded color palette the brand.json hasn't shipped yet. Same inline-override pattern as `industries` and `data_subject_contestation` above: brand.json is canonical, the override is per-call. Adopters needing to override fields outside this subset (`voice_attributes`, `prohibited_terms`, etc.) MUST publish a different brand.json and reference it via a different `domain` — the inline override is intentionally narrow to a small high-traffic subset.\n\n**Merge semantics (normative).** The merge is **field-level**, not whole-object replacement. Each field within `brand_kit_override` (`logo`, `colors`, `voice`, `tagline`) is evaluated independently — when a field is present on the override the override value applies; when a field is absent the brand.json value applies (or is absent if brand.json doesn't carry one either). For composite fields (`colors.primary`, `colors.secondary`, `colors.accent`), the merge is one level deeper: each color slot is evaluated independently — a producer can override `colors.primary` while still inheriting `colors.secondary` from brand.json. SDKs MUST NOT treat a present `brand_kit_override.colors` as wiping the brand.json `colors` block entirely; only the per-slot fields present in the override take precedence. Without this rule, a partial-override semantics would diverge across SDKs and produce inconsistent rendering for the same payload.", "properties": { "logo": { "$ref": "/schemas/core/assets/image-asset.json", diff --git a/static/schemas/source/creative/validate-input-result.json b/static/schemas/source/creative/validate-input-result.json index 49ad6f2e0c..accb6265cf 100644 --- a/static/schemas/source/creative/validate-input-result.json +++ b/static/schemas/source/creative/validate-input-result.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/creative/validate-input-result.json", "title": "Validate Input Result", - "description": "Per-target result of a validate_input call. The `result_kind` discriminator (replacing the earlier boolean `ok`) lets buyers distinguish three meaningfully different outcomes:\n\n- `validated_pass` — manifest validates cleanly against the target. Buyers can submit with confidence.\n- `validated_fail` — manifest is structurally evaluable AND fails specific constraints. `violations[]` enumerates which. Buyers fix and retry.\n- `unvalidatable_nondeterministic` — predictive validation is impossible because the target's production pipeline is genuinely nondeterministic (Veo / Sora / Runway-class formats with `synthesis_nondeterministic: true`). The platform's own post-synthesis QA loop applies; outcome is unknowable until `build_creative` runs. Buyers MUST plan for the QA-loop semantics: submission may return `task_failed` with a `synthesis_failed` reason if the QA loop exhausts. There is no protocol state for orphaned out-of-spec artifacts.\n\nThe boolean `ok` field carried in earlier drafts is removed — it conflated `validated_fail` (a real validation result the buyer can act on) with `unvalidatable_nondeterministic` (a structural property of the target the buyer needs to handle differently). `validated_fail` returns `violations[]`; `unvalidatable_nondeterministic` does not (there's nothing to enumerate).", + "description": "Per-target result of a validate_input call. The `result_kind` discriminator (replacing the earlier boolean `ok`) lets buyers distinguish three meaningfully different outcomes:\n\n- `validated_pass` — manifest validates cleanly against the target. Buyers can submit with confidence.\n- `validated_fail` — manifest is structurally evaluable AND fails specific constraints. `violations[]` enumerates which. Buyers fix and retry.\n- `unvalidatable_nondeterministic` — predictive validation is impossible because the target's production pipeline is genuinely nondeterministic (Veo / Sora / Runway-class formats with `synthesis_nondeterministic: true`). The platform's own post-synthesis QA loop applies; outcome is unknowable until `build_creative` runs. Buyers MUST plan for the QA-loop semantics: submission may return `task_failed` with a `synthesis_failed` reason if the QA loop exhausts. There is no protocol state for orphaned out-of-spec artifacts.\n\nThe boolean `ok` field carried in earlier drafts is removed — it conflated `validated_fail` (a real validation result the buyer can act on) with `unvalidatable_nondeterministic` (a structural property of the target the buyer needs to handle differently). `validated_fail` returns `violations[]`; `unvalidatable_nondeterministic` does not (there's nothing to enumerate).\n\n**Scope of validation (normative).** `validate_input` validates **manifest structure** against the canonical / product / third-party format target — slot counts, asset types, parameter ranges, format-shape constraints. It does NOT predict the surface's per-impression rendering choice. This distinction matters for `composition_model: algorithmic` formats (`responsive_creative`, `agent_placement`) where the surface picks combinations or phrasing at delivery time: the buyer's asset pool can still be structurally validated (does it meet the count/size/length requirements?) → `validated_pass` or `validated_fail` with violations. What's unpredictable is the rendered output composition, and that's NOT what `validate_input` claims to predict. `unvalidatable_nondeterministic` is reserved for `synthesis_nondeterministic: true` cases where the production pipeline itself can't be evaluated upfront — distinct from algorithmic composition where the inputs ARE evaluable but the output rendering isn't. Buyers calling `validate_input` on an algorithmic-composition target SHOULD expect a structural verdict (pass/fail), not a rendering preview.", "type": "object", "required": ["target", "result_kind"], "properties": { diff --git a/static/schemas/source/formats/canonical/_base.json b/static/schemas/source/formats/canonical/_base.json index 9624ff8ef0..2a9af14bdd 100644 --- a/static/schemas/source/formats/canonical/_base.json +++ b/static/schemas/source/formats/canonical/_base.json @@ -30,7 +30,7 @@ }, "platform_extensions": { "type": "array", - "description": "Platform-specific extensions narrowing the canonical (pixel ID shapes, conversion event taxonomies, platform-specific CTAs/destinations). Each extension is a URI+digest reference resolved against the bundled `extensions` map in get_products responses or fetched directly.", + "description": "Platform-specific extensions narrowing the canonical (pixel ID shapes, conversion event taxonomies, platform-specific CTAs/destinations). Each extension is a URI+digest reference resolved against the bundled `extensions` map in get_products responses or fetched directly.\n\n**Collision precedence (normative).** When two or more `platform_extensions[]` entries on the same declaration extend the same target (e.g., both extend `tracking`) with overlapping field names, **array order is authoritative — later entries override earlier ones on a per-field basis** (last-in-array-wins). SDKs MUST surface the overlap via the `errors[]` array on the `get_products` response with a structured code (`FORMAT_DECLARATION_DIVERGENT` is appropriate when the overlap appears across dual-emitted shapes; a producer-self-emitted overlap on a single declaration SHOULD use the same code with `error.details: { collision_kind: \"platform_extension_field\", target, overlapping_fields, winning_extension_uri }`). Producers SHOULD avoid the collision by emitting one extension per target or by partitioning fields across extensions; the deterministic precedence is for last-resort consistency across SDK implementations, not a sanctioned merging strategy.", "items": { "$ref": "/schemas/core/platform-extension-ref.json" } }, "tracking_extensions": { diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index 2ae42525ca..f0d5fbe24f 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -959,6 +959,11 @@ "type": "boolean", "description": "When true, this creative agent bills through the AdCP rate-card surface: list_creatives returns pricing_options when include_pricing=true with an authenticated account, build_creative populates pricing_option_id and vendor_cost on the response, and report_usage accepts records against the rate card. When false or absent, the agent bills out of band (flat license, SaaS contract, bundled enterprise agreement) and buyers should skip pricing fields and tolerate report_usage returning accepted: 0 with errors carrying BILLING_OUT_OF_BAND. A pre-call discriminator so buyer agents can route across many creative agents without first establishing an account to probe pricing.", "default": false + }, + "canonical_catalog_version": { + "type": "string", + "pattern": "^\\d+\\.\\d+(\\.\\d+)?$", + "description": "Optional. The AdCP canonical-formats catalog version this agent's runtime is built against (e.g., `3.1`, `3.2.0`). Lets buyer SDKs detect canonical-catalog skew between their generated types and the seller's actual support. SDKs MAY declare the version they were generated against (typically the AdCP version they ship for); when seller and SDK versions disagree, SDKs SHOULD soft-warn rather than fail (the open-enum semantics on `canonical-format-kind.json` make unknown canonicals safe to retain, so skew is not a hard error — it just means the older side might not understand newer canonical values). Omitted by sellers who haven't yet generated against a versioned catalog; absence is interpreted as the AdCP version advertised by the broader capabilities response." } }, "additionalProperties": true From ffbef21b2a2e7abd7b9d79ee80ef2e5f39c8a779 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 16 May 2026 15:05:30 -0400 Subject: [PATCH 38/41] feat(canonical-formats): v1_format_ref on ProductFormatDeclaration; deprecate canonical_parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flips the v1↔v2 link direction per Brian's design pass: v2 declaration is the source of truth for shape; the v1 named format is just the legacy identifier the v2 declaration links back to via v1_format_ref. Eliminates the canonical_parameters drift surface (v1 file mirroring the v2 shape) by removing the parallel declaration entirely. Schema changes: - ProductFormatDeclaration: new optional v1_format_ref ($ref FormatId). - Mutual exclusion with canonical_formats_only:true enforced via allOf/not. - format_kind:"custom" relaxed: requires format_shape + format_schema AND EITHER canonical_formats_only:true OR v1_format_ref. One or the other. - format.json#canonical_parameters: marked deprecated:true. Retained for 3.1 backward-compat (SDKs MUST honor when present); removed at 4.0. New code SHOULD migrate to v1_format_ref on the v2 side. Resolution order updated (v1-canonical-mapping.json): 1. v2 declaration with v1_format_ref pointing at this v1 format (authoritative) 2. v1 file's explicit canonical field (seller-asserted) 3. format_id_glob registry match 4. structural registry match 5. fail-closed with FORMAT_PROJECTION_FAILED Negative fixtures expanded: 4 new cases (v1_format_ref on Track-A canonical accepted; v1_format_ref on custom accepted; canonical_formats_only:true AND v1_format_ref rejected; custom with neither rejected). 19 negative + 14 positive fixtures all passing. Docs: new "v2 → v1 linking via v1_format_ref" subsection in canonical-formats.mdx. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/creative/canonical-formats.mdx | 27 +++++++++++-- static/schemas/source/core/format.json | 3 +- .../core/product-format-declaration.json | 28 +++++++++++-- .../registries/v1-canonical-mapping.json | 2 +- tests/canonical-negative-fixtures.test.cjs | 40 +++++++++++++++++++ 5 files changed, 90 insertions(+), 10 deletions(-) diff --git a/docs/creative/canonical-formats.mdx b/docs/creative/canonical-formats.mdx index 40fe957af6..d338494863 100644 --- a/docs/creative/canonical-formats.mdx +++ b/docs/creative/canonical-formats.mdx @@ -815,10 +815,11 @@ Products MAY carry both `format_ids` (v1) and `format_options` (v2) during the m When reading a product on the v1 path, SDKs project `format_ids` to `format_options` using the resolution order from `v1-canonical-mapping.json`: -1. Explicit `canonical` field on the v1 format declaration (seller-declared, highest priority). -2. `format_id_glob` match in the registry. -3. Structural match in the registry. -4. **Fail closed**: SDK MUST NOT synthesize a `format_options` entry. SDKs MUST surface the failure via the `errors[]` array on the `get_products` response envelope with `code: FORMAT_PROJECTION_FAILED` (NOT logger-only — a buyer that silently sees N fewer products has no remediation path; the warning needs to reach the response, not just the log file). The advisory is non-fatal: the response stays 200/success, the product is still valid on the v1 path, only the v2 `format_options` projection is absent. +1. **Authoritative v2 → v1 link**: if any v2 `ProductFormatDeclaration` on the same product carries `v1_format_ref` pointing at this v1 `format_id`, use that v2 declaration directly. Highest priority — seller asserts the link. +2. **Seller-asserted on the v1 file**: explicit `canonical` field on the v1 format declaration. +3. **Registry glob**: `format_id_glob` match. +4. **Structural match**: registry structural-shape match. +5. **Fail closed**: SDK MUST NOT synthesize a `format_options` entry. SDKs MUST surface the failure via the `errors[]` array on the `get_products` response envelope with `code: FORMAT_PROJECTION_FAILED` (NOT logger-only — a buyer that silently sees N fewer products has no remediation path; the warning needs to reach the response, not just the log file). The advisory is non-fatal: the response stays 200/success, the product is still valid on the v1 path, only the v2 `format_options` projection is absent. ### Consumer rules (divergence detection) @@ -847,6 +848,24 @@ The narrows relation is one-directional: v2 narrows v1 (v2 is the stricter shape SDK authors implementing the narrowing check SHOULD apply parameter-by-parameter subsumption per the rules above. Edge cases (composite parameters, platform_extensions, slot vocabulary changes) are not yet specified; SDKs MAY treat them as "unknown — pass" for 3.1 and surface a structured warning, with the working group tightening them per adopter feedback through 3.x. +### v2 → v1 linking via `v1_format_ref` + +When a seller has both a published v1 named format AND a v2 declaration for the same underlying product/inventory, the v2 declaration carries `v1_format_ref: { agent_url, id }` linking back to the v1 identifier. The v2 declaration is the source of truth for shape; the v1 format file stays a pure v1 shape — no mirrored declaration. + +```json test=false +{ + "format_kind": "custom", + "format_shape": "multi_placement_takeover", + "format_schema": { "uri": "https://nytimes.adcp/schemas/formats/homepage_takeover_v3", "digest": "sha256:..." }, + "v1_format_ref": { "agent_url": "https://nytimes.adcp", "id": "homepage_takeover" }, + "params": { ... } +} +``` + +This replaces the earlier `canonical_parameters` field on v1 `format.json` files (which is deprecated in 3.1 and removed at 4.0). The directional link from v2 → v1 captures the same fact without the parallel-shape drift surface — v1 files no longer mirror the v2 shape. + +`v1_format_ref` is mutually exclusive with `canonical_formats_only: true` — a declaration either has a v1 home (linked via `v1_format_ref`) or doesn't (asserted via `canonical_formats_only: true`). For `format_kind: "custom"` declarations, exactly one of the two MUST be set. + ### v2-only declarations on the wire A buyer reading a product on the v1 wire path sees `format_options` entries with `canonical_formats_only: true` absent from `format_ids`. This is intentional and not a producer error. v2-aware buyers reading `format_options` see them. v1-only buyers see fewer options on `format_ids` than a v2-aware buyer sees on `format_options` for the same product — the v1 surface is a strict subset on these products until v1 sunset (5.0). diff --git a/static/schemas/source/core/format.json b/static/schemas/source/core/format.json index 6579ba1072..d3ee7fe6bf 100644 --- a/static/schemas/source/core/format.json +++ b/static/schemas/source/core/format.json @@ -678,7 +678,8 @@ }, "canonical_parameters": { "$ref": "/schemas/core/product-format-declaration.json", - "description": "Optional. When `canonical` is set, this field carries the full ProductFormatDeclaration that the SDK projects this v1 format into. Strict-typed via `$ref` to product-format-declaration so the discriminator + canonical-specific params validate consistently. The `format_kind` MUST equal the `canonical` field value (validators enforce). When set, this is the authoritative source for SDK v1→v2 projection — the registry's structural-match parameter inference is bypassed.\n\nUse case: a custom seller format whose v2 narrowing isn't representable purely from registry structural-match (e.g., a takeover that bundles multi-canonical components — set `canonical: \"custom\"` and provide `canonical_parameters` with a fully-formed ProductFormatDeclaration including `format_shape` and `format_schema`).\n\n**Drift contract (normative).** Hand-authored `canonical_parameters` MUST satisfy the *narrows* relation against this v1 format's `requirements` and `assets[*]` shape (see canonical-formats.mdx 'Narrows — formal definition'). Concretely: every parameter value on `canonical_parameters.params` must be a subset of (or compatible with) the corresponding `requirements.*` value on this v1 format. A v1 file that says `requirements: { min_duration_ms: 3000 }` and a `canonical_parameters: { duration_ms_range: [5000, 30000] }` is a drift bug — a v1-side buyer ships a 4s creative that's valid against v1 but the v2-side rejects. SDKs that read this v1 file SHOULD lint-time check the equivalence at build/load and emit `FORMAT_PROJECTION_FAILED` if the two disagree; producers SHOULD prefer to leave `canonical_parameters` absent and let the SDK derive the v2 shape from `requirements` + `assets[*]` rather than hand-author both shapes (the derived path is unambiguously consistent by construction). When the v1 format genuinely needs custom v2-side narrowing (e.g., multi-component takeovers), authoring is unavoidable — in that case the seller MUST keep the two surfaces in sync and SHOULD treat any divergence as a build error in CI." + "deprecated": true, + "description": "**DEPRECATED in 3.1. Removed at 4.0.** Use `v1_format_ref` on the v2 `ProductFormatDeclaration` instead — the seller authors a v2 declaration (in `Product.format_options` or `creative.supported_formats`) and links it back to this v1 format via `v1_format_ref: { agent_url, id }`. The directional link from v2 → v1 is the same fact as `canonical_parameters` without the parallel-shape drift surface (v1 file and `canonical_parameters` were two declarations of the same thing; hand-authored, drifting silently).\n\nMigration: every seller currently authoring `canonical_parameters` SHOULD migrate to authoring a v2 declaration on the corresponding product (or capability) with `v1_format_ref` pointing back at this v1 format. v1 files become pure v1 again — no v2-shape mirroring.\n\n*Legacy behavior, retained for 3.1–3.x backward compatibility:* When `canonical` is set, this field carries the full ProductFormatDeclaration that the SDK projects this v1 format into. The `format_kind` MUST equal the `canonical` field value (validators enforce). When set, this is the authoritative source for SDK v1→v2 projection — the registry's structural-match parameter inference is bypassed. SDKs reading 3.1 catalogs MUST continue to honor `canonical_parameters` when present; 4.0+ SDKs MAY reject the field. New code SHOULD NOT emit this field.\n\n**Drift contract (still normative while supported).** Hand-authored `canonical_parameters` MUST satisfy the *narrows* relation against this v1 format's `requirements` and `assets[*]` shape (see canonical-formats.mdx 'Narrows — formal definition'). SDKs that read this v1 file SHOULD lint-time check the equivalence at build/load and emit `FORMAT_PROJECTION_FAILED` if the two disagree." } }, "required": [ diff --git a/static/schemas/source/core/product-format-declaration.json b/static/schemas/source/core/product-format-declaration.json index 43f341853c..cf03900d1c 100644 --- a/static/schemas/source/core/product-format-declaration.json +++ b/static/schemas/source/core/product-format-declaration.json @@ -36,6 +36,10 @@ "type": "string", "description": "REQUIRED when `format_kind: \"custom\"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`multi_placement_takeover`, `roadblock`, `branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values valid (validators MAY soft-warn) — adopters CAN ship a shape that isn't yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group promotes it to a first-class canonical." }, + "v1_format_ref": { + "$ref": "/schemas/core/format-id.json", + "description": "Authoritative v2 → v1 link. When present, asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 `format_id` ({agent_url, id}). Used when a seller has both a published v1 named format AND a v2 declaration for the same product/inventory: the v2 declaration is the source of truth for shape; the v1 format is the legacy identifier buyers' v1-allowlist code matches against.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) the referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ∋ `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and the referenced v1 format file's `requirements`.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to a v1 named format (`v1_format_ref`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifier) is the same fact without the parallel-shape drift surface." + }, "format_schema": { "$ref": "/schemas/core/platform-extension-ref.json", "description": "REQUIRED when `format_kind: \"custom\"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape's actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://mirror.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that's why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK's discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO mirror namespace (`https://mirror.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document's parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a 'valid' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO mirror trust**: `https://mirror.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the mirror or its CA compromises every buyer agent. Mirror-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the mirror response). Future hardening (signed bodies, transparency log) is tracked separately." @@ -48,10 +52,14 @@ "required": ["format_kind"] }, "then": { - "required": ["format_shape", "format_schema", "canonical_formats_only"], - "properties": { - "canonical_formats_only": { "const": true } - } + "required": ["format_shape", "format_schema"], + "anyOf": [ + { + "properties": { "canonical_formats_only": { "const": true } }, + "required": ["canonical_formats_only"] + }, + { "required": ["v1_format_ref"] } + ] }, "else": { "not": { "anyOf": [ @@ -59,6 +67,18 @@ { "required": ["format_schema"] } ] } } + }, + { + "$comment": "canonical_formats_only:true and v1_format_ref are mutually exclusive — a declaration EITHER asserts no v1 projection OR links to a v1 named format, never both.", + "not": { + "allOf": [ + { + "properties": { "canonical_formats_only": { "const": true } }, + "required": ["canonical_formats_only"] + }, + { "required": ["v1_format_ref"] } + ] + } } ], "oneOf": [ diff --git a/static/schemas/source/registries/v1-canonical-mapping.json b/static/schemas/source/registries/v1-canonical-mapping.json index c5a08b465a..afb9832633 100644 --- a/static/schemas/source/registries/v1-canonical-mapping.json +++ b/static/schemas/source/registries/v1-canonical-mapping.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/registries/v1-canonical-mapping.json", "title": "v1 → v2 Canonical Format Mapping Registry", - "description": "Authoritative AAO-published mapping from v1 named formats to v2 canonical declarations. Used by SDKs to project between wire shapes during the migration window: v1 format on the wire → v2 canonical in memory (or vice versa).\n\n**Resolution order** (per RFC #3305 amendment #3767, normative):\n1. If the v1 format declaration carries an explicit `canonical` field, use it (seller-declared, highest priority).\n2. Else, look up `format_id` in this registry's `format_id_glob` entries.\n3. Else, attempt structural match against this registry's `structural` entries.\n4. Else, fail closed: SDK MUST NOT emit `format_options` for products carrying this format. SDKs MUST surface the resolution failure via the `errors[]` array on the `get_products` response envelope (NOT logger-only) with `code: FORMAT_PROJECTION_FAILED`, `field: \"products[N].format_ids[K]\"`, and `error.details: { format_id, product_id, resolution_failure: \"no_explicit_canonical\" | \"no_registry_match\" | \"no_structural_match\" }`. Logger-only warnings die in DEBUG and the buyer that silently sees N fewer products has no remediation path — wire-level surfacing in `errors[]` is the only place the warning durably reaches an operator. The advisory is non-fatal: the response stays 200/success, the product is still valid on the v1 path, only the v2 `format_options` projection is absent. Consumer-side counterpart to the producer SHOULD (sellers should add an explicit `canonical` field or file a registry PR).\n\n**Match modes:**\n- `format_id_glob` — exact / glob match against the v1 `format_id.id` (the named-format identifier). Covers IAB-conventional sizes, named platform formats, common publisher conventions. Glob syntax: `*` matches any segment.\n- `structural` — match against the format's slot shape, asset types, and version constraints. Catches custom v1 formats that are structurally a standard format under a different name (e.g., `acme_homepage_300x250` is structurally an IAB MREC).\n\n**Alias collision precedence (normative).** When a v1 format's `assets[i]` carries multiple `asset_group_id` aliases that resolve to the same canonical asset_group (e.g., two slots both aliasing to `landing_page_url`), the SDK MUST resolve deterministically: the v1 format's `assets[*]` array order is authoritative — the first slot in declaration order wins, subsequent collisions are dropped from the projected v2 manifest and surfaced via `FORMAT_PROJECTION_FAILED` with `error.details: { collision_kind: \"asset_group_id_alias\", asset_group_id, winning_slot_id, dropped_slot_ids }`. SDKs MUST NOT silently pick one and discard the other without surfacing — silent picking creates inter-SDK divergence. Producers SHOULD avoid the collision by deduplicating aliased slots or using distinct `asset_group_id` values when both slots are semantically meaningful.\n\n**Governance**: same vocabulary-governance rules as `asset-group-vocabulary.json` and `format-shape-vocabulary.json` — additions land via PR with rationale + ≥1 reference adopter; AAO maintainer review; versioned + content-digested. Entries are additive; once published they are not removed (they may be marked `deprecated: true` if superseded).\n\n**Initial scope (3.1)**: ~15 unambiguous entries covering IAB display sizes, VAST 4.x, DAAST 1.x. Subsequent PRs expand coverage as adopter feedback surfaces patterns. The full v1-format audit dataset (~76% of formats from the 12-platform / 86-format audit in #3305) seeds the long-term roadmap.\n\nDigest the file content (sha256) when emitting in capabilities responses or referencing from SDK output. Buyers cache by `version` + `digest`.", + "description": "Authoritative AAO-published mapping from v1 named formats to v2 canonical declarations. Used by SDKs to project between wire shapes during the migration window: v1 format on the wire → v2 canonical in memory (or vice versa).\n\n**Resolution order** (per RFC #3305 amendment #3767, normative):\n1. **Authoritative v2→v1 link**: if any v2 `ProductFormatDeclaration` on the same product carries `v1_format_ref` pointing at this v1 format_id, use that v2 declaration. Highest priority — seller asserts the link directly.\n2. **Seller-asserted on the v1 file**: if the v1 format declaration carries an explicit `canonical` field, use it. (Note: `canonical_parameters` on the v1 file is deprecated for 3.1; SDKs reading 3.1 catalogs MUST still honor it when present, but `v1_format_ref` is the path forward.)\n3. **Registry glob**: look up `format_id` in this registry's `format_id_glob` entries.\n4. **Structural match**: attempt structural match against this registry's `structural` entries.\n5. **Fail closed**: SDK MUST NOT emit `format_options` for products carrying this format. SDKs MUST surface the resolution failure via the `errors[]` array on the `get_products` response envelope (NOT logger-only) with `code: FORMAT_PROJECTION_FAILED`, `field: \"products[N].format_ids[K]\"`, and `error.details: { format_id, product_id, resolution_failure: \"no_explicit_canonical\" | \"no_registry_match\" | \"no_structural_match\" }`. Logger-only warnings die in DEBUG and the buyer that silently sees N fewer products has no remediation path — wire-level surfacing in `errors[]` is the only place the warning durably reaches an operator. The advisory is non-fatal: the response stays 200/success, the product is still valid on the v1 path, only the v2 `format_options` projection is absent. Consumer-side counterpart to the producer SHOULD (sellers should add a v2 declaration with `v1_format_ref`, an explicit `canonical` field, or file a registry PR).\n\n**Match modes:**\n- `format_id_glob` — exact / glob match against the v1 `format_id.id` (the named-format identifier). Covers IAB-conventional sizes, named platform formats, common publisher conventions. Glob syntax: `*` matches any segment.\n- `structural` — match against the format's slot shape, asset types, and version constraints. Catches custom v1 formats that are structurally a standard format under a different name (e.g., `acme_homepage_300x250` is structurally an IAB MREC).\n\n**Alias collision precedence (normative).** When a v1 format's `assets[i]` carries multiple `asset_group_id` aliases that resolve to the same canonical asset_group (e.g., two slots both aliasing to `landing_page_url`), the SDK MUST resolve deterministically: the v1 format's `assets[*]` array order is authoritative — the first slot in declaration order wins, subsequent collisions are dropped from the projected v2 manifest and surfaced via `FORMAT_PROJECTION_FAILED` with `error.details: { collision_kind: \"asset_group_id_alias\", asset_group_id, winning_slot_id, dropped_slot_ids }`. SDKs MUST NOT silently pick one and discard the other without surfacing — silent picking creates inter-SDK divergence. Producers SHOULD avoid the collision by deduplicating aliased slots or using distinct `asset_group_id` values when both slots are semantically meaningful.\n\n**Governance**: same vocabulary-governance rules as `asset-group-vocabulary.json` and `format-shape-vocabulary.json` — additions land via PR with rationale + ≥1 reference adopter; AAO maintainer review; versioned + content-digested. Entries are additive; once published they are not removed (they may be marked `deprecated: true` if superseded).\n\n**Initial scope (3.1)**: ~15 unambiguous entries covering IAB display sizes, VAST 4.x, DAAST 1.x. Subsequent PRs expand coverage as adopter feedback surfaces patterns. The full v1-format audit dataset (~76% of formats from the 12-platform / 86-format audit in #3305) seeds the long-term roadmap.\n\nDigest the file content (sha256) when emitting in capabilities responses or referencing from SDK output. Buyers cache by `version` + `digest`.", "version": "1.0.0", "last_updated": "2026-05-01", "type": "object", diff --git a/tests/canonical-negative-fixtures.test.cjs b/tests/canonical-negative-fixtures.test.cjs index 4133a40757..214899d47d 100644 --- a/tests/canonical-negative-fixtures.test.cjs +++ b/tests/canonical-negative-fixtures.test.cjs @@ -151,6 +151,46 @@ const NEGATIVE_CASES = { expected: true, doc: { format_kind: 'image', canonical_formats_only: false, params: { width: 300, height: 250 } }, }, + { + label: 'format_kind=image with v1_format_ref accepted (Track-A canonical linking to v1 named format)', + expected: true, + doc: { + format_kind: 'image', + v1_format_ref: { agent_url: 'https://nytimes.adcp', id: 'mrec_300x250' }, + params: { width: 300, height: 250 }, + }, + }, + { + label: 'format_kind=custom with v1_format_ref accepted (custom format linked to a v1 named format)', + expected: true, + doc: { + format_kind: 'custom', + v1_format_ref: { agent_url: 'https://nytimes.adcp', id: 'homepage_takeover' }, + format_shape: 'multi_placement_takeover', + format_schema: { uri: 'https://x.example/s', digest: SHA }, + params: { foo: 'bar' }, + }, + }, + { + label: 'canonical_formats_only=true AND v1_format_ref rejected (mutually exclusive)', + expected: false, + doc: { + format_kind: 'image', + canonical_formats_only: true, + v1_format_ref: { agent_url: 'https://nytimes.adcp', id: 'mrec_300x250' }, + params: { width: 300, height: 250 }, + }, + }, + { + label: 'format_kind=custom with neither canonical_formats_only:true nor v1_format_ref rejected', + expected: false, + doc: { + format_kind: 'custom', + format_shape: 'multi_placement_takeover', + format_schema: { uri: 'https://x.example/s', digest: SHA }, + params: { foo: 'bar' }, + }, + }, ], '/schemas/creative/validate-input-result.json': [ { From ef7693f467de7c0645bee5b8c956e02569501e24 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 16 May 2026 15:17:54 -0400 Subject: [PATCH 39/41] refactor(canonical-formats): collapse status/runtime_status to experimental; drop tracking_extensions; rename *_source to asset_source; pin version patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Big architectural cleanup per Brian's design pass on the punch list (items B/C/D/E/I). D — collapse two stability axes to one experimental flag: - Drop _base.json#status enum (was: stable | preview | deprecated) - Drop ProductFormatDeclaration#runtime_status enum (was: stable | preview | declared_only) - Add _base.json#experimental boolean (default false) - Add _base.json#deprecated boolean (kept separate — different concept) - Add ProductFormatDeclaration#experimental boolean (independent of canonical-level) - Drop the Track A/B GA promotion rubric prose entirely - Same semantic as 'experimental' on protocols: 'may not work, have a fallback' E — mark four canonicals experimental: true: - sponsored_placement (4 adapter contracts) - responsive_creative (algorithmic, no v1 equivalent) - agent_placement (3.2-track, tracking model underspecified) - custom (handled via per-declaration experimental flag) - The 6 IAB/VAST/DAAST re-encodings plus html5 and image_carousel ship non-experimental B — drop tracking_extensions field: - _base.json#tracking_extensions removed - Buyers filter platform_extensions via extensions[uri].extends === 'tracking' - Single source of truth; no parallel array C — rename *_source fields to single asset_source: - audio_source / image_source / video_source → asset_source (shared 5-value enum) - buyer_audio_acceptance / buyer_image_acceptance / buyer_video_acceptance → buyer_asset_acceptance - item_production_model on sponsored_placement keeps its name (semantically different) - but now described as 'sharing the same enum' as asset_source - Cross-canonical leak fixed (audio_source on image canonical no longer accepted in practice) - Fixtures migrated (the_daily_30s_host_read.json, veo_generative_video_15s.json) I — pin semver patterns: - _base.json#since_version: ^\d+\.\d+$ - _base.json#migration_target_version: ^\d+\.\d+$ - Reject patch precision and placeholders like 'unknown' (omit field instead) Docs: glossary entries updated; replaced 'Status / runtime_status' sections with single 'experimental' section; tracking_extensions references replaced with platform_extensions filter pattern. Tests: 14 positive fixtures + 19 negative fixtures all green. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/creative/canonical-formats-migration.mdx | 18 +++---- docs/creative/canonical-formats.mdx | 48 +++++++++---------- .../canonical/the_daily_30s_host_read.json | 4 +- .../canonical/veo_generative_video_15s.json | 4 +- .../core/product-format-declaration.json | 9 ++-- .../source/formats/canonical/_base.json | 27 ++++++----- .../formats/canonical/agent_placement.json | 4 ++ .../source/formats/canonical/audio_daast.json | 4 -- .../formats/canonical/audio_hosted.json | 12 ++--- .../source/formats/canonical/display_tag.json | 4 -- .../source/formats/canonical/image.json | 14 ++---- .../canonical/responsive_creative.json | 4 ++ .../canonical/sponsored_placement.json | 6 ++- .../formats/canonical/video_hosted.json | 12 ++--- .../source/formats/canonical/video_vast.json | 4 -- 15 files changed, 81 insertions(+), 93 deletions(-) diff --git a/docs/creative/canonical-formats-migration.mdx b/docs/creative/canonical-formats-migration.mdx index 276ee3ea9d..1441b4d78a 100644 --- a/docs/creative/canonical-formats-migration.mdx +++ b/docs/creative/canonical-formats-migration.mdx @@ -244,8 +244,8 @@ Side-by-side for an audio format: "duration_ms_exact": 30000, "audio_codecs": ["mp3"], "loudness_lufs": -16, - "audio_source": "agent_synthesized", - "buyer_audio_acceptance": "rejected", + "asset_source": "agent_synthesized", + "buyer_asset_acceptance": "rejected", "slots": [ { "asset_group_id": "creative_brief", "required": true, "asset_type": "brief", "max_chars": 1000 }, { "asset_group_id": "voice_id", "required": false, "asset_type": "text" } @@ -297,7 +297,7 @@ Override fields take precedence over `brand.json` for the call carrying this Bra 1. **Inventory**: enumerate your existing v1 named formats. Confirm each maps to one of the 11 v2 canonicals OR to a custom shape (see "Shipping a custom format" below). Composed/coordinated/sponsorship shapes (multi-placement takeover, roadblock, branded content, cross-screen sponsorship, sponsorship lockup, newsletter, AR lens, playable, live event sponsorship) ship as `format_kind: "custom"` with a `format_shape` registry classifier and a `format_schema` URI+digest reference. 2. **Translate**: for each named format, write a v2 `ProductFormatDeclaration` narrowing the canonical with your platform's parameters. For custom shapes, author a JSON Schema describing your format's `params` and `slots`, host it at a stable URI on your subdomain (or via the AAO mirror for walled-garden sellers), and reference it from `format_schema`. -3. **Be honest about runtime readiness**: set `runtime_status` on each declaration. `stable` (default) means your runtime fully honors the declared format and production source. `preview` means the basic path works but advanced axes (per-item fan-out under `item_production_model`, brief-driven overrides, advanced `platform_extensions`) may be partial. `declared_only` means the catalog declaration is forward-looking and your runtime does NOT yet implement the path — common during migration when you port v1 catalog declarations forward but haven't wired the new production-source axis yet. Buyers can filter on this; compliance storyboards skip-gate `declared_only` entries gracefully. Upgrade the value as your runtime catches up. +3. **Be honest about runtime readiness**: set `experimental: true` on each declaration whose runtime path isn't fully wired yet. Same flag whether the concern is "spec is still settling" (canonical-level) or "my runtime is mid-migration" (declaration-level). Buyers SHOULD filter `experimental: true` from default views; they SHOULD prefer the v1 fallback (via the declaration's `v1_format_ref` or the parent product's `format_ids`) until you drop the flag. Drop the flag when the runtime catches up. 4. **Test**: validate translated declarations against `/schemas/core/product.json` (use the `npm run test:canonical-fixtures` pattern). 5. **Publish dual**: keep your v1 named formats and `list_creative_formats` working through 4.x. Add the v2 `format_options` field on products that have it. 6. **Flatten wrapper**: implement a server-side wrapper that derives the v1 `list_creative_formats` shape from v2 product declarations. Lets v1-era dashboards and tooling keep working. @@ -336,7 +336,7 @@ The spec has historically read sales-agent-first. v2 reshapes the creative-agent | Discovery | Sales agents pointed buyers at you via `creative_agents[]` (recursive query); buyers fetched your `list_creative_formats` to learn what you produce | Buyers reach you directly — through brand-side relationships, AAO registry, direct knowledge. Sales agents in v2 do NOT carry a list of "approved creative agents." Each side is independent. | | `build_creative` contract | Buyer shipped a manifest with `format_id` + `assets` + `inputs` (separate "production inputs" map) | Buyer ships the same envelope, but `inputs` is collapsed into `assets` — everything goes through one `assets` map keyed by canonical `asset_group_id`. The format's `slots` declaration tells you which assets to expect, each typed by `asset_type`. The seller (you) dispatches per slot — render verbatim for `image` / `video` / `audio` slots; consume for production for `text` / `brief` / `object` slots (e.g., `script` text → host-recorded audio; `creative_brief` brief → generated image). | | Production-source declaration | Implicit (the named format's name implied the model — `*_generated_*` for AI-produced) | Explicit per-canonical: `audio_source` / `image_source` / `video_source` enums declare who renders and when (`buyer_uploaded` / `publisher_host_recorded` / `seller_pre_rendered_from_brief` / `seller_human_designed` / `agent_synthesized`). Plus `synthesis_nondeterministic: true` for Veo/Sora-class flows that need post-synthesis QA-loop semantics. | -| Tracking integration | Your platform's pixel IDs, viewability vendors, OM-SDK partners lived in your named format's `tracking_events` field — sellers and buyers parsed your free-text declarations | Declare via `platform_extensions: [{uri, digest}]` and `tracking_extensions` on each `supported_formats[].format`. Each extension is a URI you host (or the AAO mirror translates) describing the schema for your platform's tracking surface (pixel IDs, conversion event taxonomies). Sellers and buyers cache by `uri@digest`; SDK codegen produces typed extension handlers. | +| Tracking integration | Your platform's pixel IDs, viewability vendors, OM-SDK partners lived in your named format's `tracking_events` field — sellers and buyers parsed your free-text declarations | Declare via `platform_extensions: [{uri, digest}]` on each `supported_formats[].format`. Each extension is a URI you host (or the AAO mirror translates) describing the schema for your platform's tracking surface (pixel IDs, conversion event taxonomies). The extension's `extends: "tracking"` metadata lets buyers filter tracking-related entries without a separate `tracking_extensions` array. Sellers and buyers cache by `uri@digest`; SDK codegen produces typed extension handlers. | | Hosting of produced bytes | Your CDN, your call | Your CDN, your call. v2 disaggregation is conceptual (the spec separates production from serving from tracking) — operationally, produced asset URLs in the manifest you return from `build_creative` continue to point at your CDN, your tracking JS continues to instrument, your platform extensions document the integration. | #### Concrete example: Flashtalking-shaped creative agent @@ -360,7 +360,7 @@ A creative agent that produces image / VAST / html5 creatives across multiple si "image_formats": ["jpg", "png", "gif"], "max_file_size_kb": 200, "ssl_required": true, - "image_source": "buyer_uploaded", + "asset_source": "buyer_uploaded", "platform_extensions": [ { "uri": "https://flashtalking.adcp/extensions/flashtalking_pixel_v2", "digest": "sha256:..." } ] @@ -424,8 +424,8 @@ A transformation agent that takes a buyer's brief or script and produces a rende "audio_sample_rates": [44100, 48000], "audio_channels": ["stereo"], "loudness_lufs": -16, - "audio_source": "seller_pre_rendered_from_brief", - "buyer_audio_acceptance": "rejected", + "asset_source": "seller_pre_rendered_from_brief", + "buyer_asset_acceptance": "rejected", "production_window_business_days": 1, "slots": [ { "asset_group_id": "creative_brief", "asset_type": "brief", "required": true, "max_chars": 500 }, @@ -442,8 +442,8 @@ A transformation agent that takes a buyer's brief or script and produces a rende "params": { "duration_ms_exact": 30000, "audio_codecs": ["mp3"], - "audio_source": "agent_synthesized", - "buyer_audio_acceptance": "rejected", + "asset_source": "agent_synthesized", + "buyer_asset_acceptance": "rejected", "synthesis_nondeterministic": false, "production_window_business_days": 0, "slots": [ diff --git a/docs/creative/canonical-formats.mdx b/docs/creative/canonical-formats.mdx index d338494863..c61e346376 100644 --- a/docs/creative/canonical-formats.mdx +++ b/docs/creative/canonical-formats.mdx @@ -26,18 +26,17 @@ Canonical formats collapse today's separate format registry into product-bound d | **Canonical format** | One of 11 AdCP-defined format archetypes that products narrow (e.g., `image`, `video_vast`, `audio_hosted`). The buyer's stable validation target. | | **`format_kind`** | Discriminator value naming a canonical format (e.g., `"image"`). Selects which canonical's parameter schema applies. | | **`format_options`** | Array of `ProductFormatDeclaration`s on a v2 product. The 90% case is single-element; multi-element declares "accepts any of." | -| **`ProductFormatDeclaration`** | Inline format declaration: `format_kind` + `params` + optional `capability_id` + optional `applies_to_channels` + optional `runtime_status`. | +| **`ProductFormatDeclaration`** | Inline format declaration: `format_kind` + `params` + optional `capability_id` + optional `applies_to_channels` + optional `experimental` + optional `v1_format_ref`. | | **`capability_id`** | Stable identifier for a format declaration, used to disambiguate when `format_options` carries multiple declarations sharing the same `format_kind`. | | **`applies_to_channels`** | Subset of the product's declared channels this format declaration applies to. Lets multi-channel products carry per-channel format options. | -| **`runtime_status`** | Per-product-declaration adopter-runtime readiness: `stable` (default), `preview` (partial paths), `declared_only` (forward-looking — runtime not wired yet). Distinct from canonical-level `status` (which is about spec maturity). | +| **`experimental`** | Boolean on canonical (`_base.json`) and on `ProductFormatDeclaration`. `true` = may not work as declared; have a v1 fallback ready. Replaces the earlier `status` + `runtime_status` enums (single binary flag rather than two stability axes). | | **`slots`** | Programmatic declaration on a format of which `asset_group_id` slots a manifest must (or may) populate, each paired with an `asset_type`. | | **`asset_group_id`** | Canonical slot-name vocabulary (e.g., `image_main`, `script`, `landing_page_url`). Replaces v1's free-text `asset_role`. | | **`composition_model`** | How the surface composes per-impression: `deterministic` (buyer-predictable per-slot) vs `algorithmic` (surface picks combinations from a pool). | -| **`*_source`** | Per-canonical production-source declaration: `audio_source`, `image_source`, `video_source`, `item_production_model`. Describes who renders the asset and when. | | **`synthesis_nondeterministic`** | When true, the production pipeline cannot guarantee in-spec output (Veo/Sora-class). Implies QA-loop + retry semantics. | | **`provenance_required`** | When true, the product rejects unsigned synthesized assets. Builders attach C2PA-compatible provenance manifests. | | **`platform_extensions`** | URI+digest references to platform-specific extensions narrowing the canonical (pixel ID shapes, conversion event taxonomies). | -| **`tracking_extensions`** | Subset of `platform_extensions` specifically scoped to tracking concerns (pixel IDs, viewability vendors, OM-SDK partners). | +| **`asset_source` / `asset_source`** | Per-canonical production-source declaration: who renders the asset bytes. Single shared `asset_source` enum across `image` / `video_hosted` / `audio_hosted`; `item_production_model` on `sponsored_placement` shares the same enum. Replaces the earlier per-canonical `audio_source` / `image_source` / `video_source` fields. | | **`status` on canonicals** | Spec-maturity axis (`stable` / `preview` / `deprecated`). All canonicals are `preview` while v2 itself is in preview; per-canonical promotion to `stable` happens at 3.1 GA based on adopter evidence. | | **`since_version` / `migration_target_version`** | Release-precision lifecycle metadata on canonicals — when introduced, when stabilization or breaking revision is expected. | | **`validate_input`** | Spec-defined dry-run primitive — buyers verify a manifest against canonicals/products without committing to a render. | @@ -80,23 +79,24 @@ Each canonical lives at `/schemas/formats/canonical/.json`. Tracking model | `responsive_creative` | Buyer asset pool, surface composes combinations (Google Responsive Display/Search Ads, Performance Max, Demand Gen; Meta Advantage+ creative) | Per-asset performance breakdown | | `agent_placement` | Sponsored placement composed by an AI surface in response to a user query (ChatGPT, Perplexity, voice assistants, sponsored search snippets). Distinct from `si_chat` (brand-owned conversation; user → brand's agent). | Mention-level impression + attribution | -### Status: every canonical is preview while v2 is preview +### `experimental` — one field, both axes -v2 itself is in preview through the 3.1 beta cycle. Until 3.1 GA ships, **every canonical is implicitly preview** — nothing is locked. The `status` field on each canonical schema (`stable | preview | deprecated`) defaults to `preview` during this window; per-canonical differentiation kicks in at GA, not before. Adopters building against the preview branch should treat all canonicals as experimental contract surface that MAY break before GA. +A canonical (or a seller's specific product declaration) carries a single `experimental: boolean` flag. Same semantics as `experimental` on protocols: 'this is shipping but may break, evolve, or fail.' Buyers reading `experimental: true` SHOULD have a v1 fallback ready and SHOULD validate via `validate_input` or a sandbox before routing production budget. This replaces the earlier two-axis design (`status` + `runtime_status` enums) — collapsed because what buyers actually care about is binary: do I treat this as production-stable, or as use-at-your-own-risk. -**At 3.1 GA**, the working group promotes individual canonicals from `preview` to `stable` based on adopter evidence. The promotion rubric: a canonical promotes to `stable` when (a) at least 2 production adopters have built against it AND (b) 90 consecutive days have passed without a breaking change to its parameter shape. Canonicals lacking adopter validation stay `preview` past GA and re-evaluate on each subsequent minor release. The 9 IAB-anchored canonicals (image, html5, display_tag, image_carousel, video_hosted, video_vast, audio_hosted, audio_daast, sponsored_placement) are expected to clear the rubric quickly because they sit on top of established industry standards. The 3 surface-composed canonicals (responsive_creative, agent_placement, and arguably sponsored_placement given the gaps around match types / negatives / bid mods on retail-media surfaces) need adopter-shipped `format_schema` evidence before promotion. Sellers shipping any canonical SHOULD declare `migration_target_version` so adopters know when to expect either stabilization or a breaking revision. +`experimental: true` is set at 3.1 GA on four canonicals: -### `runtime_status` on each product declaration — separate axis from canonical `status` +| Canonical | Why experimental | Promotion gated on | +|---|---|---| +| `sponsored_placement` | Four meaningfully different retail-media adapter contracts (Amazon SP, Criteo SP / CitrusAd SP, Pinterest Collection, generative-per-SKU) under one canonical | #4592 adapter-contract docs | +| `responsive_creative` | Algorithmic composition (surface picks combinations); no clean v1-translatable equivalent | Adopter evidence + per-surface conformance | +| `agent_placement` | Tracking macro vocabulary / postback shape / cross-surface dedup intentionally underspecified for 3.1 | 3.2 tracking-contract spec | +| `custom` | Inherently experimental — adopter-defined shape until promoted to a first-class `format_kind` | Per-shape promotion via the #3666 queue | -`status` on the canonical describes whether the working group has locked the spec definition (currently `preview` for all canonicals; per-canonical promotion at GA per the rubric above). `runtime_status` on each `ProductFormatDeclaration` is a separate axis describing whether THIS seller's runtime actually honors what they declared on THIS product. The two vary independently: a `preview` canonical can have a seller whose runtime fully honors the preview shape (`runtime_status: stable`); a future `stable` canonical can have sellers who declared aspirationally without wiring the runtime yet (`runtime_status: declared_only`). +The 6 IAB/VAST/DAAST re-encodings (`image`, `display_tag`, `video_hosted`, `video_vast`, `audio_hosted`, `audio_daast`) plus `html5` and `image_carousel` ship non-experimental — they're settled industry standards being re-encoded in canonical-formats vocabulary. -| `runtime_status` value | What it signals | Buyer action | -|---|---|---| -| `stable` (default, or omitted) | Adopter's runtime fully honors the declared format + production source. | Treat the declaration as a serving contract. | -| `preview` | Basic path works; advanced axes (per-item fan-out, brief-driven overrides, advanced `platform_extensions`) may be partial. | `validate_input` or sandbox before committing budget. | -| `declared_only` | Catalog declaration is forward-looking; runtime does NOT yet implement this path. | Treat as informational. Confirm via `validate_input` or a sandbox storyboard before purchase. Compliance storyboards SHOULD skip-gate `declared_only` entries gracefully rather than failing. | +Sellers MAY set `experimental: true` at the product-declaration level (on a specific `ProductFormatDeclaration`) even when the underlying canonical is non-experimental — useful for beta runtime paths or forward-looking catalog declarations the seller hasn't wired yet. Buyer SDKs SHOULD filter products with `experimental: true` from default views and offer an opt-in to surface them. -Why this axis exists: without `runtime_status`, sellers in mid-migration silently lie about what they support. They declare the shiny new production-source axis (`item_production_model: seller_pre_rendered_from_brief`) on a forward-looking product, but the actual `sync_creatives` runtime is still a buyer-uploaded-bytes loop. Buyers discover the mismatch only at submission time — exactly what v2's canonical-as-contract is supposed to prevent. Sellers MUST upgrade the value as the runtime catches up; buyers cache it like any other capability field. +When a seller marks a product `experimental: true`, the buyer's path of least resistance is the v1 fallback: if the v2 declaration carries `v1_format_ref` linking to a v1 named format, ship against v1 until the seller drops the experimental flag. v1 is the safe path; v2 is the surface the seller is still testing. ## Two axes: composition (per-impression) vs production (who renders) @@ -106,7 +106,7 @@ Two orthogonal patterns govern how a creative is produced and how it serves. Con - `deterministic` — buyer can predict per-slot rendering. The surface serves what it received. (`image`, `video_hosted`, `audio_hosted`, `video_vast`, `audio_daast`, `sponsored_placement`.) - `algorithmic` — surface picks combinations from a buyer-supplied asset pool per-impression. The buyer ships a pool; the surface composes. (`responsive_creative` for Google PMax / Meta Advantage+; `agent_placement` for AI-surface composition.) -**Production source** — per-canonical `*_source` parameters, all sharing a single 5-value enum: `buyer_uploaded | publisher_host_recorded | seller_pre_rendered_from_brief | seller_human_designed | agent_synthesized`. Describes **who renders the rendered asset, and when**: +**Production source** — per-canonical `asset_source` parameters, all sharing a single 5-value enum: `buyer_uploaded | publisher_host_recorded | seller_pre_rendered_from_brief | seller_human_designed | agent_synthesized`. Describes **who renders the rendered asset, and when**: - `audio_source` on `audio_hosted` - `image_source` on `image` - `video_source` on `video_hosted` @@ -114,13 +114,13 @@ Two orthogonal patterns govern how a creative is produced and how it serves. Con The two axes don't collapse. A generative DSP that produces ONE rendered image from a brief is `composition_model: deterministic` (the surface serves what it received) + `image_source: seller_pre_rendered_from_brief` (seller produced it from inputs at sync_creatives time). A retail-media surface that runs an AI synthesis pipeline per catalog item is `composition_model: deterministic` + `item_production_model: agent_synthesized`. Google PMax is `composition_model: algorithmic` + (production-source unspecified — buyer ships a pool of pre-rendered assets so the production-source question doesn't apply at the format level). -The production-source enums are informational, not the binding contract. The format's `slots` declaration is the contract — what the buyer ships, in what shape. The `*_source` field tells the buyer "here's how this product produces the rendered creative" so they can pick products whose production model fits their workflow (in-house pre-rendered vs upstream creative agent vs seller-driven generative). +The production-source enums are informational, not the binding contract. The format's `slots` declaration is the contract — what the buyer ships, in what shape. The `asset_source` field tells the buyer "here's how this product produces the rendered creative" so they can pick products whose production model fits their workflow (in-house pre-rendered vs upstream creative agent vs seller-driven generative). ### Tracker assembly under seller-rendered sources -When `*_source` is `buyer_uploaded`, the buyer ships rendered assets and any tracker URLs attached to those assets are buyer-controlled (universal_macros for impression/click; `vast_tracker` / `daast_tracker` assets for decomposed VAST/DAAST trackers). When `*_source` is any of the seller-rendered values (`seller_pre_rendered_from_brief`, `seller_human_designed`, `agent_synthesized`) or `publisher_host_recorded`, the buyer never sees the rendered artifact directly. Two normative paths apply: +When `asset_source` is `buyer_uploaded`, the buyer ships rendered assets and any tracker URLs attached to those assets are buyer-controlled (universal_macros for impression/click; `vast_tracker` / `daast_tracker` assets for decomposed VAST/DAAST trackers). When `asset_source` is any of the seller-rendered values (`seller_pre_rendered_from_brief`, `seller_human_designed`, `agent_synthesized`) or `publisher_host_recorded`, the buyer never sees the rendered artifact directly. Two normative paths apply: -- **Macro-substituted tracking (default).** The seller honors AdCP universal_macros at impression time — `{IMPRESSION_TRACKER}`, `{CLICK_TRACKER}`, etc. — and substitutes buyer-supplied tracker URLs (declared on the manifest's optional `landing_page_url` and the buyer's measurement-vendor pixels declared via `tracking_extensions` on the format) into the rendered creative's serving template. The buyer registers their measurement pixels client-side; the seller calls them at serve time. This is the dominant path for image / video / audio production where serving and tracking are decoupled. +- **Macro-substituted tracking (default).** The seller honors AdCP universal_macros at impression time — `{IMPRESSION_TRACKER}`, `{CLICK_TRACKER}`, etc. — and substitutes buyer-supplied tracker URLs (declared on the manifest's optional `landing_page_url` and the buyer's measurement-vendor pixels declared via `platform_extensions` on the format, filtered by `extensions[uri].extends === "tracking"`) into the rendered creative's serving template. The buyer registers their measurement pixels client-side; the seller calls them at serve time. This is the dominant path for image / video / audio production where serving and tracking are decoupled. - **Sync-creatives tracker block.** For products where the seller produces a serving artifact that embeds tracker URLs directly (e.g., a generated VAST tag or a stitched companion banner), the seller's `sync_creatives` response SHOULD include a `tracker_block` field listing the impression URL pattern and click URL pattern. Buyers register those with their measurement vendor at sync time. This path covers the generative-DSP pattern where the serving artifact and the tracking shape are produced together. `vast_tracker` and `daast_tracker` decomposed tracker assets work for both `buyer_uploaded` and seller-rendered sources — when the seller renders, those tracker assets are inputs to the rendered tag, attached to the appropriate VAST/DAAST `` block at production time. When the buyer ships a complete `vast` or `daast` tag, the trackers travel inside the tag. @@ -388,8 +388,8 @@ Host-reads are the host-recorded-from-buyer-script pattern. The product declares "audio_sample_rates": [44100, 48000], "audio_channels": ["stereo"], "loudness_lufs": -16, - "audio_source": "publisher_host_recorded", - "buyer_audio_acceptance": "rejected", + "asset_source": "publisher_host_recorded", + "buyer_asset_acceptance": "rejected", "composition_model": "deterministic", "slots": [ { @@ -533,8 +533,8 @@ A generative DSP (universalads, Pencil, AdCreative.ai-shaped tools) is a sales a "image_formats": ["jpg", "png"], "ssl_required": true, "composition_model": "deterministic", - "image_source": "seller_pre_rendered_from_brief", - "buyer_image_acceptance": "rejected", + "asset_source": "seller_pre_rendered_from_brief", + "buyer_asset_acceptance": "rejected", "production_window_business_days": 0, "slots": [ { "asset_group_id": "creative_brief", "asset_type": "brief", "required": true, "max_chars": 500 }, @@ -909,7 +909,7 @@ For these channels, sellers continue to use v1 `format_ids` and adopt v2 increme ### Generative-DSP and multi-output patterns are forward-looking -The `*_source` enums (including `seller_pre_rendered_from_brief` and `agent_synthesized`) and `item_production_model` on `sponsored_placement` are designed for generative-DSP and AI-rendered retail-media patterns that are emerging but not yet a large share of programmatic spend in 2026. Universalads-shaped tools, Pencil, AdCreative.ai, GenStudio-shaped tools — these are real adopters, but the volume is small relative to the boring 90% (buyer ships an MREC PNG; surface serves it). Reading too much into the schema breadth is a mistake. The fields exist so generative-DSP adopters have a clean v2 home; the worked examples include them so adopters can map their adapter cleanly. They are not a signal that the v2 narrative is AI-first. The dominant flows for 3.1 are still buyer-uploaded assets going through deterministic surfaces. +The `asset_source` enums (including `seller_pre_rendered_from_brief` and `agent_synthesized`) and `item_production_model` on `sponsored_placement` are designed for generative-DSP and AI-rendered retail-media patterns that are emerging but not yet a large share of programmatic spend in 2026. Universalads-shaped tools, Pencil, AdCreative.ai, GenStudio-shaped tools — these are real adopters, but the volume is small relative to the boring 90% (buyer ships an MREC PNG; surface serves it). Reading too much into the schema breadth is a mistake. The fields exist so generative-DSP adopters have a clean v2 home; the worked examples include them so adopters can map their adapter cleanly. They are not a signal that the v2 narrative is AI-first. The dominant flows for 3.1 are still buyer-uploaded assets going through deterministic surfaces. ### Creative-agent business model diff --git a/static/examples/products/canonical/the_daily_30s_host_read.json b/static/examples/products/canonical/the_daily_30s_host_read.json index 4e13d06ea0..d7b06fe513 100644 --- a/static/examples/products/canonical/the_daily_30s_host_read.json +++ b/static/examples/products/canonical/the_daily_30s_host_read.json @@ -53,8 +53,8 @@ "stereo" ], "loudness_lufs": -16, - "audio_source": "publisher_host_recorded", - "buyer_audio_acceptance": "rejected", + "asset_source": "publisher_host_recorded", + "buyer_asset_acceptance": "rejected", "composition_model": "deterministic", "slots": [ { diff --git a/static/examples/products/canonical/veo_generative_video_15s.json b/static/examples/products/canonical/veo_generative_video_15s.json index 8112d764bc..1deb5e6fa5 100644 --- a/static/examples/products/canonical/veo_generative_video_15s.json +++ b/static/examples/products/canonical/veo_generative_video_15s.json @@ -57,8 +57,8 @@ 24, 30 ], - "video_source": "agent_synthesized", - "buyer_video_acceptance": "rejected", + "asset_source": "agent_synthesized", + "buyer_asset_acceptance": "rejected", "captions": "recommended", "composition_model": "deterministic", "synthesis_nondeterministic": true, diff --git a/static/schemas/source/core/product-format-declaration.json b/static/schemas/source/core/product-format-declaration.json index cf03900d1c..60dd49cd9b 100644 --- a/static/schemas/source/core/product-format-declaration.json +++ b/static/schemas/source/core/product-format-declaration.json @@ -26,11 +26,10 @@ "default": false, "description": "When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: \"custom\"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence." }, - "runtime_status": { - "type": "string", - "enum": ["stable", "preview", "declared_only"], - "default": "stable", - "description": "Adopter-runtime readiness for this product-format declaration. **Distinct from the canonical's `status` field** (which describes whether the v2 working group has stabilized the format definition itself). `runtime_status` describes whether THIS seller's runtime actually honors what they declared on THIS product.\n\n- `stable` (default) — adopter's runtime fully honors the declared format + production source. Buyers can rely on the declaration as a serving contract.\n- `preview` — runtime supports the basic path; some axes (e.g., per-item fan-out under `item_production_model`, brief-driven overrides, advanced `platform_extensions`) may be partial. Buyers SHOULD validate via `validate_input` or sandbox before committing budget.\n- `declared_only` — catalog declaration is forward-looking; runtime does NOT yet implement this path. Buyers MUST treat as informational and confirm via `validate_input` or a sandbox storyboard before purchase. Compliance storyboards SHOULD skip-gate `declared_only` entries gracefully rather than failing. **Buyer SDKs SHOULD filter `declared_only` declarations from default product views by default** (opt-in flag to include them) — without the SDK-default filter, `declared_only` is a doc string adopters ignore. With the default filter, sellers get a useful pre-runtime advertising signal (catalog presence) without buyer agents accidentally routing budget against an unimplemented runtime.\n\nThe two axes vary independently: a `stable` canonical can have `declared_only` adopters (canonical is settled in spec but adopter hasn't wired runtime yet), and a `preview` canonical can have `stable` adopters (adopter built against the preview shape and their runtime fully honors it). Producers SHOULD set this when their product declaration is aspirational; absence is interpreted as `stable`. Sellers MUST upgrade the value as the runtime catches up; buyers cache it like any other capability field." + "experimental": { + "type": "boolean", + "default": false, + "description": "When true, THIS seller's specific product declaration may not work as declared — even if the underlying canonical is stable. Use for beta runtime paths, forward-looking catalog entries the runtime doesn't yet honor, or experimental products where the seller wants buyer-side caution. Buyers reading `experimental: true` on a product declaration SHOULD prefer the v1 path when a v1 fallback exists for the same product (via `format_ids` on the parent product or via this declaration's `v1_format_ref`) and SHOULD validate via `validate_input` or a sandbox before routing production budget.\n\nIndependent of the canonical's own `experimental` flag — a stable canonical (e.g., `image`, `video_hosted`) can carry an experimental product declaration when the seller is shipping a new runtime path that isn't fully wired yet. Conversely, an experimental canonical (`sponsored_placement`, `responsive_creative`, `agent_placement`) MAY carry non-experimental product declarations where the seller's adopter contract is well-tested. Buyer SDKs SHOULD filter products with `experimental: true` from default views and offer an opt-in flag to surface them.\n\nReplaces the earlier `runtime_status` enum (`stable | preview | declared_only`) — same semantic ('use with caution') without the cognitive overhead of two stability axes." }, "format_shape": { "type": "string", diff --git a/static/schemas/source/formats/canonical/_base.json b/static/schemas/source/formats/canonical/_base.json index 2a9af14bdd..f1c4a8a5fa 100644 --- a/static/schemas/source/formats/canonical/_base.json +++ b/static/schemas/source/formats/canonical/_base.json @@ -5,19 +5,25 @@ "description": "Shared parameter fields that apply across canonical formats. Each canonical format extends this base with format-specific parameters (dimensions, durations, codecs, slot constraints).", "type": "object", "properties": { - "status": { - "type": "string", - "enum": ["stable", "preview", "deprecated"], - "default": "preview", - "description": "Stability tier for this canonical format.\n\n- `stable` — schema and tracking model are committed; breaking changes go through normal major-version deprecation.\n- `preview` — parameter shape and tracking model MAY break in a subsequent minor release once 2-3 adopters have built against it; buyers should treat preview canonicals as experimental and plan for migration.\n- `deprecated` — replacement is available; existing adopters supported through the deprecation cycle but new adoption is discouraged.\n\n**3.1 GA promotion rubric** uses a two-track rule:\n\n*Track A — v1-translatable canonicals promote to `stable` at GA.* Canonicals whose parameter shape round-trips cleanly through `v1-canonical-mapping.json` are re-encodings of IAB/VAST/DAAST specs that have been the same for ≥20 years — calling them `preview` would be misleading (the schema isn't experimental, only the v2 wire shape is). The 6 Track-A canonicals are: `image`, `display_tag`, `video_hosted`, `video_vast`, `audio_hosted`, `audio_daast`. Each carries `default: \"stable\"` on its own `status` field, overriding this base default. The commitment: their parameter names are locked at GA and follow normal major-version deprecation. Track A is exception, not the rule — promoting v2 wrapper fields (`runtime_status`, `canonical_formats_only`, `platform_extensions` shape) is governed by the standard major-version process.\n\n*Track B — non-v1-translatable canonicals stay `preview` past GA and use the evidence-based rubric.* A canonical promotes from preview to stable when (a) at least 2 production adopters have built against it AND (b) 90 consecutive days have passed without a breaking change to its parameter shape. The 5 Track-B canonicals at GA: `html5` (round-trip lossy — OM-SDK / clickTag specifics demote to `platform_extensions`), `image_carousel` (no v1 mapping entry — needs adopter validation), `sponsored_placement` (4 different retail-media adapter contracts under one canonical — gates promotion on the IR5 #4592 adapter-docs work), `responsive_creative` (algorithmic composition, no clean v1 equivalent), `agent_placement` (3.2-track; tracking macro/postback/dedup intentionally underspecified for 3.1). `custom` is preview by default and stays preview indefinitely (it's the meta-shape).\n\nPair `preview` canonicals with `migration_target_version` to indicate when the working group expects stabilization or a breaking revision." + "experimental": { + "type": "boolean", + "default": false, + "description": "When true, this canonical (or a seller's specific narrowing of it) may not work as declared — adopters SHOULD have a v1 fallback ready and SHOULD NOT route production budget without testing. Same semantics as `experimental` on protocols: 'this is shipping but may break, evolve, or fail.' Buyers reading `experimental: true` SHOULD prefer the v1 path when a v1 fallback exists for the same product (via `format_ids` on the parent product or via the v2 declaration's `v1_format_ref`).\n\nThree drivers of `experimental: true`:\n1. **Spec maturity** — the canonical's tracking model or parameter shape is still being settled (`agent_placement`'s tracking macros, `sponsored_placement`'s per-adapter contracts, `responsive_creative`'s algorithmic composition).\n2. **Adopter runtime gap** — the seller has declared the canonical in their catalog but their runtime doesn't yet honor it cleanly.\n3. **Custom shapes** — `format_kind: \"custom\"` is inherently experimental until the working group promotes a `format_shape` to a first-class canonical.\n\nReplaces the earlier `status` enum (`stable | preview | deprecated`) + `runtime_status` enum (`stable | preview | declared_only`) — two axes with subtle overlap. The single boolean is what buyers actually care about: do I treat this as production-stable or as 'try at my own risk.' Sellers SHOULD set `experimental: true` on canonicals or product declarations that aren't yet production-ready, regardless of which axis (spec, runtime, custom) drives the experimentation. The 6 IAB/VAST/DAAST re-encodings (`image`, `display_tag`, `video_hosted`, `video_vast`, `audio_hosted`, `audio_daast`) default to non-experimental at the canonical level; sellers MAY still mark a specific product declaration experimental (e.g., a beta runtime path for an existing product)." + }, + "deprecated": { + "type": "boolean", + "default": false, + "description": "When true, this canonical (or a seller's specific narrowing of it) is going away. Existing adopters are supported through the deprecation cycle; new adoption is discouraged. Pair with `migration_target_version` to indicate when the canonical is expected to be removed. Distinct from `experimental`: an experimental canonical may stabilize and stop being experimental; a deprecated canonical is on a sunset path." }, "since_version": { "type": "string", - "description": "AdCP release-precision version that introduced this canonical (e.g., '3.1', '3.2.0'). Lets adopters reason about minimum protocol version requirements when consuming a format declaration." + "pattern": "^\\d+\\.\\d+$", + "description": "AdCP MAJOR.MINOR version that introduced this canonical (e.g., '3.1', '3.2'). Lets adopters reason about minimum protocol version requirements when consuming a format declaration. Patch precision is intentionally rejected — canonicals are introduced at minor-version boundaries." }, "migration_target_version": { "type": "string", - "description": "For `preview` canonicals: the AdCP release-precision version by which the working group expects to either promote the canonical to `stable` or surface a breaking revision. Lets adopters time their migration. Unset for `stable` canonicals (they migrate via the normal major-version deprecation cycle). For `deprecated` canonicals, indicates the release in which the canonical will be removed." + "pattern": "^\\d+\\.\\d+$", + "description": "AdCP MAJOR.MINOR version by which the working group expects this canonical to stabilize, surface a breaking revision, or (when `deprecated: true`) be removed. Patch precision is intentionally rejected — canonicals shift at minor-version boundaries. Absence signals 'no specific target' (omit the field rather than use a placeholder like 'unknown')." }, "composition_model": { "type": "string", @@ -33,14 +39,9 @@ "description": "Platform-specific extensions narrowing the canonical (pixel ID shapes, conversion event taxonomies, platform-specific CTAs/destinations). Each extension is a URI+digest reference resolved against the bundled `extensions` map in get_products responses or fetched directly.\n\n**Collision precedence (normative).** When two or more `platform_extensions[]` entries on the same declaration extend the same target (e.g., both extend `tracking`) with overlapping field names, **array order is authoritative — later entries override earlier ones on a per-field basis** (last-in-array-wins). SDKs MUST surface the overlap via the `errors[]` array on the `get_products` response with a structured code (`FORMAT_DECLARATION_DIVERGENT` is appropriate when the overlap appears across dual-emitted shapes; a producer-self-emitted overlap on a single declaration SHOULD use the same code with `error.details: { collision_kind: \"platform_extension_field\", target, overlapping_fields, winning_extension_uri }`). Producers SHOULD avoid the collision by emitting one extension per target or by partitioning fields across extensions; the deterministic precedence is for last-resort consistency across SDK implementations, not a sanctioned merging strategy.", "items": { "$ref": "/schemas/core/platform-extension-ref.json" } }, - "tracking_extensions": { - "type": "array", - "description": "Subset of platform_extensions specifically scoped to tracking concerns (pixel IDs, conversion event taxonomies, viewability vendors, OM-SDK partners). Functionally equivalent to listing tracking-related extensions under platform_extensions; the separate field surfaces \"what's tracking-related\" without forcing buyers to fetch each extension definition to find out. When present, every entry MUST also be present in (or implied by) platform_extensions. Producers MAY omit tracking_extensions and put everything under platform_extensions; the split is for buyer convenience, not schema enforcement.", - "items": { "$ref": "/schemas/core/platform-extension-ref.json" } - }, "synthesis_nondeterministic": { "type": "boolean", - "description": "When true, the format's production pipeline is genuinely nondeterministic — the platform cannot guarantee that synthesis from a given input set produces in-spec output. Veo / Sora / Runway-class generative video, and other AI-synthesis flows where output dimensions, duration, or quality vary per run. Implies a different validation contract: predictive `validate_input` is impossible; the platform's own post-synthesis QA loop applies; if the QA loop exhausts without producing a valid artifact, `build_creative` returns task_failed with a synthesis_failed reason. Distinct from `composition_model` (which describes how the surface composes per-slot rendering, not whether synthesis is deterministic). When false or absent, the format's production is predictable enough that `validate_input` can predict output properties from input properties.\n\n**Compatibility with production-source enums** (`audio_source` / `image_source` / `video_source` / `item_production_model`): `synthesis_nondeterministic: true` MAY pair with any of `seller_pre_rendered_from_brief`, `seller_human_designed`, or `agent_synthesized` (the QA loop is concept-level, not source-specific — \"seller renders from brief but each retry differs\" is just as nondeterministic as Veo). It MUST NOT pair with `buyer_uploaded` (the buyer ships pre-rendered bytes; there's no synthesis step to be nondeterministic about). It MUST NOT pair with `publisher_host_recorded` (the publisher's host produces a deterministic-from-script output even if the human voice varies). When `synthesis_nondeterministic: true` is set with an incompatible source, validators SHOULD reject with a structured error.", + "description": "When true, the format's production pipeline is genuinely nondeterministic — the platform cannot guarantee that synthesis from a given input set produces in-spec output. Veo / Sora / Runway-class generative video, and other AI-synthesis flows where output dimensions, duration, or quality vary per run. Implies a different validation contract: predictive `validate_input` is impossible; the platform's own post-synthesis QA loop applies; if the QA loop exhausts without producing a valid artifact, `build_creative` returns task_failed with a synthesis_failed reason. Distinct from `composition_model` (which describes how the surface composes per-slot rendering, not whether synthesis is deterministic). When false or absent, the format's production is predictable enough that `validate_input` can predict output properties from input properties.\n\n**Compatibility with `asset_source` / `item_production_model`**: `synthesis_nondeterministic: true` MAY pair with any of `seller_pre_rendered_from_brief`, `seller_human_designed`, or `agent_synthesized` (the QA loop is concept-level, not source-specific — 'seller renders from brief but each retry differs' is just as nondeterministic as Veo). It MUST NOT pair with `buyer_uploaded` (the buyer ships pre-rendered bytes; there's no synthesis step to be nondeterministic about). It MUST NOT pair with `publisher_host_recorded` (the publisher's host produces a deterministic-from-script output even if the human voice varies). When `synthesis_nondeterministic: true` is set with an incompatible source, validators SHOULD reject with a structured error.", "default": false }, "slots": { diff --git a/static/schemas/source/formats/canonical/agent_placement.json b/static/schemas/source/formats/canonical/agent_placement.json index ccb4b48062..ab1b3d76ec 100644 --- a/static/schemas/source/formats/canonical/agent_placement.json +++ b/static/schemas/source/formats/canonical/agent_placement.json @@ -5,6 +5,10 @@ "description": "**3.2-track canonical.** The structural shape (algorithmic composition + brand-context input + optional offering/landing_page) is captured here so adopters can declare against it in 3.1 catalogs, but the **mention-level tracking contract is intentionally underspecified for 3.1**: no normative macro vocabulary, no postback shape, no cross-surface dedup model. Adopters claiming `agent_placement` in 3.1 ship private tracking integrations and SHOULD set `runtime_status: 'preview'` or `'declared_only'` on the declaration; buyer agents MUST treat agent_placement attribution as adapter-defined until the 3.2 tracking-macro spec lands. The canonical promotes to a normatively-buyer-callable surface in 3.2 (or later) once the tracking contract is specified.\n\nSponsored placement integrated into an AI-surface's response to a user. Buyer supplies a `BrandRef` (resolving brand.json for context), an optional `offering_ref` to focus the mention on a specific offering, and an optional `landing_page_url` the surface MAY attach as a citation. The surface (LLM, voice assistant, sponsored-search ranker) composes a natural-language mention, sponsored card, or audio snippet within its response to a user query. **Composition is algorithmic** — the agent chooses phrasing and presentation. Output asset_type varies by surface: `text` for chat UIs and sponsored search snippets; `audio` (synthesized) for voice assistants; `card` for structured AI-surface result cards. Tracking model: mention-level impression + attribution events; per-mention id keys back to brand and offering — but see the 3.2-track note above; the wire shape of these events is not yet specified. Distinct from `si_chat` (which is the user-converses-with-brand's-agent pattern — brand owns the conversational surface) and from `sponsored_placement` (retail-media catalog-driven). Parallels `sponsored_placement` structurally: both are surface-composed placements; agent_placement is for AI/agentic surfaces, sponsored_placement is for retail media.", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { + "experimental": { + "default": true, + "description": "Marked experimental at 3.1 GA: the canonical's tracking model (mention-level impression + attribution, postback shape, cross-surface dedup) is intentionally underspecified for 3.1. Adopters claiming `agent_placement` ship private tracking integrations; buyer agents MUST treat attribution as adapter-defined until the 3.2 tracking-macro spec lands. Promotion to non-experimental gated on the 3.2 tracking-contract spec." + }, "slots": { "default": [ { "asset_group_id": "offering_ref", "asset_type": "text", "required": false }, diff --git a/static/schemas/source/formats/canonical/audio_daast.json b/static/schemas/source/formats/canonical/audio_daast.json index 643e204152..f008149932 100644 --- a/static/schemas/source/formats/canonical/audio_daast.json +++ b/static/schemas/source/formats/canonical/audio_daast.json @@ -5,10 +5,6 @@ "description": "DAAST-tag-delivered audio creative (audio analog of VAST). Slot: `daast_tag` (daast asset, URL or inline XML). Tracking model: DAAST events inherent to the spec — `impression`, `firstQuartile`, `midpoint`, `thirdQuartile`, `complete`, `start`, `pause`, `resume`, `mute`, `unmute`, `clickTracking`, `error`. Distinct from `audio_hosted` (direct file with external tracking).", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { - "status": { - "default": "stable", - "description": "Track A (v1-translatable): default `stable` at 3.1 GA. DAAST is an IAB standard (the audio analog of VAST); canonical re-encodes the established spec and is locked at GA. See `_base.json#status` for the rubric." - }, "slots": { "default": [ { "asset_group_id": "daast_tag", "asset_type": "daast", "required": true }, diff --git a/static/schemas/source/formats/canonical/audio_hosted.json b/static/schemas/source/formats/canonical/audio_hosted.json index c89f0b2496..8840f51fab 100644 --- a/static/schemas/source/formats/canonical/audio_hosted.json +++ b/static/schemas/source/formats/canonical/audio_hosted.json @@ -5,10 +5,6 @@ "description": "Direct audio creative — buyer ships an `audio` asset (mp3/aac/wav) for asset-driven products, or ships a `script` / `creative_brief` text asset for products where the seller produces audio internally (podcast host-reads, TTS synthesis). Optional companion slots: `companion_image`, `brand_name`, `landing_page_url`. Tracking model: standard impression + completion + companion-image-click pixels via universal_macros. Distinct from `audio_daast` (DAAST tag, inherent DAAST event tracking). For host-reads and synthesized audio, the format declares `audio_source: 'publisher_host_recorded'` or `'agent_synthesized'` plus `buyer_audio_acceptance: 'rejected'`; the format's `slots` declaration enumerates which assets the buyer ships (e.g., `script` text asset for host-reads). The seller decides how to consume each asset (render verbatim vs produce audio from text) — there is no separate manifest 'inputs' map; everything the buyer ships goes in `assets`.", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { - "status": { - "default": "stable", - "description": "Track A (v1-translatable): default `stable` at 3.1 GA. Hosted audio with companion-image trackers is a settled pattern; the parameter shape is locked at GA. See `_base.json#status` for the rubric." - }, "slots": { "default": [ { "asset_group_id": "audio_main", "asset_type": "audio", "required": true }, @@ -57,17 +53,17 @@ "type": "number", "description": "Maximum true-peak level in dBFS (typical: -2)." }, - "audio_source": { + "asset_source": { "type": "string", "enum": ["buyer_uploaded", "publisher_host_recorded", "seller_pre_rendered_from_brief", "seller_human_designed", "agent_synthesized"], "default": "buyer_uploaded", - "description": "Where the rendered audio comes from. Parallels `image_source` on `image` and `video_source` on `video_hosted`. `buyer_uploaded` (default): buyer ships a pre-rendered audio asset. `publisher_host_recorded`: the publisher's host records the audio (podcast host-read pattern); buyer must use the publisher's build_creative capability. `seller_pre_rendered_from_brief`: buyer ships a brief plus structured copy; seller renders ONE audio file from those inputs at sync_creatives or build_creative time (TTS-from-brief, AI-narration-from-script). `seller_human_designed`: seller's studio team produces the audio manually. `agent_synthesized`: AI synthesis pipeline (TTS or generative audio); pair with `synthesis_nondeterministic: true` when the platform cannot guarantee in-spec output. The `slots` declaration is the binding contract for what the buyer ships; `audio_source` is informational." + "description": "Where the rendered audio bytes come from. Single shared enum across canonicals (see `image.json#asset_source` for the full semantics). `publisher_host_recorded`: the publisher's host records the audio (podcast host-read pattern); buyer must use the publisher's build_creative capability. This value is audio-specific." }, - "buyer_audio_acceptance": { + "buyer_asset_acceptance": { "type": "string", "enum": ["accepted", "rejected"], "default": "accepted", - "description": "Whether the product accepts buyer-uploaded audio. When `rejected`, the buyer cannot ship an audio asset directly — they must use build_creative (or sync_creatives with brief inputs) so the seller produces the audio. Combined with `audio_source`, lets a product declare 'I produce audio from briefs and refuse buyer uploads' (audio_source=`seller_pre_rendered_from_brief`, buyer_audio_acceptance=`rejected`)." + "description": "Whether the product accepts buyer-uploaded audio. When `rejected`, the buyer cannot ship an audio asset directly — they must use build_creative (or sync_creatives with brief inputs) so the seller produces the audio. Combined with `asset_source`, lets a product declare 'I produce audio from briefs and refuse buyer uploads' (asset_source=`seller_pre_rendered_from_brief`, buyer_asset_acceptance=`rejected`)." }, "companion_image_required": { "type": "boolean" diff --git a/static/schemas/source/formats/canonical/display_tag.json b/static/schemas/source/formats/canonical/display_tag.json index dbbf8172f1..3899a81cd9 100644 --- a/static/schemas/source/formats/canonical/display_tag.json +++ b/static/schemas/source/formats/canonical/display_tag.json @@ -5,10 +5,6 @@ "description": "Third-party-served display tag (JS, iframe, or 1×1 redirect). The buyer's adserver hosts the creative; the seller calls the tag URL at impression time. Slot: `tag_url` (url asset with appropriate `url_type`). Tracking model: opaque to seller — third party serves and measures. Click tracking via redirect URL substitution using universal_macros. Distinct from `image` (static asset hosted by seller) and `html5` (zip bundle hosted by seller).", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { - "status": { - "default": "stable", - "description": "Track A (v1-translatable): default `stable` at 3.1 GA. Third-party display-tag delivery is a settled industry pattern; the parameter shape is locked at GA. See `_base.json#status` for the rubric." - }, "slots": { "default": [ { "asset_group_id": "tag_url", "asset_type": "url", "required": true }, diff --git a/static/schemas/source/formats/canonical/image.json b/static/schemas/source/formats/canonical/image.json index 5496844349..b91511b9af 100644 --- a/static/schemas/source/formats/canonical/image.json +++ b/static/schemas/source/formats/canonical/image.json @@ -5,10 +5,6 @@ "description": "Static image creative format. Slots: `image_main` (image asset, file or hosted URL), optional `headline` (text), `body_text` (text), `cta` (text/enum), `landing_page_url` (url). Tracking model: impression pixel + click URL via universal_macros, with optional viewability pixel. Distinct from `html5` (interactive bundles) and `display_tag` (third-party served). AR/dimensions narrow to specific sizes via product parameters — covers IAB display sizes (300x250, 728x90, 970x250, etc.) without a separate iab_size enum.", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { - "status": { - "default": "stable", - "description": "Track A (v1-translatable): default `stable` at 3.1 GA. This canonical re-encodes IAB display sizes that have been the same for 20 years; the parameter shape is locked at GA. See `_base.json#status` for the rubric." - }, "slots": { "default": [ { "asset_group_id": "image_main", "asset_type": "image", "required": true }, @@ -60,17 +56,17 @@ "items": { "type": "string" }, "description": "Permitted CTA values for this product (e.g., ['LEARN_MORE', 'SHOP_NOW'])." }, - "image_source": { + "asset_source": { "type": "string", - "enum": ["buyer_uploaded", "seller_pre_rendered_from_brief", "seller_human_designed", "agent_synthesized"], + "enum": ["buyer_uploaded", "publisher_host_recorded", "seller_pre_rendered_from_brief", "seller_human_designed", "agent_synthesized"], "default": "buyer_uploaded", - "description": "Where the rendered image comes from. Parallels `audio_source` on `audio_hosted`. `buyer_uploaded` (default): buyer ships a pre-rendered image asset; product narrows file format / dimensions / size. `seller_pre_rendered_from_brief`: buyer ships a brief plus structured copy (headline, landing_page_url); seller renders ONE image at sync_creatives or build_creative time and serves it like any deterministic creative (generative-DSP pattern: universalads, Pencil, AdCreative.ai). `seller_human_designed`: seller's design team renders manually from a brief (human-in-the-loop services). `agent_synthesized`: AI synthesis pipeline; pair with `synthesis_nondeterministic: true` when the platform cannot guarantee in-spec output (Veo/Sora/Imagen-class). The `slots` declaration is the binding contract for what the buyer ships; `image_source` is informational and lets buyers understand the production model when picking products." + "description": "Where the rendered asset bytes come from. Single shared enum across all canonicals (`image`, `video_hosted`, `audio_hosted` — replaces the earlier per-canonical `image_source` / `video_source` / `audio_source` fields). `buyer_uploaded` (default): buyer ships a pre-rendered asset. `publisher_host_recorded`: publisher's host records the asset (audio-specific; podcast host-read pattern). `seller_pre_rendered_from_brief`: buyer ships a brief plus structured copy; seller renders ONE asset at sync_creatives or build_creative time (generative-DSP pattern). `seller_human_designed`: seller's design team renders manually from a brief. `agent_synthesized`: AI synthesis pipeline; pair with `synthesis_nondeterministic: true` when the platform cannot guarantee in-spec output (Veo/Sora/Imagen-class).\n\nNot every value is meaningful on every canonical — `publisher_host_recorded` is audio-specific; on `image` or `video_hosted` it has no defined behavior. Adopters MUST select a value appropriate to the canonical's asset type. The `slots` declaration is the binding contract for what the buyer ships; `asset_source` is informational and lets buyers understand the production model when picking products." }, - "buyer_image_acceptance": { + "buyer_asset_acceptance": { "type": "string", "enum": ["accepted", "rejected"], "default": "accepted", - "description": "Whether the product accepts buyer-uploaded images. When `rejected`, the buyer cannot ship an image asset directly — they must use build_creative (or sync_creatives with brief inputs) so the seller produces the image. Combined with `image_source`, lets a product declare 'I produce images from briefs and refuse buyer uploads' (image_source=`seller_pre_rendered_from_brief`, buyer_image_acceptance=`rejected`)." + "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) so the seller produces 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`)." } }, "additionalProperties": true diff --git a/static/schemas/source/formats/canonical/responsive_creative.json b/static/schemas/source/formats/canonical/responsive_creative.json index 6204e8c2af..9ae46e5956 100644 --- a/static/schemas/source/formats/canonical/responsive_creative.json +++ b/static/schemas/source/formats/canonical/responsive_creative.json @@ -5,6 +5,10 @@ "description": "Buyer supplies a pool of typed assets (multiple headlines, descriptions, images, videos, logos); the surface algorithmically composes combinations per placement. **Composition is algorithmic** — surface picks combinations and reports per-asset performance breakdowns. Covers Google Responsive Display Ads (RDA), Responsive Search Ads (RSA), Performance Max (PMax), Demand Gen, and Meta Advantage+ creative. Industry term: \"Responsive\" (Google) / \"Advantage+ creative\" (Meta) / \"Dynamic Creative\" (older Meta term). Distinct from `sponsored_placement` (catalog-driven, deterministic) and `agent_placement` (AI-surface composition). The structured `slots` field below enumerates expected canonical asset_group_id slots; per-slot count/length narrowing lives in flat parameters (`headlines_min`, `headline_max_chars`, etc.).", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { + "experimental": { + "default": true, + "description": "Marked experimental at 3.1 GA: composition is algorithmic (the surface picks combinations and reports per-asset breakdowns), and there's no clean v1-translatable equivalent. Buyers ship asset pools rather than rendered creatives; the surface's per-impression composition cannot be predicted by `validate_input`. Adopters SHOULD validate behavior per surface (Google PMax vs Meta Advantage+ creative differ meaningfully)." + }, "slots": { "default": [ { "asset_group_id": "headlines", "asset_type": "text", "required": true, "min": 3, "max": 15 }, diff --git a/static/schemas/source/formats/canonical/sponsored_placement.json b/static/schemas/source/formats/canonical/sponsored_placement.json index 961f2b421a..b0d40ecc6a 100644 --- a/static/schemas/source/formats/canonical/sponsored_placement.json +++ b/static/schemas/source/formats/canonical/sponsored_placement.json @@ -5,6 +5,10 @@ "description": "Catalog-driven retail-media format. Slot: `source_catalog` (catalog asset — product/SKU/ASIN/GTIN catalog reference), 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. Distinct from `responsive_creative` (algorithmic combinator from buyer pool) and `agent_placement` (text/audio AI-surface composition).", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { + "experimental": { + "default": true, + "description": "Marked experimental at 3.1 GA: the canonical covers 4 meaningfully different retail-media adapter contracts (Amazon SP, Criteo SP / CitrusAd SP, Pinterest Collection, generative-per-SKU). Adopter contracts vary; buyers MUST validate per-adapter behavior before routing budget. Promotion to non-experimental gated on the #4592 adapter-contract docs work." + }, "slots": { "default": [ { "asset_group_id": "source_catalog", "required": true, "asset_type": "catalog" }, @@ -52,7 +56,7 @@ "type": "string", "enum": ["buyer_uploaded", "seller_pre_rendered_from_brief", "seller_human_designed", "agent_synthesized"], "default": "buyer_uploaded", - "description": "How each per-item creative is produced. Aligned with the buyer-side default name (`buyer_uploaded`) used by `image_source` / `video_source` / `audio_source` so SDK codegen emits a single shared 4-value enum across all production-source fields. `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." + "description": "How each per-item creative is produced. Shares the same enum as `asset_source` on `image` / `video_hosted` / `audio_hosted` so SDK codegen emits a single shared production-source enum. `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." } }, "additionalProperties": true diff --git a/static/schemas/source/formats/canonical/video_hosted.json b/static/schemas/source/formats/canonical/video_hosted.json index 24efc27a00..e383a0b5ae 100644 --- a/static/schemas/source/formats/canonical/video_hosted.json +++ b/static/schemas/source/formats/canonical/video_hosted.json @@ -5,10 +5,6 @@ "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" }], "properties": { - "status": { - "default": "stable", - "description": "Track A (v1-translatable): default `stable` at 3.1 GA. Hosted video with OM-SDK + external tracker pixels is a settled pattern; the parameter shape is locked at GA. See `_base.json#status` for the rubric." - }, "slots": { "default": [ { "asset_group_id": "video_main", "asset_type": "video", "required": true }, @@ -87,13 +83,13 @@ "type": "array", "items": { "type": "integer", "minimum": 1 } }, - "video_source": { + "asset_source": { "type": "string", - "enum": ["buyer_uploaded", "seller_pre_rendered_from_brief", "seller_human_designed", "agent_synthesized"], + "enum": ["buyer_uploaded", "publisher_host_recorded", "seller_pre_rendered_from_brief", "seller_human_designed", "agent_synthesized"], "default": "buyer_uploaded", - "description": "Where the rendered video comes from. Parallels `audio_source` on `audio_hosted`. `buyer_uploaded` (default): buyer ships a pre-rendered video asset; product narrows codec / dimensions / duration. `seller_pre_rendered_from_brief`: buyer ships a brief plus structured copy; seller renders ONE video at sync_creatives or build_creative time and serves it like any deterministic creative (generative-DSP pattern). `seller_human_designed`: seller's editorial / design team renders manually from a brief. `agent_synthesized`: AI synthesis pipeline; pair with `synthesis_nondeterministic: true` for Veo / Sora / Runway-class flows where output dimensions or duration vary per run. The `slots` declaration is the binding contract for what the buyer ships; `video_source` is informational and lets buyers understand the production model when picking products." + "description": "Where the rendered asset bytes come from. Single shared enum across canonicals. See `image.json#asset_source` for the full semantics. `publisher_host_recorded` is audio-specific and has no defined behavior on video — adopters MUST select a value appropriate to the canonical." }, - "buyer_video_acceptance": { + "buyer_asset_acceptance": { "type": "string", "enum": ["accepted", "rejected"], "default": "accepted", diff --git a/static/schemas/source/formats/canonical/video_vast.json b/static/schemas/source/formats/canonical/video_vast.json index a7b4ac8ebb..67b45c542c 100644 --- a/static/schemas/source/formats/canonical/video_vast.json +++ b/static/schemas/source/formats/canonical/video_vast.json @@ -5,10 +5,6 @@ "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": { - "status": { - "default": "stable", - "description": "Track A (v1-translatable): default `stable` at 3.1 GA. VAST is an IAB standard since 2008; canonical re-encodes the established spec and is locked at GA. See `_base.json#status` for the rubric." - }, "slots": { "default": [ { "asset_group_id": "vast_tag", "asset_type": "vast", "required": true }, From 972a1575213ae3e858ece0298338b2b4e08a0930 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 16 May 2026 16:22:02 -0400 Subject: [PATCH 40/41] =?UTF-8?q?feat(canonical-formats):=20F+H=20?= =?UTF-8?q?=E2=80=94=20slot-constraint=20mutex=20+=20card-asset=20descript?= =?UTF-8?q?ion/cta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F (4c) — slot max_chars/max_size_kb mutex enforced via if/then on _base.json: - text/markdown/brief asset_types: max_chars allowed, max_size_kb rejected - image/video/audio/zip asset_types: max_size_kb allowed, max_chars rejected - url/catalog/html/css/javascript/webhook/daast/vast/card/object: both rejected - Field descriptions clarified to call out the mutex - Producers can no longer ship {asset_type: text, max_chars: 100, max_size_kb: 50} — downstream picks-arbitrarily bug eliminated H — card-asset.json expanded to cover Pinterest/Meta/TikTok/AI-surface multi-card patterns: - Added `description: string` (longer body copy, 100-500 chars typical) — Pinterest pin description, Meta carousel description, AI-result body - Added `cta: string` (per-card call-to-action label, MUST be in parent format's cta_values) — Meta and TikTok per-card CTA support - image_carousel.json: added `card_description_max_chars` parameter (parallel to existing `card_headline_max_chars`) - Description prose updated to call out Meta/Pinterest/Snap/TikTok/AI-surface coverage - platform_extensions remains the home for genuinely platform-specific fields (Pinterest rich-pin, Snap price tags, source attribution) Tests: 14 positive + 19 negative all green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../source/core/assets/card-asset.json | 14 ++++++-- .../source/formats/canonical/_base.json | 36 +++++++++++++++++-- .../formats/canonical/image_carousel.json | 8 ++++- 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/static/schemas/source/core/assets/card-asset.json b/static/schemas/source/core/assets/card-asset.json index d24ba9fd34..4fb3d16ffb 100644 --- a/static/schemas/source/core/assets/card-asset.json +++ b/static/schemas/source/core/assets/card-asset.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/assets/card-asset.json", "title": "Card Asset", - "description": "A single card in a multi-card creative (image_carousel, future composed carousels). Carries: `media` (an image OR video asset), optional `headline` (text), optional `landing_page_url` (url asset with `url_type: \"clickthrough\"`).\n\nUsed as the array element type for the `cards` slot on image_carousel canonicals. Adopters MUST NOT invent per-card key conventions like `card_0_headline` / `cards.0.headline` — the manifest's `assets.cards` value is an array of card-asset objects, period. Per-card platform extensions (e.g., Meta-specific carousel attributes, Pinterest pin overrides) attach via the `platform_extensions` field, never via inline non-canonical keys.", + "description": "A single card in a multi-card creative (image_carousel, future composed carousels). Carries: `media` (an image OR video asset), optional `headline` (short text), optional `description` (longer text), optional `cta` (call-to-action label), optional `landing_page_url` (url asset with `url_type: \"clickthrough\"`).\n\nCovers the multi-card patterns across Meta carousel, Pinterest sponsored pin, Snap Collection, TikTok carousel, and AI-surface result cards. The two-field text shape (`headline` + `description`) reflects how almost every adopter splits short labels from longer copy; Pinterest pin description and Meta per-card description both go in `description`.\n\nUsed as the array element type for the `cards` slot on image_carousel canonicals. Adopters MUST NOT invent per-card key conventions like `card_0_headline` / `cards.0.headline` — the manifest's `assets.cards` value is an array of card-asset objects, period. Per-card platform extensions (e.g., Meta-specific carousel attributes, Pinterest rich-pin metadata, Snap Collection price tags) attach via the `platform_extensions` field, never via inline non-canonical keys.", "type": "object", "required": ["asset_type", "media"], "properties": { @@ -12,7 +12,7 @@ "description": "Discriminator identifying this as a card asset. See /schemas/creative/asset-types for the registry." }, "media": { - "description": "The card's primary visual asset. Either an `image` or `video` asset, matching the parent format's `allowed_card_asset_types` parameter.", + "description": "The card's primary visual asset. Either an `image` or `video` asset, matching the parent format's `allowed_card_media_asset_types` parameter.", "oneOf": [ { "$ref": "/schemas/core/assets/image-asset.json" }, { "$ref": "/schemas/core/assets/video-asset.json" } @@ -21,7 +21,15 @@ }, "headline": { "type": "string", - "description": "Optional per-card headline. Length governed by `card_headline_max_chars` on the format declaration." + "description": "Optional per-card short text label (typically 25-40 chars). Length governed by `card_headline_max_chars` on the format declaration. Meta carousel headline, Pinterest pin title, Snap Collection sticker text, TikTok caption-short." + }, + "description": { + "type": "string", + "description": "Optional per-card longer text (typically 100-500 chars). Distinct from `headline`: `description` is body copy, `headline` is the label. Length governed by `card_description_max_chars` on the format declaration. Meta carousel description, Pinterest pin description, AI-surface result body text, TikTok long caption." + }, + "cta": { + "type": "string", + "description": "Optional per-card call-to-action label (e.g., 'SHOP_NOW', 'LEARN_MORE'). When the parent format declares `cta_values` (allowed CTA labels), the per-card `cta` MUST be one of those values. Lets a Meta or TikTok carousel show different CTAs per card." }, "landing_page_url": { "$ref": "/schemas/core/assets/url-asset.json", diff --git a/static/schemas/source/formats/canonical/_base.json b/static/schemas/source/formats/canonical/_base.json index f1c4a8a5fa..1984d31c17 100644 --- a/static/schemas/source/formats/canonical/_base.json +++ b/static/schemas/source/formats/canonical/_base.json @@ -78,12 +78,12 @@ "max_chars": { "type": "integer", "minimum": 1, - "description": "Per-slot character limit for text / markdown / brief assets." + "description": "Per-slot character limit. Valid only when `asset_type` is `text`, `markdown`, or `brief`. Mutually exclusive with `max_size_kb` (which applies to binary asset types). Schema enforces via if/then so a producer can't set both on the same slot." }, "max_size_kb": { "type": "integer", "minimum": 1, - "description": "Per-slot file size limit in kilobytes for image / video / audio / zip assets." + "description": "Per-slot file size limit in kilobytes. Valid only when `asset_type` is `image`, `video`, `audio`, or `zip`. Mutually exclusive with `max_chars` (which applies to text asset types). Schema enforces via if/then so a producer can't set both on the same slot." }, "description": { "type": "string", @@ -95,7 +95,37 @@ "description": "Dispatch hint for `build_creative` and v1↔v2 wire translators: when `true`, the slot's value is consumed as INPUT to a production step (host-read script, brief copy fed to generative synthesis, catalog feed driving per-SKU rendering) and is not rendered verbatim. When `false` (default), the slot's value is rendered verbatim on the placement (image bytes, video file, display tag).\n\nMotivates the v1↔v2 dispatch table: pre-v2 buyers shipped production-consumed inputs separately in a `inputs` map on the build_creative request; v2 collapses inputs and rendered assets into a single `assets` map keyed by `asset_group_id`. SDK translators between v1 and v2 use this flag per canonical to know which assets in the v2 manifest map back to v1 `inputs` vs v1 `assets`. Without the per-slot flag the dispatch table lives in adopter code and every SDK gets it slightly different.\n\nProducers SHOULD set this explicitly on slots whose consumption pattern isn't obvious (host-read scripts on `audio_hosted`, briefs on generative `video_hosted`, catalog feeds on `sponsored_placement`). For canonicals where every slot is render-verbatim (`image`, `display_tag`, `video_vast`), the default `false` is sufficient and the flag MAY be omitted." } }, - "additionalProperties": true + "additionalProperties": true, + "allOf": [ + { + "$comment": "max_chars is valid only for text-shaped asset types; max_size_kb is valid only for binary asset types. A slot MUST NOT carry both; the type-appropriate one is enforced via if/then.", + "if": { + "properties": { "asset_type": { "enum": ["text", "markdown", "brief"] } }, + "required": ["asset_type"] + }, + "then": { "not": { "required": ["max_size_kb"] } } + }, + { + "if": { + "properties": { "asset_type": { "enum": ["image", "video", "audio", "zip"] } }, + "required": ["asset_type"] + }, + "then": { "not": { "required": ["max_chars"] } } + }, + { + "$comment": "Non-size asset types (url, catalog, html, css, javascript, webhook, daast, vast, card, object) reject both — there's no defined per-slot size semantics on those.", + "if": { + "properties": { "asset_type": { "enum": ["url", "catalog", "html", "css", "javascript", "webhook", "daast", "vast", "card", "object"] } }, + "required": ["asset_type"] + }, + "then": { + "not": { "anyOf": [ + { "required": ["max_chars"] }, + { "required": ["max_size_kb"] } + ] } + } + } + ] } }, "production_window_business_days": { diff --git a/static/schemas/source/formats/canonical/image_carousel.json b/static/schemas/source/formats/canonical/image_carousel.json index 63b290ee71..cc952a9938 100644 --- a/static/schemas/source/formats/canonical/image_carousel.json +++ b/static/schemas/source/formats/canonical/image_carousel.json @@ -55,7 +55,13 @@ }, "card_headline_max_chars": { "type": "integer", - "minimum": 1 + "minimum": 1, + "description": "Per-card headline character limit. Governs the `headline` field on each card-asset in the `cards` slot." + }, + "card_description_max_chars": { + "type": "integer", + "minimum": 1, + "description": "Per-card description character limit. Governs the `description` field on each card-asset in the `cards` slot. Distinct from `card_headline_max_chars`: description is longer body copy (typically 100-500 chars); headline is the short label (typically 25-40 chars)." }, "ssl_required": { "type": "boolean" From c97e4bb4226bb44588ba9992924586a2a816bf78 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 16 May 2026 16:52:09 -0400 Subject: [PATCH 41/41] =?UTF-8?q?fix(canonical-formats):=20expert-review?= =?UTF-8?q?=20fixes=20=E2=80=94=20rename=20stragglers,=20semver=20tighten,?= =?UTF-8?q?=20narrowing-check=20normative?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triaged from three expert reviews (code-reviewer, ad-tech-protocol, adtech-product): 1. Rename stragglers — audio_source/image_source/video_source/buyer_*_acceptance still in: - product-format-declaration.json (description prose + audio_hosted example) - audio_hosted.json (description + slots default description) - the_daily_30s_host_read fixture (description prose) - canonical-formats.mdx (production-source bullet list, generative DSP example) - canonical-formats-migration.mdx (multiple rows + prose) Schema accepted via additionalProperties:true but adopters reading the spec saw old names — worst kind of drift. 2. sponsored_placement.item_production_model now correctly says "4-value subset of asset_source's 5-value enum" instead of "shares the same enum" — the previous claim was structurally wrong (drops publisher_host_recorded). Glossary entry updated accordingly. 3. Glossary table had a sed-mishap "asset_source / asset_source" duplicate header — cleaned up. 4. tracking_extensions removed from product-format-declaration.json description prose (had been dropped from the schema but a reference survived in the field description). 5. since_version / migration_target_version pattern tightened from `^\d+\.\d+$` to `^[1-9]\d*\.(0|[1-9]\d*)$` — rejects `03.1`, `3.01`, leading-zero variants. MAJOR can't be 0 since AdCP is 3.x. 6. v1-canonical-mapping.json resolution-order step 1 now normatively requires SDKs SHOULD run the narrows check between the v2 declaration and the referenced v1 format's requirements; surfaces FORMAT_DECLARATION_DIVERGENT on conflict. Was prose-only ('Narrows — formal definition' section), now it's the registry step. Closes the gap that v1_format_ref was a hint, not a contract. 7. scripts/oneof-discriminators.baseline.json updated to accept 6 new undiscriminated oneOfs (all structurally distinguishable per code-reviewer: 4 single-vs-array assets patterns, 2 format_id-vs-format_kind mutexes, 1 v1_pattern variant). All 14 positive + 19 negative fixtures green. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/creative/canonical-formats-migration.mdx | 8 ++-- docs/creative/canonical-formats.mdx | 18 ++++----- scripts/oneof-discriminators.baseline.json | 38 +++++++++++++++++-- .../canonical/the_daily_30s_host_read.json | 2 +- .../core/product-format-declaration.json | 6 +-- .../source/formats/canonical/_base.json | 4 +- .../formats/canonical/audio_hosted.json | 4 +- .../canonical/sponsored_placement.json | 2 +- .../registries/v1-canonical-mapping.json | 2 +- 9 files changed, 56 insertions(+), 28 deletions(-) diff --git a/docs/creative/canonical-formats-migration.mdx b/docs/creative/canonical-formats-migration.mdx index 1441b4d78a..91e9019bc9 100644 --- a/docs/creative/canonical-formats-migration.mdx +++ b/docs/creative/canonical-formats-migration.mdx @@ -214,7 +214,7 @@ The agentic-adapters audit found ~30 `*_generated_*` format files (e.g., `meta_g - The format declaration's `slots` array enumerates everything the buyer ships in the manifest's `assets` map — each entry is a canonical `asset_group_id` paired with an `asset_type`. Some slots are rendered verbatim (image / video / audio); some are consumed for production (text script → host-read audio; brief → synthesized image; scenes → generated video). The seller dispatches per slot. - Whether the seller's internal production is generative AI, host recording, transcoding, or asset rendering is **invisible to the buyer** -- A single canonical format (e.g., `audio_hosted`) handles both buyer-uploaded audio and agent-produced audio; the format's `audio_source` and `buyer_audio_acceptance` parameters describe which flows are accepted +- A single canonical format (e.g., `audio_hosted`) handles both buyer-uploaded audio and agent-produced audio; the format's `asset_source` and `buyer_asset_acceptance` parameters describe which flows are accepted Side-by-side for an audio format: @@ -309,7 +309,7 @@ Three concrete hooks v2 introduces that existing seller implementations don't ha - **`sync_creatives` provenance verification when `provenance_required: true`.** When a v2 product's format declaration carries `provenance_required: true` (and the buyer's manifest contains synthesized assets — typically video/image from generative platforms), `sync_creatives` MUST verify a C2PA-compatible provenance manifest is attached and reject unsigned synthesized assets. This is a natural extension of existing AI-provenance tracking on Creative (EU AI Act Article 50 work) — the new piece is a validation hook that gates submission. Sellers without existing provenance plumbing only need this once they ship a v2 product with the flag set; until then it's no-op. - **`get_products` response gathers extension definitions.** When products carry v2 `format.params.platform_extensions` references, the response SHOULD include the referenced extension definitions in the `extensions` map keyed by `@sha256:`. Implementations gather extensions referenced by any product in the response, dedupe by digest, and emit. Buyers cache by URI@digest; subsequent responses MAY omit definitions the buyer already has cached. Trivial when no products use v2 declarations; only kicks in when tenants opt in. -- **`production_window_business_days` on host-read / agent-produced products.** Today most server implementations don't model production turnaround on Products — the field is a v2 addition. Only matters once a tenant ships a v2 host-read or generative-video product (audio_hosted with `audio_source: 'publisher_host_recorded'`, or any product with `synthesis_nondeterministic: true`). Today many of these flows route through hand-trafficked sponsorships and don't surface turnaround over the protocol; v2 makes it declarable. +- **`production_window_business_days` on host-read / agent-produced products.** Today most server implementations don't model production turnaround on Products — the field is a v2 addition. Only matters once a tenant ships a v2 host-read or generative-video product (audio_hosted with `asset_source: 'publisher_host_recorded'`, or any product with `synthesis_nondeterministic: true`). Today many of these flows route through hand-trafficked sponsorships and don't surface turnaround over the protocol; v2 makes it declarable. #### Shipping a custom format @@ -335,7 +335,7 @@ The spec has historically read sales-agent-first. v2 reshapes the creative-agent | Format authoring | You authored named formats keyed under `your-domain.adcp` and published them | You declare which AdCP-defined canonical formats you can produce (`format_kind` discriminator) with your platform-specific narrowing (`params`). No more publishing free-text named formats. | | Discovery | Sales agents pointed buyers at you via `creative_agents[]` (recursive query); buyers fetched your `list_creative_formats` to learn what you produce | Buyers reach you directly — through brand-side relationships, AAO registry, direct knowledge. Sales agents in v2 do NOT carry a list of "approved creative agents." Each side is independent. | | `build_creative` contract | Buyer shipped a manifest with `format_id` + `assets` + `inputs` (separate "production inputs" map) | Buyer ships the same envelope, but `inputs` is collapsed into `assets` — everything goes through one `assets` map keyed by canonical `asset_group_id`. The format's `slots` declaration tells you which assets to expect, each typed by `asset_type`. The seller (you) dispatches per slot — render verbatim for `image` / `video` / `audio` slots; consume for production for `text` / `brief` / `object` slots (e.g., `script` text → host-recorded audio; `creative_brief` brief → generated image). | -| Production-source declaration | Implicit (the named format's name implied the model — `*_generated_*` for AI-produced) | Explicit per-canonical: `audio_source` / `image_source` / `video_source` enums declare who renders and when (`buyer_uploaded` / `publisher_host_recorded` / `seller_pre_rendered_from_brief` / `seller_human_designed` / `agent_synthesized`). Plus `synthesis_nondeterministic: true` for Veo/Sora-class flows that need post-synthesis QA-loop semantics. | +| Production-source declaration | Implicit (the named format's name implied the model — `*_generated_*` for AI-produced) | Explicit per-canonical: `asset_source` / `asset_source` / `asset_source` enums declare who renders and when (`buyer_uploaded` / `publisher_host_recorded` / `seller_pre_rendered_from_brief` / `seller_human_designed` / `agent_synthesized`). Plus `synthesis_nondeterministic: true` for Veo/Sora-class flows that need post-synthesis QA-loop semantics. | | Tracking integration | Your platform's pixel IDs, viewability vendors, OM-SDK partners lived in your named format's `tracking_events` field — sellers and buyers parsed your free-text declarations | Declare via `platform_extensions: [{uri, digest}]` on each `supported_formats[].format`. Each extension is a URI you host (or the AAO mirror translates) describing the schema for your platform's tracking surface (pixel IDs, conversion event taxonomies). The extension's `extends: "tracking"` metadata lets buyers filter tracking-related entries without a separate `tracking_extensions` array. Sellers and buyers cache by `uri@digest`; SDK codegen produces typed extension handlers. | | Hosting of produced bytes | Your CDN, your call | Your CDN, your call. v2 disaggregation is conceptual (the spec separates production from serving from tracking) — operationally, produced asset URLs in the manifest you return from `build_creative` continue to point at your CDN, your tracking JS continues to instrument, your platform extensions document the integration. | @@ -458,7 +458,7 @@ A transformation agent that takes a buyer's brief or script and produces a rende } ``` -Two distinct capabilities — brief-to-audio (creative direction → produced ad) and script-to-audio (deterministic TTS from verbatim script) — declared as two `supported_formats` entries sharing `format_kind: audio_hosted` but with different `audio_source` values. `capability_id` disambiguates which capability the buyer is invoking when they call `build_creative`. +Two distinct capabilities — brief-to-audio (creative direction → produced ad) and script-to-audio (deterministic TTS from verbatim script) — declared as two `supported_formats` entries sharing `format_kind: audio_hosted` but with different `asset_source` values. `capability_id` disambiguates which capability the buyer is invoking when they call `build_creative`. #### Server-side hooks for creative agents diff --git a/docs/creative/canonical-formats.mdx b/docs/creative/canonical-formats.mdx index c61e346376..9bacb14882 100644 --- a/docs/creative/canonical-formats.mdx +++ b/docs/creative/canonical-formats.mdx @@ -36,7 +36,7 @@ Canonical formats collapse today's separate format registry into product-bound d | **`synthesis_nondeterministic`** | When true, the production pipeline cannot guarantee in-spec output (Veo/Sora-class). Implies QA-loop + retry semantics. | | **`provenance_required`** | When true, the product rejects unsigned synthesized assets. Builders attach C2PA-compatible provenance manifests. | | **`platform_extensions`** | URI+digest references to platform-specific extensions narrowing the canonical (pixel ID shapes, conversion event taxonomies). | -| **`asset_source` / `asset_source`** | Per-canonical production-source declaration: who renders the asset bytes. Single shared `asset_source` enum across `image` / `video_hosted` / `audio_hosted`; `item_production_model` on `sponsored_placement` shares the same enum. Replaces the earlier per-canonical `audio_source` / `image_source` / `video_source` fields. | +| **`asset_source`** | Production-source declaration: who renders the asset bytes. Single shared 5-value enum across `image` / `video_hosted` / `audio_hosted`. `item_production_model` on `sponsored_placement` covers the same axis with a 4-value subset (drops `publisher_host_recorded`, which is audio-specific). | | **`status` on canonicals** | Spec-maturity axis (`stable` / `preview` / `deprecated`). All canonicals are `preview` while v2 itself is in preview; per-canonical promotion to `stable` happens at 3.1 GA based on adopter evidence. | | **`since_version` / `migration_target_version`** | Release-precision lifecycle metadata on canonicals — when introduced, when stabilization or breaking revision is expected. | | **`validate_input`** | Spec-defined dry-run primitive — buyers verify a manifest against canonicals/products without committing to a render. | @@ -106,13 +106,11 @@ Two orthogonal patterns govern how a creative is produced and how it serves. Con - `deterministic` — buyer can predict per-slot rendering. The surface serves what it received. (`image`, `video_hosted`, `audio_hosted`, `video_vast`, `audio_daast`, `sponsored_placement`.) - `algorithmic` — surface picks combinations from a buyer-supplied asset pool per-impression. The buyer ships a pool; the surface composes. (`responsive_creative` for Google PMax / Meta Advantage+; `agent_placement` for AI-surface composition.) -**Production source** — per-canonical `asset_source` parameters, all sharing a single 5-value enum: `buyer_uploaded | publisher_host_recorded | seller_pre_rendered_from_brief | seller_human_designed | agent_synthesized`. Describes **who renders the rendered asset, and when**: -- `audio_source` on `audio_hosted` -- `image_source` on `image` -- `video_source` on `video_hosted` -- `item_production_model` on `sponsored_placement` — same enum, applied per catalog item (the multi-output generative case: 1 brief × N catalog items → N rendered creatives) +**Production source** — `asset_source` describes **who renders the rendered asset, and when**: +- `asset_source` on `image`, `video_hosted`, `audio_hosted` — single shared 5-value enum: `buyer_uploaded | publisher_host_recorded | seller_pre_rendered_from_brief | seller_human_designed | agent_synthesized`. `publisher_host_recorded` is audio-specific (podcast host-read pattern) and meaningful only on `audio_hosted`. +- `item_production_model` on `sponsored_placement` — same axis, 4-value subset (drops `publisher_host_recorded`), applied per catalog item (the multi-output generative case: 1 brief × N catalog items → N rendered creatives) -The two axes don't collapse. A generative DSP that produces ONE rendered image from a brief is `composition_model: deterministic` (the surface serves what it received) + `image_source: seller_pre_rendered_from_brief` (seller produced it from inputs at sync_creatives time). A retail-media surface that runs an AI synthesis pipeline per catalog item is `composition_model: deterministic` + `item_production_model: agent_synthesized`. Google PMax is `composition_model: algorithmic` + (production-source unspecified — buyer ships a pool of pre-rendered assets so the production-source question doesn't apply at the format level). +The two axes don't collapse. A generative DSP that produces ONE rendered image from a brief is `composition_model: deterministic` (the surface serves what it received) + `asset_source: seller_pre_rendered_from_brief` (seller produced it from inputs at sync_creatives time). A retail-media surface that runs an AI synthesis pipeline per catalog item is `composition_model: deterministic` + `item_production_model: agent_synthesized`. Google PMax is `composition_model: algorithmic` + (production-source unspecified — buyer ships a pool of pre-rendered assets so the production-source question doesn't apply at the format level). The production-source enums are informational, not the binding contract. The format's `slots` declaration is the contract — what the buyer ships, in what shape. The `asset_source` field tells the buyer "here's how this product produces the rendered creative" so they can pick products whose production model fits their workflow (in-house pre-rendered vs upstream creative agent vs seller-driven generative). @@ -435,7 +433,7 @@ The buyer submits assets directly to the seller; the seller produces internally 3. Seller produces internally; how is invisible to the buyer 4. Returns async status; buyer polls or waits for completion -The format's `audio_source: "publisher_host_recorded"` + `buyer_audio_acceptance: "rejected"` tells the buyer which flows are accepted. For The Daily's host-read, both flows are valid because the publisher's host needs to be the producer in either case — the difference is whether the buyer drives the build call or the seller drives it. Other products might accept Flow 1 only (buyer must pre-produce) or Flow 2 only. +The format's `asset_source: "publisher_host_recorded"` + `buyer_asset_acceptance: "rejected"` tells the buyer which flows are accepted. For The Daily's host-read, both flows are valid because the publisher's host needs to be the producer in either case — the difference is whether the buyer drives the build call or the seller drives it. Other products might accept Flow 1 only (buyer must pre-produce) or Flow 2 only. For brief-driven (talking-points-style) host-reads, the same shape applies with a `creative_brief` slot (asset_type `brief`) in place of the `script` slot. Same target format (`audio_hosted`); different slot declaration. @@ -511,7 +509,7 @@ Buyer calls `sync_creatives` on NYTimes with the manifest from Flashtalking. NYT The seller's validation contract is the canonical, not the creative agent. This is what makes the third-party path additive rather than coupled: the buyer can swap creative agents without changing the seller-facing flow. -## Worked example — generative DSP (universalads-class, image_source: seller_pre_rendered_from_brief) +## Worked example — generative DSP (universalads-class, asset_source: seller_pre_rendered_from_brief) A generative DSP (universalads, Pencil, AdCreative.ai-shaped tools) is a sales agent that ALSO renders creatives inline at `sync_creatives` time — it is NOT a creative agent the buyer calls separately. The buyer ships a brief plus structured copy; the seller renders ONE image and serves it like any deterministic creative. @@ -551,7 +549,7 @@ A generative DSP (universalads, Pencil, AdCreative.ai-shaped tools) is a sales a } ``` -Buyer's manifest carries the brief, headline, and clickthrough URL — no rendered image asset. Seller's `sync_creatives` produces the rendered MREC PNG and registers it. Two axes: `composition_model: deterministic` (the surface serves what it received), `image_source: seller_pre_rendered_from_brief` (the seller renders from inputs at sync time). `buyer_image_acceptance: "rejected"` makes it explicit that the buyer cannot ship a pre-rendered image directly — the production model is brief-driven only. +Buyer's manifest carries the brief, headline, and clickthrough URL — no rendered image asset. Seller's `sync_creatives` produces the rendered MREC PNG and registers it. Two axes: `composition_model: deterministic` (the surface serves what it received), `asset_source: seller_pre_rendered_from_brief` (the seller renders from inputs at sync time). `buyer_asset_acceptance: "rejected"` makes it explicit that the buyer cannot ship a pre-rendered image directly — the production model is brief-driven only. ## Worked example — multi-format product (Flashtalking html5 OR internal display_tag) diff --git a/scripts/oneof-discriminators.baseline.json b/scripts/oneof-discriminators.baseline.json index b256d2d545..7a99f333e9 100644 --- a/scripts/oneof-discriminators.baseline.json +++ b/scripts/oneof-discriminators.baseline.json @@ -38,8 +38,8 @@ }, "brand.json##/oneOf": { "kind": "dangerous", - "variants": 4, - "note": "shared-required=[∅]; 0:req=[authoritative_location] | 1:req=[house] | 2:req=[∅] | 3:req=[house,brands]" + "variants": 5, + "note": "shared-required=[∅]; 0:req=[authoritative_location] | 1:req=[house] | 2:req=[∅] | 3:req=[house] | 4:req=[∅]" }, "brand/acquire-rights-response.json##/oneOf": { "kind": "dangerous", @@ -146,10 +146,30 @@ "variants": 3, "note": "0:[travel_time,transport_mode] | 1:[radius] | 2:[geometry]" }, + "core/creative-asset.json##/oneOf": { + "kind": "narrowable", + "variants": 2, + "note": "0:[format_id] | 1:[format_kind]" + }, + "core/creative-asset.json##/properties/assets/patternProperties/^[a-z0-9_]+$/oneOf": { + "kind": "dangerous", + "variants": 2, + "note": "shared-required=[∅]; 0:nested-union(/schemas/core/assets/asset-union.json) | 1:array" + }, + "core/creative-manifest.json##/oneOf": { + "kind": "narrowable", + "variants": 2, + "note": "0:[format_id] | 1:[format_kind]" + }, + "core/creative-manifest.json##/properties/assets/patternProperties/^[a-z0-9_]+$/oneOf": { + "kind": "dangerous", + "variants": 2, + "note": "shared-required=[∅]; 0:nested-union(/schemas/core/assets/asset-union.json) | 1:array" + }, "core/format.json##/properties/assets/items/oneOf": { "kind": "dangerous", - "variants": 15, - "note": "shared-required=[∅]; 0:req=[∅] | 1:req=[∅] | 2:req=[∅] | 3:req=[∅] | 4:req=[∅] | 5:req=[∅] | 6:req=[∅] | 7:req=[∅] | 8:req=[∅] | 9:req=[∅] | 10:req=[∅] | 11:req=[∅] | 12:req=[∅] | 13:req=[∅] | 14:req=[item_type,asset_group_id,required,min_count,max_count,assets]" + "variants": 16, + "note": "shared-required=[∅]; 0:req=[∅] | 1:req=[∅] | 2:req=[∅] | 3:req=[∅] | 4:req=[∅] | 5:req=[∅] | 6:req=[∅] | 7:req=[∅] | 8:req=[∅] | 9:req=[∅] | 10:req=[∅] | 11:req=[∅] | 12:req=[∅] | 13:req=[∅] | 14:req=[∅] | 15:req=[item_type,asset_group_id,required,min_count,max_count,assets]" }, "core/format.json##/properties/renders/items/oneOf": { "kind": "narrowable", @@ -181,6 +201,11 @@ "variants": 2, "note": "0:[results] | 1:[errors]" }, + "creative/list-creatives-response.json##/properties/creatives/items/properties/assets/patternProperties/^[a-z0-9_]+$/oneOf": { + "kind": "dangerous", + "variants": 2, + "note": "shared-required=[∅]; 0:nested-union(/schemas/core/assets/asset-union.json) | 1:array" + }, "creative/preview-creative-response.json##/oneOf/1/properties/results/items/oneOf": { "kind": "narrowable", "variants": 2, @@ -271,6 +296,11 @@ "variants": 2, "note": "shared-required=[∅]; 0:string | 1:ref-only(/schemas/core/property-id.json)" }, + "registries/v1-canonical-mapping.json##/properties/mappings/items/properties/v1_pattern/oneOf": { + "kind": "narrowable", + "variants": 2, + "note": "0:[format_id_glob] | 1:[structural]" + }, "signals/activate-signal-response.json##/oneOf": { "kind": "narrowable", "variants": 2, diff --git a/static/examples/products/canonical/the_daily_30s_host_read.json b/static/examples/products/canonical/the_daily_30s_host_read.json index d7b06fe513..24b9f3ed87 100644 --- a/static/examples/products/canonical/the_daily_30s_host_read.json +++ b/static/examples/products/canonical/the_daily_30s_host_read.json @@ -2,7 +2,7 @@ "$schema": "/schemas/core/product.json", "product_id": "the_daily_30s_host_read_us", "name": "The Daily — 30s Host-Read Pre-roll (US)", - "description": "30-second podcast host-read pre-roll on The Daily. Buyer-uploaded audio is rejected (audio_source: publisher_host_recorded); buyer submits a verbatim script (≤800 chars) as a text asset under the `script` slot, plus brand context via the manifest's BrandRef. The publisher's host records the audio, which is dynamically inserted at podcast playback time. 7-business-day production turnaround. A brief-driven host-read product would have the same shape with `creative_brief` (brief asset_type) in the slots instead of `script` (text asset_type).", + "description": "30-second podcast host-read pre-roll on The Daily. Buyer-uploaded audio is rejected (asset_source: publisher_host_recorded); buyer submits a verbatim script (≤800 chars) as a text asset under the `script` slot, plus brand context via the manifest's BrandRef. The publisher's host records the audio, which is dynamically inserted at podcast playback time. 7-business-day production turnaround. A brief-driven host-read product would have the same shape with `creative_brief` (brief asset_type) in the slots instead of `script` (text asset_type).", "publisher_properties": [ { "publisher_domain": "thedailypod.example", diff --git a/static/schemas/source/core/product-format-declaration.json b/static/schemas/source/core/product-format-declaration.json index 60dd49cd9b..017281bbb7 100644 --- a/static/schemas/source/core/product-format-declaration.json +++ b/static/schemas/source/core/product-format-declaration.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/core/product-format-declaration.json", "title": "Product Format Declaration", - "description": "Inline format declaration on a product. The `format_kind` discriminator names which canonical format the product narrows; `params` carries the canonical's parameter schema (slots, dimensions, durations, codecs, character limits, platform_extensions, tracking_extensions, etc.). Optional `capability_id` (stable identifier for routing when a product's `format_options` contains multiple declarations sharing the same `format_kind`), `display_name` (seller-controlled human-readable label for dashboard and catalog UIs), and `applies_to_channels` (subset of the product's declared channels this declaration applies to — lets a multi-channel product carry distinct format_options per channel). Discriminated-union shape generates clean tagged unions in TypeScript and Pydantic codegen. Replaces v1's named-format pattern (where products referenced a separately-defined format file via compound `format_id`). v1 named formats remain supported through the deprecation cycle; v2 product-bound declarations are opt-in.\n\n**Custom format_kind** (`format_kind: \"custom\"`): for adopter-defined shapes that don't fit the 11 canonicals (multi-placement takeover, roadblock, branded content, cross-screen sponsorship, sponsorship lockup, newsletter sponsorship, AR lens, playable, live event sponsorship). When `format_kind` is `custom`, the declaration MUST carry `format_shape` (recognized global pattern from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json)) AND `format_schema` (URI+digest reference to a fetchable schema describing the actual `params` and `slots`). Buyer agents fetch the schema, validate manifests structurally, and reason about manifests without per-seller integration code. See [adcp#3666](https://github.com/adcontextprotocol/adcp/issues/3666) for the canonical promotion queue.", + "description": "Inline format declaration on a product. The `format_kind` discriminator names which canonical format the product narrows; `params` carries the canonical's parameter schema (slots, dimensions, durations, codecs, character limits, platform_extensions, etc.). Optional `capability_id` (stable identifier for routing when a product's `format_options` contains multiple declarations sharing the same `format_kind`), `display_name` (seller-controlled human-readable label for dashboard and catalog UIs), and `applies_to_channels` (subset of the product's declared channels this declaration applies to — lets a multi-channel product carry distinct format_options per channel). Discriminated-union shape generates clean tagged unions in TypeScript and Pydantic codegen. Replaces v1's named-format pattern (where products referenced a separately-defined format file via compound `format_id`). v1 named formats remain supported through the deprecation cycle; v2 product-bound declarations are opt-in.\n\n**Custom format_kind** (`format_kind: \"custom\"`): for adopter-defined shapes that don't fit the 11 canonicals (multi-placement takeover, roadblock, branded content, cross-screen sponsorship, sponsorship lockup, newsletter sponsorship, AR lens, playable, live event sponsorship). When `format_kind` is `custom`, the declaration MUST carry `format_shape` (recognized global pattern from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json)) AND `format_schema` (URI+digest reference to a fetchable schema describing the actual `params` and `slots`). Buyer agents fetch the schema, validate manifests structurally, and reason about manifests without per-seller integration code. See [adcp#3666](https://github.com/adcontextprotocol/adcp/issues/3666) for the canonical promotion queue.", "type": "object", "required": ["format_kind", "params"], "discriminator": { "propertyName": "format_kind" }, @@ -233,8 +233,8 @@ "audio_sample_rates": [44100, 48000], "audio_channels": ["stereo"], "loudness_lufs": -16, - "audio_source": "publisher_host_recorded", - "buyer_audio_acceptance": "rejected", + "asset_source": "publisher_host_recorded", + "buyer_asset_acceptance": "rejected", "composition_model": "deterministic", "slots": [ { "asset_group_id": "script", "required": true, "asset_type": "text", "max_chars": 800 }, diff --git a/static/schemas/source/formats/canonical/_base.json b/static/schemas/source/formats/canonical/_base.json index 1984d31c17..da82f20139 100644 --- a/static/schemas/source/formats/canonical/_base.json +++ b/static/schemas/source/formats/canonical/_base.json @@ -17,12 +17,12 @@ }, "since_version": { "type": "string", - "pattern": "^\\d+\\.\\d+$", + "pattern": "^[1-9]\\d*\\.(0|[1-9]\\d*)$", "description": "AdCP MAJOR.MINOR version that introduced this canonical (e.g., '3.1', '3.2'). Lets adopters reason about minimum protocol version requirements when consuming a format declaration. Patch precision is intentionally rejected — canonicals are introduced at minor-version boundaries." }, "migration_target_version": { "type": "string", - "pattern": "^\\d+\\.\\d+$", + "pattern": "^[1-9]\\d*\\.(0|[1-9]\\d*)$", "description": "AdCP MAJOR.MINOR version by which the working group expects this canonical to stabilize, surface a breaking revision, or (when `deprecated: true`) be removed. Patch precision is intentionally rejected — canonicals shift at minor-version boundaries. Absence signals 'no specific target' (omit the field rather than use a placeholder like 'unknown')." }, "composition_model": { diff --git a/static/schemas/source/formats/canonical/audio_hosted.json b/static/schemas/source/formats/canonical/audio_hosted.json index 8840f51fab..7096d3cb8f 100644 --- a/static/schemas/source/formats/canonical/audio_hosted.json +++ b/static/schemas/source/formats/canonical/audio_hosted.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/formats/canonical/audio_hosted.json", "title": "Canonical Format: Hosted Audio", - "description": "Direct audio creative — buyer ships an `audio` asset (mp3/aac/wav) for asset-driven products, or ships a `script` / `creative_brief` text asset for products where the seller produces audio internally (podcast host-reads, TTS synthesis). Optional companion slots: `companion_image`, `brand_name`, `landing_page_url`. Tracking model: standard impression + completion + companion-image-click pixels via universal_macros. Distinct from `audio_daast` (DAAST tag, inherent DAAST event tracking). For host-reads and synthesized audio, the format declares `audio_source: 'publisher_host_recorded'` or `'agent_synthesized'` plus `buyer_audio_acceptance: 'rejected'`; the format's `slots` declaration enumerates which assets the buyer ships (e.g., `script` text asset for host-reads). The seller decides how to consume each asset (render verbatim vs produce audio from text) — there is no separate manifest 'inputs' map; everything the buyer ships goes in `assets`.", + "description": "Direct audio creative — buyer ships an `audio` asset (mp3/aac/wav) for asset-driven products, or ships a `script` / `creative_brief` text asset for products where the seller produces audio internally (podcast host-reads, TTS synthesis). Optional companion slots: `companion_image`, `brand_name`, `landing_page_url`. Tracking model: standard impression + completion + companion-image-click pixels via universal_macros. Distinct from `audio_daast` (DAAST tag, inherent DAAST event tracking). For host-reads and synthesized audio, the format declares `asset_source: 'publisher_host_recorded'` or `'agent_synthesized'` plus `buyer_asset_acceptance: 'rejected'`; the format's `slots` declaration enumerates which assets the buyer ships (e.g., `script` text asset for host-reads). The seller decides how to consume each asset (render verbatim vs produce audio from text) — there is no separate manifest 'inputs' map; everything the buyer ships goes in `assets`.", "allOf": [{ "$ref": "/schemas/formats/canonical/_base.json" }], "properties": { "slots": { @@ -12,7 +12,7 @@ { "asset_group_id": "brand_name", "asset_type": "text", "required": false }, { "asset_group_id": "landing_page_url", "asset_type": "url", "required": false } ], - "description": "Default slots for buyer-uploaded audio. Host-read products override with a `script` (asset_type: text) or `creative_brief` (asset_type: brief) slot in place of `audio_main`, plus `audio_source: 'publisher_host_recorded'` and `buyer_audio_acceptance: 'rejected'`. TTS-from-script products override similarly with `audio_source: 'seller_pre_rendered_from_brief'`." + "description": "Default slots for buyer-uploaded audio. Host-read products override with a `script` (asset_type: text) or `creative_brief` (asset_type: brief) slot in place of `audio_main`, plus `asset_source: 'publisher_host_recorded'` and `buyer_asset_acceptance: 'rejected'`. TTS-from-script products override similarly with `asset_source: 'seller_pre_rendered_from_brief'`." }, "duration_ms_range": { "type": "array", diff --git a/static/schemas/source/formats/canonical/sponsored_placement.json b/static/schemas/source/formats/canonical/sponsored_placement.json index b0d40ecc6a..e13f402c71 100644 --- a/static/schemas/source/formats/canonical/sponsored_placement.json +++ b/static/schemas/source/formats/canonical/sponsored_placement.json @@ -56,7 +56,7 @@ "type": "string", "enum": ["buyer_uploaded", "seller_pre_rendered_from_brief", "seller_human_designed", "agent_synthesized"], "default": "buyer_uploaded", - "description": "How each per-item creative is produced. Shares the same enum as `asset_source` on `image` / `video_hosted` / `audio_hosted` so SDK codegen emits a single shared production-source enum. `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." + "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." } }, "additionalProperties": true diff --git a/static/schemas/source/registries/v1-canonical-mapping.json b/static/schemas/source/registries/v1-canonical-mapping.json index afb9832633..256312d975 100644 --- a/static/schemas/source/registries/v1-canonical-mapping.json +++ b/static/schemas/source/registries/v1-canonical-mapping.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/registries/v1-canonical-mapping.json", "title": "v1 → v2 Canonical Format Mapping Registry", - "description": "Authoritative AAO-published mapping from v1 named formats to v2 canonical declarations. Used by SDKs to project between wire shapes during the migration window: v1 format on the wire → v2 canonical in memory (or vice versa).\n\n**Resolution order** (per RFC #3305 amendment #3767, normative):\n1. **Authoritative v2→v1 link**: if any v2 `ProductFormatDeclaration` on the same product carries `v1_format_ref` pointing at this v1 format_id, use that v2 declaration. Highest priority — seller asserts the link directly.\n2. **Seller-asserted on the v1 file**: if the v1 format declaration carries an explicit `canonical` field, use it. (Note: `canonical_parameters` on the v1 file is deprecated for 3.1; SDKs reading 3.1 catalogs MUST still honor it when present, but `v1_format_ref` is the path forward.)\n3. **Registry glob**: look up `format_id` in this registry's `format_id_glob` entries.\n4. **Structural match**: attempt structural match against this registry's `structural` entries.\n5. **Fail closed**: SDK MUST NOT emit `format_options` for products carrying this format. SDKs MUST surface the resolution failure via the `errors[]` array on the `get_products` response envelope (NOT logger-only) with `code: FORMAT_PROJECTION_FAILED`, `field: \"products[N].format_ids[K]\"`, and `error.details: { format_id, product_id, resolution_failure: \"no_explicit_canonical\" | \"no_registry_match\" | \"no_structural_match\" }`. Logger-only warnings die in DEBUG and the buyer that silently sees N fewer products has no remediation path — wire-level surfacing in `errors[]` is the only place the warning durably reaches an operator. The advisory is non-fatal: the response stays 200/success, the product is still valid on the v1 path, only the v2 `format_options` projection is absent. Consumer-side counterpart to the producer SHOULD (sellers should add a v2 declaration with `v1_format_ref`, an explicit `canonical` field, or file a registry PR).\n\n**Match modes:**\n- `format_id_glob` — exact / glob match against the v1 `format_id.id` (the named-format identifier). Covers IAB-conventional sizes, named platform formats, common publisher conventions. Glob syntax: `*` matches any segment.\n- `structural` — match against the format's slot shape, asset types, and version constraints. Catches custom v1 formats that are structurally a standard format under a different name (e.g., `acme_homepage_300x250` is structurally an IAB MREC).\n\n**Alias collision precedence (normative).** When a v1 format's `assets[i]` carries multiple `asset_group_id` aliases that resolve to the same canonical asset_group (e.g., two slots both aliasing to `landing_page_url`), the SDK MUST resolve deterministically: the v1 format's `assets[*]` array order is authoritative — the first slot in declaration order wins, subsequent collisions are dropped from the projected v2 manifest and surfaced via `FORMAT_PROJECTION_FAILED` with `error.details: { collision_kind: \"asset_group_id_alias\", asset_group_id, winning_slot_id, dropped_slot_ids }`. SDKs MUST NOT silently pick one and discard the other without surfacing — silent picking creates inter-SDK divergence. Producers SHOULD avoid the collision by deduplicating aliased slots or using distinct `asset_group_id` values when both slots are semantically meaningful.\n\n**Governance**: same vocabulary-governance rules as `asset-group-vocabulary.json` and `format-shape-vocabulary.json` — additions land via PR with rationale + ≥1 reference adopter; AAO maintainer review; versioned + content-digested. Entries are additive; once published they are not removed (they may be marked `deprecated: true` if superseded).\n\n**Initial scope (3.1)**: ~15 unambiguous entries covering IAB display sizes, VAST 4.x, DAAST 1.x. Subsequent PRs expand coverage as adopter feedback surfaces patterns. The full v1-format audit dataset (~76% of formats from the 12-platform / 86-format audit in #3305) seeds the long-term roadmap.\n\nDigest the file content (sha256) when emitting in capabilities responses or referencing from SDK output. Buyers cache by `version` + `digest`.", + "description": "Authoritative AAO-published mapping from v1 named formats to v2 canonical declarations. Used by SDKs to project between wire shapes during the migration window: v1 format on the wire → v2 canonical in memory (or vice versa).\n\n**Resolution order** (per RFC #3305 amendment #3767, normative):\n1. **Authoritative v2→v1 link**: if any v2 `ProductFormatDeclaration` on the same product carries `v1_format_ref` pointing at this v1 format_id, use that v2 declaration. Highest priority — seller asserts the link directly. SDKs SHOULD run the *narrows* check (canonical-formats.mdx 'Narrows — formal definition') between the v2 declaration's `params` and the referenced v1 format's `requirements`; on conflict, surface `FORMAT_DECLARATION_DIVERGENT` on the `get_products` response `errors[]`. Without the narrowing check, `v1_format_ref` is a hint rather than a contract.\n2. **Seller-asserted on the v1 file**: if the v1 format declaration carries an explicit `canonical` field, use it. (Note: `canonical_parameters` on the v1 file is deprecated for 3.1; SDKs reading 3.1 catalogs MUST still honor it when present, but `v1_format_ref` is the path forward.)\n3. **Registry glob**: look up `format_id` in this registry's `format_id_glob` entries.\n4. **Structural match**: attempt structural match against this registry's `structural` entries.\n5. **Fail closed**: SDK MUST NOT emit `format_options` for products carrying this format. SDKs MUST surface the resolution failure via the `errors[]` array on the `get_products` response envelope (NOT logger-only) with `code: FORMAT_PROJECTION_FAILED`, `field: \"products[N].format_ids[K]\"`, and `error.details: { format_id, product_id, resolution_failure: \"no_explicit_canonical\" | \"no_registry_match\" | \"no_structural_match\" }`. Logger-only warnings die in DEBUG and the buyer that silently sees N fewer products has no remediation path — wire-level surfacing in `errors[]` is the only place the warning durably reaches an operator. The advisory is non-fatal: the response stays 200/success, the product is still valid on the v1 path, only the v2 `format_options` projection is absent. Consumer-side counterpart to the producer SHOULD (sellers should add a v2 declaration with `v1_format_ref`, an explicit `canonical` field, or file a registry PR).\n\n**Match modes:**\n- `format_id_glob` — exact / glob match against the v1 `format_id.id` (the named-format identifier). Covers IAB-conventional sizes, named platform formats, common publisher conventions. Glob syntax: `*` matches any segment.\n- `structural` — match against the format's slot shape, asset types, and version constraints. Catches custom v1 formats that are structurally a standard format under a different name (e.g., `acme_homepage_300x250` is structurally an IAB MREC).\n\n**Alias collision precedence (normative).** When a v1 format's `assets[i]` carries multiple `asset_group_id` aliases that resolve to the same canonical asset_group (e.g., two slots both aliasing to `landing_page_url`), the SDK MUST resolve deterministically: the v1 format's `assets[*]` array order is authoritative — the first slot in declaration order wins, subsequent collisions are dropped from the projected v2 manifest and surfaced via `FORMAT_PROJECTION_FAILED` with `error.details: { collision_kind: \"asset_group_id_alias\", asset_group_id, winning_slot_id, dropped_slot_ids }`. SDKs MUST NOT silently pick one and discard the other without surfacing — silent picking creates inter-SDK divergence. Producers SHOULD avoid the collision by deduplicating aliased slots or using distinct `asset_group_id` values when both slots are semantically meaningful.\n\n**Governance**: same vocabulary-governance rules as `asset-group-vocabulary.json` and `format-shape-vocabulary.json` — additions land via PR with rationale + ≥1 reference adopter; AAO maintainer review; versioned + content-digested. Entries are additive; once published they are not removed (they may be marked `deprecated: true` if superseded).\n\n**Initial scope (3.1)**: ~15 unambiguous entries covering IAB display sizes, VAST 4.x, DAAST 1.x. Subsequent PRs expand coverage as adopter feedback surfaces patterns. The full v1-format audit dataset (~76% of formats from the 12-platform / 86-format audit in #3305) seeds the long-term roadmap.\n\nDigest the file content (sha256) when emitting in capabilities responses or referencing from SDK output. Buyers cache by `version` + `digest`.", "version": "1.0.0", "last_updated": "2026-05-01", "type": "object",