diff --git a/.changeset/creative-transformer-receipt-and-param-enforcement.md b/.changeset/creative-transformer-receipt-and-param-enforcement.md new file mode 100644 index 0000000000..7b6d5a222a --- /dev/null +++ b/.changeset/creative-transformer-receipt-and-param-enforcement.md @@ -0,0 +1,13 @@ +--- +"adcontextprotocol": patch +--- + +spec(creative): harden the unreleased 3.1 creative-transformer surface — make three already-documented normative rules schema-enforceable, and fix a self-contradictory `leaves_total` formula. + +These refine the 3.1 transformer / `build_creative` multiplicity feature before GA. No new surface; each change makes an existing MUST checkable or corrects a description. + +- **Per-leaf pricing receipt is now enforced when a build reports cost.** `BuildCreativeVariantSuccess` documents that untrafficked best-of-N / fan-out leaves are billed via the inline per-leaf `vendor_cost` *only* (they never earn a `creative_id`, so never reach `report_usage`), and that the aggregate `vendor_cost` MUST equal the sum of the per-leaf values — but the leaf only required `[build_variant_id, creative_manifest]`, so a paid agent could bill N leaves and return no machine-readable cost for any of them. Added: (a) a branch-level `if (aggregate vendor_cost present) then` each produced leaf requires `vendor_cost` + `currency`; (b) per-leaf `dependencies` so a leaf can't carry a partial receipt (`vendor_cost`↔`currency` co-required; `pricing_option_id` ⇒ both). A genuinely free build omits the aggregate and is unaffected; a CPM-deferred leaf reports `vendor_cost: 0` (a value, not an omission). + +- **`transformer-param.json` `value_source` now binds to its descriptor.** The prose already stated the rules (`inline` ⇒ `allowed_values`; `range` ⇒ `minimum`/`maximum`; `free_text` ⇒ `type: string` and `allowed_values`/`minimum`/`maximum`/`options`/`options_cursor` absent), but nothing enforced them. Added `allOf` `if/then` blocks. `enumerable` is intentionally unconstrained — its `options[]` are returned only when expanded via `expand_params`. + +- **Fixed the `leaves_total` formula.** The `conditions_total` field documented the three-factor product (`items_to_produce × conditions_total × variants_per_item`) while the `leaves_total` field two lines down — and the `BuildCreativeVariantSuccess.leaves_total` description — stated the two-factor product, so an agent computing expected leaves from the field's own description under-counted by a factor of `conditions_total` whenever `signal_conditions` was present. All three now state the conditions factor consistently. Docs (`build_creative.mdx`, `creative-transformers.mdx` migration guide) updated to match. diff --git a/docs/creative/task-reference/build_creative.mdx b/docs/creative/task-reference/build_creative.mdx index 7fd54c6abb..0cfdd242c3 100644 --- a/docs/creative/task-reference/build_creative.mdx +++ b/docs/creative/task-reference/build_creative.mdx @@ -580,6 +580,8 @@ When the request uses `max_creatives`, `max_variants` > 1, `variant_axis`, or `r } ``` +_The `creatives[]` array above is abbreviated to one of the 5 returned groups; the aggregate `vendor_cost` of $4.00 covers all 5 groups × 2 variants × $0.40 — it equals the sum of every produced leaf's `vendor_cost`, not just the leaves shown._ + - **creatives[]**: One entry per built creative group. With `max_creatives`, there is one entry per sampled catalog item (`catalog_item_ref` identifies which item); without catalog fan-out there is a single entry. Treat this as the produced group set; choose among alternatives inside each group's `variants[]`. - **creatives[].build_creative_id**: Identifies a built creative within this response. - **creatives[].catalog_item_ref**: Present on catalog fan-out — an object identifying the source catalog item via `item_id` (plus optional `catalog_type`). @@ -591,7 +593,7 @@ When the request uses `max_creatives`, `max_variants` > 1, `variant_axis`, or `r - **variants[].variant_axis_value**: The value of the `variant_axis` dimension this variant represents (e.g. a voice, a theme). - **variants[].recommended** / **variants[].rank**: Best-of-N signals — `recommended` flags the agent's top pick; `rank` orders the variants. These plus `variant_axis` and `keep_mode` are how best-of-N is expressed. - **variants[].eval**: Present only when the request supplied an advisory [`evaluator`](#request-parameters) and the agent advertises `creative.supports_evaluator`. The rank-side of the [`get_creative_features`](/docs/governance/creative/get_creative_features) feature oracle. `eval.features[]` is **`creative-feature-result[]`** — the same shape `get_creative_features.results[]` returns (e.g. a `creative_quality_score` number, an `on_brief` categorical, a calibrated `predicted_performance`); the wrapper is open, each feature item is closed. The agent runs a **gate-then-rank** pipeline over its best-of-N: it evaluates each leaf, drops leaves failing the hard `evaluator.feature_requirement[]` gate from its recommended survivors, then orders survivors by `evaluator.rank_by` (`[{feature_id, direction}]`) — which is what `recommended`/`rank` reflect. The gate is **internal pruning** of which leaves the agent recommends from its own exploration, not an AdCP-layer block: what is produced and billed is governed by `max_variants`/`max_creatives`/`max_spend`, so this block appears only on returned (billed) leaves. Feature discovery uses `get_adcp_capabilities.governance.creative_features`; `evaluator_id` is an account-scoped preset that emits values over that feature vocabulary, not a discovered feature ID and not a separate evaluator feature catalog. The exemplars form calibrates one `predicted_performance` feature in [0,1]; a pass/warn/fail verdict is a categorical string feature value gated via `feature_requirement.allowed_values` — `eval.features[]` is a feature measurement, never a stored verdict. When the evaluator names an external agent, it must be on the seller's `creative_policy.accepted_verifiers[]` (off-list → `EVALUATOR_AGENT_NOT_ACCEPTED`); the producing agent authenticates its outbound call on the transport, not inside the evaluator payload. Sellers SHOULD populate `eval.calls_used` / `eval.seconds_used` when `agent_url` was used and an `eval_budget` was supplied. -- **variants[] pricing receipt**: Each variant carries a per-leaf receipt (`pricing_option_id`, `vendor_cost`, `currency`, `consumption`) for the work that produced it. +- **variants[] pricing receipt**: Each variant carries a per-leaf receipt (`pricing_option_id`, `vendor_cost`, `currency`, `consumption`) for the work that produced it. Whenever the build reports cost (the aggregate `vendor_cost` is present), every produced leaf MUST carry its own `vendor_cost` + `currency` (schema-enforced) — leaves are the billing source of truth, and an untrafficked leaf is reconciled from this receipt alone. A CPM-deferred leaf reports `vendor_cost: 0`. - **items_total** / **items_returned**: For catalog fan-out, the total eligible catalog items and how many creatives were returned (e.g. "5 of 150"). - **leaves_total** / **leaves_returned**: Leaf-granular counts — total leaves the request would produce vs. how many were actually produced/billed. When `budget_status` is `capped`, `leaves_returned` < `leaves_total` is the shortfall (works even for variant-only fan-out with no catalog). - **vendor_cost** / **currency**: Aggregate cost across all produced variants in the response. @@ -673,7 +675,7 @@ A `mode: "estimate"` request returns the `BuildCreativeEstimate` shape (`oneOf` } ``` -- **estimate.leaves_total** = `items_to_produce` × `variants_per_item` — the number of billable leaves `mode: "execute"` would produce. +- **estimate.leaves_total** = `items_to_produce` × `variants_per_item` (× `conditions_total` when `signal_conditions` was sent) — the number of billable leaves `mode: "execute"` would produce. - **estimate.cost_low / cost_high / cost_expected**: the projected aggregate cost band. The seller derives it from your actual inputs. - **estimate.basis**: `fixed` (per-format flat — `cost_low == cost_high`, exact), `estimated_units` (generative `per_unit`; the band reflects unit-count uncertainty), or `cpm_deferred` (CPM — build-time cost 0, accrues at serve, so the band is 0). - **estimate.per_leaf** (optional): per-leaf breakdown. diff --git a/docs/reference/migration/creative-transformers.mdx b/docs/reference/migration/creative-transformers.mdx index f57b30c7b3..0dc6eec247 100644 --- a/docs/reference/migration/creative-transformers.mdx +++ b/docs/reference/migration/creative-transformers.mdx @@ -69,7 +69,7 @@ These do not throw — which is exactly why they are the reason this guide exist 2. **Per-output pricing loss (multi-publisher template sellers).** See the pricing note above — a template that priced outputs differently silently mis-states pricing after a naïve `Format.pricing_options` → `transformer.pricing_options` lift. Split into per-price-point transformers. -3. **Fan-out / best-of-N spend under-counting (billing pipelines).** With `max_variants` / `max_creatives`, every produced variant is billed, but only a **trafficked** leaf lazily earns a `creative_id`. Discarded best-of-N leaves are billed via the **inline per-leaf `vendor_cost`** on the `build_creative` response *only* — they never earn a `creative_id` and so never appear in `report_usage`. A pipeline that reconciles spend solely from `report_usage` (the 3.0 invariant "every billed unit reconciles via `creative_id`") will **under-count** by every discarded variant. Ingest the inline per-leaf receipts as the authoritative record for untrafficked leaves. +3. **Fan-out / best-of-N spend under-counting (billing pipelines).** With `max_variants` / `max_creatives`, every produced variant is billed, but only a **trafficked** leaf lazily earns a `creative_id`. Discarded best-of-N leaves are billed via the **inline per-leaf `vendor_cost`** on the `build_creative` response *only* — they never earn a `creative_id` and so never appear in `report_usage`. A pipeline that reconciles spend solely from `report_usage` (the 3.0 invariant "every billed unit reconciles via `creative_id`") will **under-count** by every discarded variant. Ingest the inline per-leaf receipts as the authoritative record for untrafficked leaves. Because that receipt is the sole record, the schema enforces it: whenever a build reports cost (the aggregate `vendor_cost` is present), every produced leaf MUST carry its own `vendor_cost` + `currency`, so a paid build cannot return leaves with no machine-readable cost. Note the boundary: the schema enforces the *internal completeness and consistency* of a reported cost — it cannot tell a genuinely free build (or a legitimately-`0` CPM-deferred leaf) from a paid build that omits the aggregate to under-report. "Aggregate absent" is a trust assertion to reconcile (seller invoice vs. `report_usage` + inline receipts, distinguished by the `pricing_option_id` basis), not proof of zero cost — don't mistake schema-validity for billing-honesty. ## SDK behavior and timeline diff --git a/docs/reference/whats-new-in-3-1.mdx b/docs/reference/whats-new-in-3-1.mdx index 6484a165c6..80d94c83a2 100644 --- a/docs/reference/whats-new-in-3-1.mdx +++ b/docs/reference/whats-new-in-3-1.mdx @@ -192,7 +192,7 @@ Live in 3.1, additive over 3.0. Products carry `format_options[]`: a list of `Pr - **Two fan-out axes.** `max_creatives` is the item/catalog axis: N distinct creatives, one per catalog item ("5 of 150" sampling) — distinct from `item_limit`, which caps the items used *within* one creative. `max_variants` (default 1) produces alternatives per creative along a `variant_axis` (`voice` | `theme` | `best_of_n` | `transformer_config` | `custom`, with optional `values[]` and `label`). `keep_mode` (`keep_all` | `keep_one` | `keep_some`) is advisory. Resolutions and quality levels are the **format** axis (`target_format_ids`), not variants. -- **New `BuildCreativeVariantSuccess` response member** (the 5th success shape) carries `creatives[]`, each `{ build_creative_id, catalog_item_ref?, variants[] }`; each variant is `{ build_variant_id, creative_manifest, variant_axis_value?, recommended, rank?, ... }` with a per-leaf pricing receipt (`pricing_option_id` + `vendor_cost` + `currency` + `consumption`). Top-level `items_total` / `items_returned` plus an aggregate `vendor_cost`. The shipped `BuildCreativeSuccess` / `BuildCreativeMultiSuccess` are **unchanged**. +- **New `BuildCreativeVariantSuccess` response member** (`oneOf` member 3 of 6) carries `creatives[]`, each `{ build_creative_id, catalog_item_ref?, variants[] }`; each variant is `{ build_variant_id, creative_manifest, variant_axis_value?, recommended, rank?, ... }` with a per-leaf pricing receipt (`pricing_option_id` + `vendor_cost` + `currency` + `consumption`). When the build reports cost (the aggregate `vendor_cost` is present), every produced leaf carries its own `vendor_cost` + `currency` (schema-enforced). Top-level `items_total` / `items_returned` plus an aggregate `vendor_cost`. The shipped `BuildCreativeSuccess` / `BuildCreativeMultiSuccess` are **unchanged**. - **Best-of-N is variants + `keep_mode` + `recommended`/`rank`.** `build_variant_id` is its own namespace — never reuse `preview_id` (preview renders) or served `variant_id` (delivery). You **pay for all produced variants** (`per_unit` × N); keeping is a client act of trafficking the chosen `build_variant_id`(s). A kept variant lazily earns a `creative_id` (added to the library / first trafficked) which flows to `report_usage`. Per-**format** generation is atomic; per-**item** (catalog fan-out) is non-atomic. diff --git a/static/schemas/source/core/transformer-param.json b/static/schemas/source/core/transformer-param.json index 17b878aaa7..67a9a090c3 100644 --- a/static/schemas/source/core/transformer-param.json +++ b/static/schemas/source/core/transformer-param.json @@ -80,6 +80,31 @@ }, "required": ["field", "type", "value_source"], "additionalProperties": true, + "allOf": [ + { + "if": { "properties": { "value_source": { "const": "inline" } }, "required": ["value_source"] }, + "then": { "required": ["allowed_values"] } + }, + { + "if": { "properties": { "value_source": { "const": "range" } }, "required": ["value_source"] }, + "then": { "required": ["minimum", "maximum"] } + }, + { + "if": { "properties": { "value_source": { "const": "free_text" } }, "required": ["value_source"] }, + "then": { + "properties": { "type": { "const": "string" } }, + "not": { + "anyOf": [ + { "required": ["allowed_values"] }, + { "required": ["minimum"] }, + { "required": ["maximum"] }, + { "required": ["options"] }, + { "required": ["options_cursor"] } + ] + } + } + } + ], "examples": [ { "field": "voice", @@ -109,6 +134,13 @@ "maximum": 2.0, "default": 1.0, "description": "Narration speed multiplier." + }, + { + "field": "negative_prompt", + "type": "string", + "value_source": "free_text", + "max_length": 500, + "description": "Open buyer-authored guidance for what the generative agent should avoid (e.g. \"no text overlays, no humans\")." } ] } diff --git a/static/schemas/source/media-buy/build-creative-response.json b/static/schemas/source/media-buy/build-creative-response.json index 0de215eb2f..ef85501d08 100644 --- a/static/schemas/source/media-buy/build-creative-response.json +++ b/static/schemas/source/media-buy/build-creative-response.json @@ -386,12 +386,12 @@ "vendor_cost": { "type": "number", "minimum": 0, - "description": "Cost incurred for this variant leaf, denominated in currency." + "description": "Cost incurred for this variant leaf, denominated in currency. REQUIRED on every produced leaf whenever the build reports cost (the top-level aggregate `vendor_cost` is present) — leaves are the billing source of truth, and an untrafficked leaf is reconciled from this field alone (it never earns a `creative_id` / `report_usage` entry). A CPM-deferred leaf reports 0 here (a value, not an omission)." }, "currency": { "type": "string", "pattern": "^[A-Z]{3}$", - "description": "ISO 4217 currency code for vendor_cost." + "description": "ISO 4217 currency code for vendor_cost. Co-required with vendor_cost." }, "consumption": { "$ref": "/schemas/core/creative-consumption.json", @@ -399,6 +399,11 @@ } }, "required": ["build_variant_id", "creative_manifest"], + "dependencies": { + "vendor_cost": ["currency"], + "currency": ["vendor_cost"], + "pricing_option_id": ["vendor_cost", "currency"] + }, "additionalProperties": true }, "minItems": 1 @@ -431,7 +436,7 @@ "leaves_total": { "type": "integer", "minimum": 0, - "description": "Total leaves the request would have produced (≈ items_to_produce × variants_per_item). Present when a max_spend cap may have stopped production short. Counts LEAVES, not catalog items — so it expresses a shortfall even for a variant-only fan-out with no catalog." + "description": "Total leaves the request would have produced (≈ items_to_produce × variants_per_item, × conditions_total when signal_conditions was sent). Present when a max_spend cap may have stopped production short. Counts LEAVES, not catalog items — so it expresses a shortfall even for a variant-only fan-out with no catalog." }, "leaves_returned": { "type": "integer", @@ -441,7 +446,7 @@ "vendor_cost": { "type": "number", "minimum": 0, - "description": "Aggregate cost across all variant leaves, denominated in currency. MUST equal the sum of the per-leaf vendor_cost values (leaves are the source of truth)." + "description": "Aggregate cost across all variant leaves, denominated in currency. MUST equal the sum of the per-leaf vendor_cost values (leaves are the source of truth). When present, every produced leaf MUST carry its own vendor_cost + currency (enforced) so the sum invariant is checkable; omit this aggregate only for a genuinely free build." }, "currency": { "type": "string", @@ -488,6 +493,23 @@ "required": [ "creatives" ], + "if": { "required": ["vendor_cost"] }, + "then": { + "required": ["currency"], + "properties": { + "creatives": { + "items": { + "properties": { + "variants": { + "items": { + "required": ["build_variant_id", "creative_manifest", "vendor_cost", "currency"] + } + } + } + } + } + } + }, "additionalProperties": true }, { @@ -508,7 +530,7 @@ "items_to_produce": { "type": "integer", "minimum": 0, "description": "Distinct creatives that would be produced (after max_creatives)." }, "conditions_total": { "type": "integer", "minimum": 1, "x-status": "experimental", "description": "Signal-fan-out conditions axis count (len(signal_conditions)). Present when signal_conditions was sent so leaves_total = items_to_produce × conditions_total × variants_per_item is legible before spend." }, "variants_per_item": { "type": "integer", "minimum": 1, "description": "Alternatives per creative (max_variants)." }, - "leaves_total": { "type": "integer", "minimum": 0, "description": "Total billable leaves = items_to_produce × variants_per_item." }, + "leaves_total": { "type": "integer", "minimum": 0, "description": "Total billable leaves = items_to_produce × variants_per_item (× conditions_total when signal_conditions was sent — see conditions_total)." }, "currency": { "type": "string", "pattern": "^[A-Z]{3}$", "description": "ISO 4217 currency for the cost band." }, "cost_low": { "type": "number", "minimum": 0, "description": "Low end of the projected aggregate vendor_cost." }, "cost_high": { "type": "number", "minimum": 0, "description": "High end. For basis \"fixed\" this equals cost_low (exact)." },