diff --git a/.changeset/library-level-creative-merge.md b/.changeset/library-level-creative-merge.md new file mode 100644 index 00000000000..c1c5cbe4f3c --- /dev/null +++ b/.changeset/library-level-creative-merge.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Add a library-level `merge` to `sync_creatives` `assignment_operations` (adcp#8897). A `merge` without `package_id` declares that a seller-assigned creative is the buyer's creative everywhere in the account. The seller merges every current assignment of it, makes every later assignment under the buyer's `creative_id`, and keeps listing the seller-assigned creative in `list_creatives` with the new `merged_into_creative_id`, which gives buyers a durable record for joining delivery from before the merge. Gated by the new `creative.supports_library_merge` capability, which requires `supports_assignment_merge`; a seller without it rejects a `merge` without `package_id` with `UNSUPPORTED_FEATURE`. `merges[]` reports the account-wide binding as an entry without `package_id`, followed by one entry per moved assignment. `CREATIVE_MERGE_REJECTED` details may omit `package_id` and gain the `not_in_account` and `already_merged` reasons. Package merges are unchanged, and delivery before any merge is still not restated. diff --git a/docs/creative/specification.mdx b/docs/creative/specification.mdx index b94ccad147b..71568ac14f3 100644 --- a/docs/creative/specification.mdx +++ b/docs/creative/specification.mdx @@ -693,6 +693,7 @@ Upload and synchronize creative assets in a library. Implemented by any agent th - Agents MUST reject requests that combine `delete_missing: true` with `creative_ids` — `delete_missing` applies to the entire library, not a filtered subset - Agents that declare `creative.stable_creative_ids` MUST NOT archive a creative that entered the library outside `sync_creatives` under `delete_missing`. A sync of an existing `creative_id` updates that creative under the normal upsert rules, whatever created it. See [Seller-assigned creative IDs](/docs/creative/task-reference/sync_creatives#seller-assigned-creative-ids). - Agents that advertise `creative.supports_assignment_merge` MUST apply a verified `merge` assignment operation by moving the seller-assigned creative's package assignment, with its serving object and approval, to the buyer's `creative_id` without re-review unless the content differs, and MUST report that object's delivery under the buyer's `creative_id` from the merge onward without restating earlier delivery. See [Merging a seller-created copy](/docs/creative/task-reference/sync_creatives#merging-a-seller-created-copy). +- Agents that advertise `creative.supports_library_merge` MUST apply a verified `merge` without `package_id` by merging every current assignment of the seller-assigned creative into the buyer's `creative_id`, making every later assignment of it under that `creative_id`, and listing the seller-assigned creative with `merged_into_creative_id`. Earlier delivery stays under the seller-assigned ID. See [Merging across the account](/docs/creative/task-reference/sync_creatives#merging-across-the-account). - Agents that advertise `creative.localization` MUST reject unsupported localization before mutation and return exact localization readback on every accepted localized creative - Agents that also manage media buys SHOULD support the `assignments` field for bulk creative-to-package mapping - Standalone creative agents that do not manage media buys SHOULD ignore the `assignments` field diff --git a/docs/creative/task-reference/list_creatives.mdx b/docs/creative/task-reference/list_creatives.mdx index 6f2f216fdd6..d5993c62d72 100644 --- a/docs/creative/task-reference/list_creatives.mdx +++ b/docs/creative/task-reference/list_creatives.mdx @@ -256,6 +256,7 @@ not maintain it omits it and the buyer falls back to rereading. |-------|------|-------------| | `creative_id` | string | Unique creative identifier. Assigned by the seller for creatives created outside AdCP. | | `platform_id` | string | The seller's identifier for the creative in its source of truth. Same value `sync_creatives` returns. | +| `merged_into_creative_id` | string | Present on a seller-assigned creative that a [library-level merge](/docs/creative/task-reference/sync_creatives#merging-across-the-account) bound to a buyer creative: the buyer's `creative_id`, which now holds its assignments and delivery. Such a creative has no assignments of its own. | | `revision_id` | string | Current buyer-authored input revision when the current content is revision-bound. Omitted for legacy/unversioned current content. | | `seller_revision` | integer or string | Optional seller-maintained, monotonic, opaque change token, distinct from `revision_id`. Advances on every committed change to the creative from any origin. See [Seller revision](#seller-revision). Added in 3.3. | | `name` | string | Human-readable name | @@ -321,7 +322,7 @@ To manage one of these creatives, a buyer uses its `creative_id` as-is. See [sel `platform_id` is the seller's identifier for the creative in its source of truth, the same value `sync_creatives` returns. It lets operators match a creative to the seller's own UI and reports without keeping the sync response. Buyers address creatives by `creative_id`, never by `platform_id`. -Delivery reporting uses the same seller-assigned `creative_id`. When such a creative delivers in a buyer's package, [`get_media_buy_delivery`](/docs/media-buy/task-reference/get_media_buy_delivery#creatives-the-buyer-didnt-sync) reports it in `by_package[].by_creative[]` under that ID, and [`get_creative_delivery`](/docs/creative/task-reference/get_creative_delivery) does too. To take one over as the buyer's own creative, see [Adopting a creative made outside AdCP](/docs/creative/task-reference/sync_creatives#adopting-a-creative-made-outside-adcp). +Delivery reporting uses the same seller-assigned `creative_id`. When such a creative delivers in a buyer's package, [`get_media_buy_delivery`](/docs/media-buy/task-reference/get_media_buy_delivery#creatives-the-buyer-didnt-sync) reports it in `by_package[].by_creative[]` under that ID, and [`get_creative_delivery`](/docs/creative/task-reference/get_creative_delivery) does too. To take one over as the buyer's own creative, see [Adopting a creative made outside AdCP](/docs/creative/task-reference/sync_creatives#adopting-a-creative-made-outside-adcp). After a library-level merge, the seller-assigned creative stays listed with `merged_into_creative_id` naming the buyer's creative, so the pairing can be read back at any time. ### Assignment indicators diff --git a/docs/creative/task-reference/sync_creatives.mdx b/docs/creative/task-reference/sync_creatives.mdx index 4c4c818ff3f..fa593ee307c 100644 --- a/docs/creative/task-reference/sync_creatives.mdx +++ b/docs/creative/task-reference/sync_creatives.mdx @@ -1307,7 +1307,9 @@ If the seller needs a person to confirm a match, it returns the [`submitted` env 1. **Find it.** [`list_creatives`](/docs/creative/task-reference/list_creatives#creatives-created-outside-adcp) returns the creative under a seller-assigned `creative_id`, with the platform's own identifier in `platform_id`. With `include_assignments: true`, it also lists the packages the creative is assigned to. Confirm that the seller declares `creative.stable_creative_ids` and `creative.supports_assignment_merge`. 2. **Sync the buyer's copy.** Build a creative under the buyer's own `creative_id` from the same assets (media, text, click-through) in the same format, and sync it. A copy whose content differs is rejected at merge time with `content_mismatch`, and one in a different format with `format_mismatch`. -3. **Merge each assignment.** Send one `merge` per package the seller-assigned creative is assigned to. The seller keeps the running serving object, its approval and its delivery, and reports it under the buyer's `creative_id` from then on. +3. **Merge.** If the seller declares `creative.supports_library_merge`, send one `merge` without `package_id` (see [Merging across the account](#merging-across-the-account)). Otherwise send one `merge` per package the seller-assigned creative is assigned to. Either way, the seller keeps the running serving object, its approval and its delivery, and reports it under the buyer's `creative_id` from then on. + +Per package: ```json { @@ -1331,10 +1333,64 @@ If the seller needs a person to confirm a match, it returns the [`submitted` env } ``` -Two limits apply in AdCP 3.3: +Two limits apply: + +- **A package merge covers one package.** Each assignment needs its own `merge`. If the seller-assigned creative is assigned to another package later, for example because the advertiser adds the same creative to a new ad in the platform's tools, that assignment is reported under the seller-assigned ID until the buyer merges it too. Buyers that adopt creatives this way watch for new assignments through `creative.assignment_changed` notifications or by rereading `list_creatives`. A [library-level merge](#merging-across-the-account) removes this limit. +- **History stays split.** Delivery before each merge stays under the seller-assigned ID, with either kind of merge. To report the creative's whole life, the buyer joins the two IDs; see [delivery before and after a merge](/docs/media-buy/task-reference/get_media_buy_delivery#delivery-before-and-after-a-merge). + +### Merging across the account + +On many platforms one creative can run in many ads across many packages. A buyer adopting it wants to say once that the seller's creative is its own, so that every assignment, now and later, reports under the buyer's `creative_id`. A `merge` without `package_id` does that. It is a library-level merge: "`slr_7f3c2a91` is my `acme_spring_carousel`, everywhere in this account." + +{/* Library-level merge is not yet released: these two examples use /schemas/latest/. Update to the major alias after release. */} + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/latest/creative/sync-creatives-request.json", + "account": { "account_id": "acct_acme_2026" }, + "idempotency_key": "4a9e1c7b-2d5f-4f08-b6a3-8e0d2c5f1b97", + "assignment_operations": [{ + "operation": "merge", + "creative_id": "acme_spring_carousel", + "merged_creative_id": "slr_7f3c2a91" + }] +} +``` + +Buyers MUST NOT send a `merge` without `package_id` unless the seller declares [`creative.supports_library_merge`](/docs/protocol/get_adcp_capabilities#creative). A seller that does not declare it rejects one with [`UNSUPPORTED_FEATURE`](/docs/building/verification/compliance-catalog#error-code-unsupported-feature), even if it declares `creative.supports_assignment_merge`. A seller that declares it: + +- MUST verify the pairing as for a package merge, and MUST treat `merged_creative_id` as the same creative as `creative_id` everywhere in the account. +- MUST merge every current assignment of `merged_creative_id`, as a package merge would. Current means on a package that is not canceled, in a media buy that is not in a terminal state. Paused packages are included. Assignments in terminal media buys stay as they were, with their delivery history. +- MUST make every later assignment of that creative under `creative_id`. When the seller's source of truth assigns the same creative to another package, for example because the advertiser adds it to a new ad in the platform's tools, the seller reports that assignment in `list_creatives`, `get_media_buys` and delivery under `creative_id`, never under `merged_creative_id`. A seller that emits `creative.assignment_changed` sends `assigned` for `creative_id`. +- MUST keep listing `merged_creative_id` in `list_creatives` while it exists in the seller's source of truth, with no assignments and with `merged_into_creative_id` set to `creative_id`. The merge doesn't purge it, and it stays out of `delete_missing` as before. +- MUST report delivery under `creative_id` from the merge onward and MUST NOT restate earlier delivery, which stays under `merged_creative_id`. `merged_into_creative_id` is the durable record buyers use to [join the two IDs](/docs/media-buy/task-reference/get_media_buy_delivery#delivery-before-and-after-a-merge). + +**Verification.** The library-level merge is all or nothing. If the seller can't merge any one current assignment, it applies no operation in the request and returns [`CREATIVE_MERGE_REJECTED`](/docs/building/verification/compliance-catalog#error-code-creative-merge-rejected) with one rejection per blocking package, each with its `package_id` and a `reason` (`format_mismatch` or `not_supported`). Reasons about the pairing itself carry no `package_id`: + +| `reason` | Meaning | Who acts | +|---|---|---| +| `content_mismatch` | The seller judges that `merged_creative_id` is a different creative. | Nobody: the seller's creative is not this creative. | +| `not_in_account` | `merged_creative_id` is not a seller-assigned creative in this account. Unknown IDs get the same reason, so the response doesn't reveal whether an ID exists elsewhere. | The buyer: reread `list_creatives` and correct the pairing. | +| `already_merged` | An earlier library-level merge bound `merged_creative_id` to a different `creative_id`. A seller-assigned creative is bound to at most one buyer creative. | The buyer: read `merged_into_creative_id` in `list_creatives`. | + +`not_on_package` doesn't apply to a library-level merge. Confirmation, `dry_run`, and atomicity with the rest of the request work as for a package merge. + +**Response.** `merges[]` gets one entry without `package_id` for the account-wide binding, followed by one entry per assignment the merge moved, ordered by `package_id`, each with its inherited `approval_status`. A seller-assigned creative with no current assignments yields only the binding entry. Repeating a library-level merge of the same pair succeeds with only the binding entry. + +```json +{ + "$schema": "https://adcontextprotocol.org/schemas/latest/creative/sync-creatives-response.json", + "status": "completed", + "creatives": [], + "merges": [ + { "creative_id": "acme_spring_carousel", "merged_creative_id": "slr_7f3c2a91" }, + { "creative_id": "acme_spring_carousel", "package_id": "pkg_spring_retargeting", "merged_creative_id": "slr_7f3c2a91", "approval_status": "approved" }, + { "creative_id": "acme_spring_carousel", "package_id": "pkg_spring_social", "merged_creative_id": "slr_7f3c2a91", "approval_status": "approved" } + ] +} +``` -- **Merge is per package.** Each assignment needs its own `merge`. If the seller-assigned creative is assigned to another package later, for example because the advertiser adds the same creative to a new ad in the platform's tools, that assignment is reported under the seller-assigned ID until the buyer merges it too. Buyers that adopt creatives watch for new assignments through `creative.assignment_changed` notifications or by rereading `list_creatives`. -- **History stays split.** Delivery before each merge stays under the seller-assigned ID. To report the creative's whole life, the buyer joins the two IDs; see [delivery before and after a merge](/docs/media-buy/task-reference/get_media_buy_delivery#delivery-before-and-after-a-merge). +Each moved assignment produces the same `creative.assignment_changed` notifications and account change feed records as a package merge. A library-level merge has no undo. To stop running `creative_id` on a package, `unassign` it or `replace` it as usual. ### Dry run diff --git a/docs/media-buy/task-reference/get_media_buy_delivery.mdx b/docs/media-buy/task-reference/get_media_buy_delivery.mdx index a6110aa951d..b99a1b016db 100644 --- a/docs/media-buy/task-reference/get_media_buy_delivery.mdx +++ b/docs/media-buy/task-reference/get_media_buy_delivery.mdx @@ -925,7 +925,7 @@ To see the creative's whole life, the buyer joins the two IDs itself: - Recompute rates such as `ctr` and `cpm` from those sums rather than averaging the rows' rates. - Don't add `reach` or `frequency` across the two rows. The same person can be in both, and a buyer-side join can't remove the overlap. Read them per ID, or per day for days that fall wholly before or after the merge. -`list_creatives` and [`get_media_buys`](/docs/media-buy/task-reference/get_media_buys) don't show the pairing after the fact, so buyers keep it from the `merges[]` entries of the `sync_creatives` response (`merged_creative_id`, `creative_id`, `package_id`). A seller that emits `creative.assignment_changed` sends `unassigned` and `assigned` with the same `observed_at`, and a seller with the account change feed records two `creative_assignment` changes sharing one `batch_id`. +After a [library-level merge](/docs/creative/task-reference/sync_creatives#merging-across-the-account), `list_creatives` shows the pairing on the seller-assigned creative as `merged_into_creative_id`, and every later assignment reports under the buyer's ID. After a package merge, `list_creatives` and [`get_media_buys`](/docs/media-buy/task-reference/get_media_buys) don't show the pairing after the fact, so buyers keep it from the `merges[]` entries of the `sync_creatives` response (`merged_creative_id`, `creative_id`, `package_id`). A seller that emits `creative.assignment_changed` sends `unassigned` and `assigned` with the same `observed_at`, and a seller with the account change feed records two `creative_assignment` changes sharing one `batch_id`. ## Catalog-item reporting diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx index ec44c6439e8..b0c6e180b1c 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -1567,6 +1567,7 @@ Creative protocol capabilities. Only present if `creative` is in `supported_prot | `supports_assignment_landing_page` | boolean | Added in 3.3; defaults to `false`. When `true`, accepts assignment-scoped `landing_page_url` in create/update media buy and sync assignment requests, serves the override or rejects the assignment, and reports the effective destination in assignment readback. Requires `has_creative_library: true`. When `false` or absent, buyers MUST NOT send the field and the agent MUST reject it with [`UNSUPPORTED_FEATURE`](/docs/building/verification/compliance-catalog#error-code-unsupported-feature). See [assignment landing pages](/docs/creative/task-reference/sync_creatives#assignment-landing-pages). | | `live_updates` | object | Experimental; added in 3.3. Which kinds of change (`click_through`, `trackers`, `asset_content`, `format`) the agent applies in place to a live creative under the same `creative_id`, and how the creative serves while each is decided: `uninterrupted`, `staged` (the prior version serves until the change is approved; `on_rejection` is `keeps_prior` or `stops`), or `paused_for_review`. A floor: listed kinds are accepted on every live creative in the listed mode or a less disruptive one. Optional `max_propagation_seconds` bounds how long a prior version may still serve after a change applies. A declaring agent refuses other changes with [`CREATIVE_LIVE_UPDATE_UNSUPPORTED`](/docs/building/verification/compliance-catalog#error-code-creative-live-update-unsupported) and answers the exact outcome of one change on a [`sync_creatives` `dry_run`](/docs/creative/task-reference/sync_creatives#live-creative-updates). Requires `has_creative_library: true`. See [Live creative updates](/docs/creative/task-reference/sync_creatives#live-creative-updates). | | `supports_assignment_merge` | boolean | When `true`, the agent accepts the `merge` operation in [`sync_creatives`](/docs/creative/task-reference/sync_creatives#merging-a-seller-created-copy) `assignment_operations`, which moves a seller-assigned creative's assignment on a package, with its serving object, approval, and delivery continuity, to the buyer's matching `creative_id`. Requires `has_creative_library: true` and `stable_creative_ids: true`, so the seller-created creative being merged has a stable ID. When `false` or absent, the agent rejects `merge` with `UNSUPPORTED_FEATURE` and buyers MUST NOT send it. | +| `supports_library_merge` | boolean | When `true`, the agent also accepts a [`merge` without `package_id`](/docs/creative/task-reference/sync_creatives#merging-across-the-account): a library-level merge that binds a seller-assigned creative to the buyer's `creative_id` across the account, merges every current assignment of it, and makes every later assignment under the buyer's ID. Requires `supports_assignment_merge: true`. When `false` or absent, the agent rejects a `merge` without `package_id` with `UNSUPPORTED_FEATURE` and buyers MUST NOT send one. | | `representation_resolution` | object | Destination-sales-agent opt-in to accept a complete `creative_representation_set` on [`build_creative`](/docs/creative/task-reference/build_creative), verify its revision digest, and select one compatible `representation_id` against that seller's current product and execution contract using an advertised deterministic strategy. Standalone creative agents cannot advertise seller-bound resolution. Absence means the caller selects before sending a seller-bound manifest; the agent cannot guess. See [creative representation sets](/docs/creative/representation-sets). | | `supported_formats` | object[] | Canonical creative operation catalog. New 3.2 producers MUST emit a stable `capability_id`, a full canonical `format` declaration, and explicit `operations` (`build`, `validate`, `preview`). Exact publisher support carries `{publisher_domain, format_option_id}` inside `format`; generic capabilities declare a satisfiable canonical parameter envelope. For 3.x compatibility, consumers accept entries without `capability_id` and interpret absent `operations` as `build`; such entries can be matched by contract but not selected through a capability-ID route. Replaces [`list_creative_formats`](/docs/creative/task-reference/list_creative_formats) in 3.2. | | `preview` | object | Per-route [`preview_creative`](/docs/creative/task-reference/preview_creative) declaration. `routes[].capability_id` names `supported_formats[]` entries whose `operations` includes `preview`; `rendering_origin` reports `platform_native` or `agent_approximation` per route. This self-description is informational and never grants authority; only a matching publisher placement `preview_provider` delegation does. See [rendering authority](/docs/creative/canonical-formats#rendering-authority-and-fallback-order). | diff --git a/docs/snippets/compliance-error-codes.mdx b/docs/snippets/compliance-error-codes.mdx index b2ae494db13..79cc12ded51 100644 --- a/docs/snippets/compliance-error-codes.mdx +++ b/docs/snippets/compliance-error-codes.mdx @@ -488,7 +488,7 @@ A creative bound to a locale-constrained product format has no materialized vari **Suggested action:** confirm the pairing via list_creatives, or use a replace assignment operation to traffic creative_id as a new serving object -One or more sync_creatives merge assignment operations failed seller verification. The seller evaluates every merge in the request, MUST leave every assignment unchanged (assignment operations are atomic), SHOULD set error.field to the first rejected assignment_operations entry, and SHOULD return details conforming to error-details/creative-merge-rejected.json listing each rejected merge with a reason: content_mismatch (the seller judges the copy is a different creative), format_mismatch (the buyer's creative cannot take over the copy's serving object), not_on_package (merged_creative_id is not a seller-assigned creative assigned to the package in this account, reported uniformly for unknown IDs), or not_supported (the seller cannot merge this pairing on this package). A seller that does not declare creative.supports_assignment_merge returns UNSUPPORTED_FEATURE instead. Recovery: correctable (drop or fix the rejected merges and resubmit; use replace to traffic creative_id as a new serving object). +One or more sync_creatives merge assignment operations failed seller verification. The seller evaluates every merge in the request, MUST leave every assignment unchanged (assignment operations are atomic), SHOULD set error.field to the first rejected assignment_operations entry, and SHOULD return details conforming to error-details/creative-merge-rejected.json listing each rejected merge with a reason: content_mismatch (the seller judges the copy is a different creative), format_mismatch (the buyer's creative cannot take over the copy's serving object), not_on_package (merged_creative_id is not a seller-assigned creative assigned to the package in this account, reported uniformly for unknown IDs), or not_supported (the seller cannot merge this pairing on this package). A library-level merge (no package_id) can also be rejected with not_in_account (merged_creative_id is not a seller-assigned creative in this account, reported uniformly for unknown IDs) or already_merged (merged_creative_id is already bound to a different creative), and names each blocking package_id when one current assignment cannot be merged. A seller that does not declare creative.supports_assignment_merge returns UNSUPPORTED_FEATURE instead, as does one that receives a library-level merge without declaring creative.supports_library_merge. Recovery: correctable (drop or fix the rejected merges and resubmit; use replace to traffic creative_id as a new serving object). diff --git a/static/schemas/source/creative/list-creatives-response.json b/static/schemas/source/creative/list-creatives-response.json index b03b77c48a5..59448ae7f7c 100644 --- a/static/schemas/source/creative/list-creatives-response.json +++ b/static/schemas/source/creative/list-creatives-response.json @@ -70,6 +70,13 @@ "$comment": "x-entity deliberately omitted: seller-opaque identifier in the seller's own namespace; buyers echo creative_id, never platform_id, on AdCP requests.", "x-added-in": "3.3.0" }, + "merged_into_creative_id": { + "type": "string", + "minLength": 1, + "description": "Present on a seller-assigned creative that a library-level sync_creatives merge bound to a buyer creative: the buyer's creative_id, which now holds every assignment of this creative and is reported in delivery from the merge onward. Such a creative has no assignments of its own. Delivery before the merge stays under this creative's creative_id; buyers join the two IDs to report its whole history.", + "x-entity": "creative", + "x-added-in": "4.0.0" + }, "revision_id": { "$ref": "/schemas/core/creative-revision-id.json", "description": "Current buyer-authored input revision for this creative. Present when the current effective content has revision identity and the seller advertises creative.supports_revisions; omitted after an accepted content-bearing legacy update without revision_id. Sellers MUST NOT mint one for a creative that entered the library outside sync_creatives; it appears only after a buyer supplies one through sync_creatives.", diff --git a/static/schemas/source/creative/sync-creatives-request.json b/static/schemas/source/creative/sync-creatives-request.json index 63821c78771..a64312686f0 100644 --- a/static/schemas/source/creative/sync-creatives-request.json +++ b/static/schemas/source/creative/sync-creatives-request.json @@ -354,14 +354,14 @@ }, { "title": "Merge seller copy", - "description": "Declares that merged_creative_id, a seller-assigned creative currently assigned to package_id, is the same creative as creative_id. On success the seller moves that assignment's serving object, approval, weight, and placement routing to (creative_id, package_id) without re-trafficking it, and merged_creative_id no longer has an assignment on the package. Unlike replace, which traffics creative_id as a new object, merge preserves delivery continuity. Requires creative.supports_assignment_merge.", + "description": "Declares that merged_creative_id, a seller-assigned creative currently assigned to package_id, is the same creative as creative_id. On success the seller moves that assignment's serving object, approval, weight, and placement routing to (creative_id, package_id) without re-trafficking it, and merged_creative_id no longer has an assignment on the package. Unlike replace, which traffics creative_id as a new object, merge preserves delivery continuity. Requires creative.supports_assignment_merge. Without package_id, the merge is library-level: it declares the two creatives the same everywhere in the account, merges every current assignment of merged_creative_id, and binds merged_creative_id to creative_id so that every later assignment of it is made under creative_id. A library-level merge requires creative.supports_library_merge.", "properties": { "operation": { "const": "merge" }, "creative_id": { "type": "string", "minLength": 1, "description": "Buyer's creative that takes over the assignment. It need not already be assigned to package_id.", "x-entity": "creative" }, - "package_id": { "type": "string", "minLength": 1, "x-entity": "package" }, - "merged_creative_id": { "type": "string", "minLength": 1, "description": "Seller-created creative (one that entered the library outside sync_creatives, under a seller-assigned creative_id) currently assigned to package_id that the seller verifies is the same creative as creative_id.", "x-entity": "creative" } + "package_id": { "type": "string", "minLength": 1, "description": "Package whose assignment of merged_creative_id is merged. Omit for a library-level merge across the account, which requires creative.supports_library_merge.", "x-entity": "package" }, + "merged_creative_id": { "type": "string", "minLength": 1, "description": "Seller-created creative (one that entered the library outside sync_creatives, under a seller-assigned creative_id) that the seller verifies is the same creative as creative_id. With package_id, it must be currently assigned to package_id; without it, it must be in the account.", "x-entity": "creative" } }, - "required": ["operation", "creative_id", "package_id", "merged_creative_id"], + "required": ["operation", "creative_id", "merged_creative_id"], "additionalProperties": false, "x-added-in": "3.3.0", "x-adcp-validation": { @@ -370,13 +370,20 @@ "required_capability": "creative.supports_assignment_merge", "without_capability": "UNSUPPORTED_FEATURE" }, + "library_merge_capability_gate": { + "applies_when": "package_id_absent", + "required_capability": "creative.supports_library_merge", + "without_capability": "UNSUPPORTED_FEATURE" + }, "on_rejection": { "evaluation": "evaluate_every_merge_in_request; any_rejection_fails_whole_request_and_changes_no_assignment", "error_code": "CREATIVE_MERGE_REJECTED", "details": "error-details/creative-merge-rejected.json rejections[] one entry per rejected merge", "reasons": { "not_on_package": "merged_creative_id_unknown_or_not_seller_assigned_or_not_assigned_to_package_in_account; reported_uniformly", - "not_supported": "seller_cannot_merge_this_pairing_on_this_package", + "not_in_account": "library_merge_only; merged_creative_id_unknown_or_not_seller_assigned_in_account; reported_uniformly", + "already_merged": "library_merge_only; merged_creative_id_already_bound_to_a_different_creative_id", + "not_supported": "seller_cannot_merge_this_pairing_on_this_package; for_library_merge_rejection_names_each_blocking_package_id", "format_mismatch": "creative_id_cannot_take_over_merged_creative_serving_object", "content_mismatch": "seller_judges_not_same_creative" } @@ -386,7 +393,14 @@ "readback": "response_merges_one_entry_per_applied_merge", "approval": "inherit_merged_creative_approval_on_package_and_must_not_re_review_unless_content_differs", "merged_creative": "remove_assignment_on_package_only; not_purged", - "delivery_attribution": "report_under_creative_id_from_merge_time; prior_delivery_stays_under_merged_creative_id_and_must_not_be_restated" + "delivery_attribution": "report_under_creative_id_from_merge_time; prior_delivery_stays_under_merged_creative_id_and_must_not_be_restated", + "library_merge": { + "current_assignments": "merge_every_assignment_of_merged_creative_id_on_a_package_not_canceled_in_a_media_buy_not_terminal; atomic", + "later_assignments": "make_under_creative_id; never_under_merged_creative_id", + "readback": "merges_entry_without_package_id_for_binding_then_one_entry_per_moved_assignment", + "merged_creative": "list_creatives_shows_merged_into_creative_id_and_no_assignments; not_purged", + "repeat_same_pair": "succeeds_with_binding_entry_only" + } } }, "spec": "docs/creative/task-reference/sync_creatives.mdx#merging-a-seller-created-copy" diff --git a/static/schemas/source/creative/sync-creatives-response.json b/static/schemas/source/creative/sync-creatives-response.json index c7a5027be80..f3c9cec2847 100644 --- a/static/schemas/source/creative/sync-creatives-response.json +++ b/static/schemas/source/creative/sync-creatives-response.json @@ -197,7 +197,7 @@ "merges": { "type": "array", "x-added-in": "3.3.0", - "description": "One entry per merge assignment operation the seller applied, in request order (under dry_run, one per merge that would be applied). Present exactly when the request contained merge operations and succeeded. A merge that fails verification fails the whole request with CREATIVE_MERGE_REJECTED, whose details list every rejected merge and its reason; a merge awaiting a person's confirmation is reported on the submitted task's completion artifact, never as a partial result here.", + "description": "One entry per merge assignment operation the seller applied, in request order (under dry_run, one per merge that would be applied). A library-level merge (no package_id in the request) adds one entry without package_id that records the account-wide binding, followed by one entry per assignment it moved, ordered by package_id. Present exactly when the request contained merge operations and succeeded. A merge that fails verification fails the whole request with CREATIVE_MERGE_REJECTED, whose details list every rejected merge and its reason; a merge awaiting a person's confirmation is reported on the submitted task's completion artifact, never as a partial result here.", "items": { "type": "object", "properties": { @@ -210,20 +210,23 @@ "package_id": { "type": "string", "minLength": 1, + "description": "Package whose assignment was merged. Absent only on the entry that records a library-level merge's account-wide binding.", "x-entity": "package" }, "merged_creative_id": { "type": "string", "minLength": 1, - "description": "Seller-assigned creative whose assignment on package_id was merged into creative_id.", + "description": "Seller-assigned creative whose assignment on package_id was merged into creative_id, or, on an entry without package_id, that is now bound to creative_id across the account.", "x-entity": "creative" }, "approval_status": { "$ref": "/schemas/enums/creative-approval-status.json", - "description": "Approval state the merged assignment inherited from merged_creative_id." + "description": "Approval state the merged assignment inherited from merged_creative_id. Present on every entry that has package_id." } }, - "required": ["creative_id", "package_id", "merged_creative_id", "approval_status"], + "required": ["creative_id", "merged_creative_id"], + "if": { "required": ["package_id"] }, + "then": { "required": ["approval_status"] }, "additionalProperties": true } }, diff --git a/static/schemas/source/enums/creative-merge-rejection-reason.json b/static/schemas/source/enums/creative-merge-rejection-reason.json index 3ef16adffb1..f828e75c4ac 100644 --- a/static/schemas/source/enums/creative-merge-rejection-reason.json +++ b/static/schemas/source/enums/creative-merge-rejection-reason.json @@ -8,13 +8,17 @@ "content_mismatch", "format_mismatch", "not_on_package", - "not_supported" + "not_supported", + "not_in_account", + "already_merged" ], "enumDescriptions": { "content_mismatch": "The seller judges that merged_creative_id is not the same creative as creative_id: its content differs. The seller's copy is a different creative; to run creative_id on the package, use replace.", "format_mismatch": "creative_id cannot take over the serving object because its format differs from merged_creative_id's (for example, a different format kind or dimensions). The buyer can sync a matching format of creative_id and retry.", "not_on_package": "merged_creative_id is not a seller-assigned creative currently assigned to package_id in this account. Covers unknown IDs, buyer-assigned creatives, and creatives assigned elsewhere, so the response does not reveal whether an ID exists outside this package.", - "not_supported": "The seller supports merge but cannot merge this pairing on this package (for example, a channel or object type the seller cannot hand over without re-trafficking). Use replace." + "not_supported": "The seller supports merge but cannot merge this pairing on this package (for example, a channel or object type the seller cannot hand over without re-trafficking). Use replace. For a library-level merge, the rejection names the blocking package_id.", + "not_in_account": "Library-level merge only: merged_creative_id is not a seller-assigned creative in this account. Covers unknown IDs and buyer-assigned creatives, so the response does not reveal whether an ID exists in another account.", + "already_merged": "Library-level merge only: an earlier library-level merge already bound merged_creative_id to a different creative_id. A seller-assigned creative is bound to at most one buyer creative." }, "x-added-in": "3.3.0" } diff --git a/static/schemas/source/enums/error-code.json b/static/schemas/source/enums/error-code.json index 4d6a9216651..1204ad435e7 100644 --- a/static/schemas/source/enums/error-code.json +++ b/static/schemas/source/enums/error-code.json @@ -156,7 +156,7 @@ "CREATIVE_LOCALE_NOT_ACCEPTED": "A creative bound to a locale-constrained product format has no materialized variant matching locale_policy.accepted_language_ranges, lacks protocol-declared locale topology, or uses serve_default with a seller-ineligible default variant. Seller ranges use RFC 4647 Basic Filtering and are applied independently for every placement where the assignment may serve, before buyer Lookup, locale_fallbacks, or default selection. Distinct from CREATIVE_REJECTED because this is a mechanically discoverable assignment-eligibility mismatch, not subjective content review. error.field SHOULD point to the offending creative or assignment; error.details SHOULD include format_option_id when present, accepted_language_ranges, available_variant_locales, and placement identity when applicable. Recovery: correctable (supply or assign a matching materialized locale variant, narrow placement scope, choose a compatible format option, or change an ineligible serve_default).", "CREATIVE_VALUE_NOT_ALLOWED": "A submitted text-asset value is not in the format's declared `allowed_values` list. Distinct from `CREATIVE_REJECTED` (generic creative-review failure) by being a closed-set constraint violation that the buyer can resolve mechanically without policy interpretation — the seller has published the complete list of acceptable values on the format, and any value outside that list is rejected by definition. The seller MUST set `error.field` to the offending asset's path within the manifest (e.g., `creatives[0].creative_manifest.assets[0].value` or the field name declared by the format) and SHOULD include the format's `allowed_values` array in `error.details.allowed_values` so the buyer agent can re-prompt its LLM with constrained sampling. Recovery: correctable (select a value from `allowed_values` and resubmit).", "CREATIVE_REVISION_CONTENT_MISMATCH": "A sync_creatives item reused a revision_id for different canonical revision content under the same creative_id. Revision identity is scoped to the parent creative and immutable after first acceptance. Sellers MUST evaluate the buyer input before transcoding or normalization, MUST leave the prior creative state unchanged, and SHOULD return details conforming to error-details/creative-revision-content-mismatch.json. Distinct from IDEMPOTENCY_CONFLICT: idempotency_key protects one request replay window, while revision identity protects creative content across requests and retention. Recovery: correctable (resend the exact content previously bound to this revision_id, or mint a new revision_id for the changed content).", - "CREATIVE_MERGE_REJECTED": "One or more sync_creatives merge assignment operations failed seller verification. The seller evaluates every merge in the request, MUST leave every assignment unchanged (assignment operations are atomic), SHOULD set error.field to the first rejected assignment_operations entry, and SHOULD return details conforming to error-details/creative-merge-rejected.json listing each rejected merge with a reason: content_mismatch (the seller judges the copy is a different creative), format_mismatch (the buyer's creative cannot take over the copy's serving object), not_on_package (merged_creative_id is not a seller-assigned creative assigned to the package in this account, reported uniformly for unknown IDs), or not_supported (the seller cannot merge this pairing on this package). A seller that does not declare creative.supports_assignment_merge returns UNSUPPORTED_FEATURE instead. Recovery: correctable (drop or fix the rejected merges and resubmit; use replace to traffic creative_id as a new serving object).", + "CREATIVE_MERGE_REJECTED": "One or more sync_creatives merge assignment operations failed seller verification. The seller evaluates every merge in the request, MUST leave every assignment unchanged (assignment operations are atomic), SHOULD set error.field to the first rejected assignment_operations entry, and SHOULD return details conforming to error-details/creative-merge-rejected.json listing each rejected merge with a reason: content_mismatch (the seller judges the copy is a different creative), format_mismatch (the buyer's creative cannot take over the copy's serving object), not_on_package (merged_creative_id is not a seller-assigned creative assigned to the package in this account, reported uniformly for unknown IDs), or not_supported (the seller cannot merge this pairing on this package). A library-level merge (no package_id) can also be rejected with not_in_account (merged_creative_id is not a seller-assigned creative in this account, reported uniformly for unknown IDs) or already_merged (merged_creative_id is already bound to a different creative), and names each blocking package_id when one current assignment cannot be merged. A seller that does not declare creative.supports_assignment_merge returns UNSUPPORTED_FEATURE instead, as does one that receives a library-level merge without declaring creative.supports_library_merge. Recovery: correctable (drop or fix the rejected merges and resubmit; use replace to traffic creative_id as a new serving object).", "CREATIVE_LIVE_UPDATE_UNSUPPORTED": "A sync_creatives item changed a live creative in a way the seller does not apply in place under the same creative_id. A creative is live when its status is approved and it has at least one assignment on a package that is not canceled, whose flight has not ended, and whose media buy is not in a terminal state; a paused package still counts. Returned only by sellers that declare creative.live_updates (others keep returning INVALID_STATE). The seller MUST fail only that item, MUST leave the creative, its review state, and its serving unchanged, SHOULD set error.field to the item, and SHOULD return details conforming to error-details/creative-live-update-unsupported.json naming the refused change kinds and the live packages. A dry_run returns the same refusal. Recovery: correctable (sync the change as a new creative_id, then swap it in on each live package with an assignment_operations replace).", "UNSUPPORTED_FEATURE": "A requested feature or field is not supported by this seller. When rejecting a `refine_proposals` request that uses a typed dimension omitted from an explicit `proposal_refinement.supported_dimensions` declaration, the error details SHOULD follow `error-details/unsupported-refinement-dimension.json` — `unsupported_dimension` names the offending dimension and `supported_dimensions` echoes the seller's declaration so the buyer can remove or translate the field without another capability round trip. When rejecting a targeting dimension that is unsupported for the product or seller, or has been removed, the error details SHOULD follow `error-details/unsupported-targeting-dimension.json` (`unsupported_dimension`, optional `supported_dimensions`, `deprecated`, `replacement_dimension`) and `error.field` SHOULD point at the targeting path. Recovery: correctable (check get_adcp_capabilities (or the product's overlay_support for a rejected targeting dimension), remove unsupported fields, and use details.replacement_dimension when present).", "UNPRICEABLE_OUTPUT": "A creative transformer build targets an output capability that no pricing option covers — no transformer.pricing_options entry has a matching applies_to_output_capability_ids and none is unscoped. The build is rejected rather than billed at a guessed rate. Recovery: correctable.", diff --git a/static/schemas/source/error-details/creative-merge-rejected.json b/static/schemas/source/error-details/creative-merge-rejected.json index 7c2b1db0054..d01ad37c713 100644 --- a/static/schemas/source/error-details/creative-merge-rejected.json +++ b/static/schemas/source/error-details/creative-merge-rejected.json @@ -24,6 +24,7 @@ "package_id": { "type": "string", "minLength": 1, + "description": "Package of the rejected merge. For a library-level merge, present only when one current assignment blocks the merge: one entry names each blocking package.", "x-entity": "package" }, "merged_creative_id": { @@ -35,7 +36,7 @@ "$ref": "/schemas/enums/creative-merge-rejection-reason.json" } }, - "required": ["operation_index", "creative_id", "package_id", "merged_creative_id", "reason"], + "required": ["operation_index", "creative_id", "merged_creative_id", "reason"], "additionalProperties": false } } diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index 367ea9b68fe..971d3a1088c 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -3256,6 +3256,12 @@ "default": false, "x-added-in": "3.3.0" }, + "supports_library_merge": { + "type": "boolean", + "description": "When true, this agent also accepts a merge operation without package_id in sync_creatives assignment_operations: a library-level merge that binds a seller-assigned creative to the buyer's creative across the account. The agent merges every current assignment of the seller-assigned creative, and makes every later assignment of it under the buyer's creative_id. Requires supports_assignment_merge: true. When false or absent, the agent rejects a merge without package_id with UNSUPPORTED_FEATURE and buyers MUST NOT send one.", + "default": false, + "x-added-in": "4.0.0" + }, "live_updates": { "type": "object", "x-status": "experimental", @@ -3617,6 +3623,20 @@ "required": ["has_creative_library", "stable_creative_ids"] } }, + { + "if": { + "properties": { + "supports_library_merge": { "const": true } + }, + "required": ["supports_library_merge"] + }, + "then": { + "properties": { + "supports_assignment_merge": { "type": "boolean", "const": true } + }, + "required": ["supports_assignment_merge"] + } + }, { "if": { "properties": { diff --git a/tests/composed-schema-validation.test.cjs b/tests/composed-schema-validation.test.cjs index 46c9fc13d5e..d4254752de0 100644 --- a/tests/composed-schema-validation.test.cjs +++ b/tests/composed-schema-validation.test.cjs @@ -4711,6 +4711,100 @@ async function runTests() { }, 'CREATIVE_MERGE_REJECTED reasons are a closed set' ); + await testSchemaValidation( + '/schemas/creative/sync-creatives-request.json', + { + idempotency_key: 'assignment-operations-merge-0004', + account: { account_id: 'account-clean-1' }, + assignment_operations: [ + { operation: 'merge', creative_id: 'creative-buyer', merged_creative_id: 'slr_1234' } + ] + }, + 'sync_creatives accepts a library-level merge without package_id' + ); + await testSchemaValidation( + '/schemas/creative/sync-creatives-response.json', + { + status: 'completed', + creatives: [], + merges: [ + { creative_id: 'creative-buyer', merged_creative_id: 'slr_1234' }, + { creative_id: 'creative-buyer', package_id: 'package-1', merged_creative_id: 'slr_1234', approval_status: 'approved' }, + { creative_id: 'creative-buyer', package_id: 'package-2', merged_creative_id: 'slr_1234', approval_status: 'pending_review' } + ] + }, + 'a library-level merge reports the account binding, then each moved assignment' + ); + await testSchemaRejection( + '/schemas/creative/sync-creatives-response.json', + { + status: 'completed', + creatives: [], + merges: [ + { creative_id: 'creative-buyer', package_id: 'package-1', merged_creative_id: 'slr_1234' } + ] + }, + 'a merges[] entry for a package carries the inherited approval_status' + ); + await testSchemaValidation( + '/schemas/error-details/creative-merge-rejected.json', + { + rejections: [ + { operation_index: 0, creative_id: 'creative-buyer', merged_creative_id: 'slr_1234', reason: 'not_in_account' }, + { operation_index: 1, creative_id: 'creative-buyer-2', merged_creative_id: 'slr_5678', reason: 'already_merged' }, + { operation_index: 2, creative_id: 'creative-buyer-3', package_id: 'package-9', merged_creative_id: 'slr_9999', reason: 'not_supported' } + ] + }, + 'CREATIVE_MERGE_REJECTED details cover library-level merges, naming a blocking package when there is one' + ); + await testSchemaValidation( + '/schemas/protocol/get-adcp-capabilities-response.json', + { + status: 'completed', + adcp: { major_versions: [3], idempotency: { supported: false } }, + supported_protocols: ['creative'], + creative: { + has_creative_library: true, + stable_creative_ids: true, + supports_assignment_merge: true, + supports_library_merge: true + } + }, + 'creative.supports_library_merge is declared alongside supports_assignment_merge' + ); + await testSchemaRejection( + '/schemas/protocol/get-adcp-capabilities-response.json', + { + status: 'completed', + adcp: { major_versions: [3], idempotency: { supported: false } }, + supported_protocols: ['creative'], + creative: { + has_creative_library: true, + stable_creative_ids: true, + supports_library_merge: true + } + }, + 'creative.supports_library_merge requires supports_assignment_merge' + ); + await testSchemaValidation( + '/schemas/creative/list-creatives-response.json', + { + status: 'completed', + query_summary: { total_matching: 1, returned: 1 }, + pagination: { has_more: false }, + creatives: [{ + creative_id: 'slr_1234', + platform_id: '8814402271', + merged_into_creative_id: 'creative-buyer', + name: 'Spring carousel', + format_id: { agent_url: 'https://creatives.adcontextprotocol.org', id: 'display_300x250' }, + status: 'approved', + created_date: '2026-04-01T00:00:00Z', + updated_date: '2026-04-20T00:00:00Z' + }] + }, + 'list_creatives shows the buyer creative a library-level merge bound a seller creative to' + ); await testSchemaRejection( '/schemas/creative/sync-creatives-request.json', {