Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/creative-transformer-receipt-and-param-enforcement.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 4 additions & 2 deletions docs/creative/task-reference/build_creative.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/migration/creative-transformers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/whats-new-in-3-1.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
32 changes: 32 additions & 0 deletions static/schemas/source/core/transformer-param.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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\")."
}
]
}
32 changes: 27 additions & 5 deletions static/schemas/source/media-buy/build-creative-response.json
Original file line number Diff line number Diff line change
Expand Up @@ -386,19 +386,24 @@
"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",
"description": "Structured consumption for this variant leaf. Informational; vendor_cost is the billing source of truth."
}
},
"required": ["build_variant_id", "creative_manifest"],
"dependencies": {
"vendor_cost": ["currency"],
"currency": ["vendor_cost"],
"pricing_option_id": ["vendor_cost", "currency"]
},
"additionalProperties": true
},
"minItems": 1
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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
},
{
Expand All @@ -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)." },
Expand Down
Loading