diff --git a/.changeset/installment-creative-assignment.md b/.changeset/installment-creative-assignment.md new file mode 100644 index 0000000000..ba37b34192 --- /dev/null +++ b/.changeset/installment-creative-assignment.md @@ -0,0 +1,13 @@ +--- +"adcontextprotocol": minor +--- + +Add experimental `installment_refs` to the `assign` and `replace` operations of `sync_creatives` `assignment_operations` (`media_buy.installment_creative_assignment`, #8968). A buyer can now give each installment of a package its own creative, such as the awareness brief on a creator's first post and the offer on the second. Each entry is a `core/canonical-installment-ref.json`, with unique items and at most 500, matching a product's installments. Absent means every installment, as before. + +- **Rotation.** Within one installment, weight, rotation and placement routing work unchanged over the assignments that apply to it. Placement routing and installment scope intersect. An installment no assignment applies to gets no creative scoped to another installment. +- **Seller rules.** Each ref MUST name an installment in the `installments` of the purchased product. Any other ref is rejected with `VALIDATION_ERROR`. An operation that changes an installment past its `deadlines.change_cutoff` is rejected with `CREATIVE_DEADLINE_EXCEEDED`, naming the installment, and is never accepted silently. No new error codes. +- **Scope changes.** A supplied list replaces the assignment's scope, and omission on an `assign` upsert keeps it. `replace` transfers the replaced assignment's scope unless it supplies one, and `merge` moves it. `unassign` and `merge` reject `installment_refs` by name. +- **Readback.** The same field appears on `get_media_buys` `packages[].creative_approvals[]` and `list_creatives` `assignments.assigned_packages[]`, and is omitted when the assignment applies to every installment. +- **Capability.** Buyers send the field only to sellers that list `media_buy.installment_creative_assignment` in `experimental_features`, because a seller that ignored it would run the creative on every installment (versioning rule 4). A seller that does not support it rejects it with `UNSUPPORTED_FEATURE`. + +The field is not on `core/creative-assignment.json`, the deprecated `create_media_buy` and `update_media_buy` package rows, or the deprecated `sync_creatives` `assignments[]` row. 3.3 adds nothing to deprecated tasks (#8501). Date windows (`valid_from` / `valid_until`) for packages without installments are a follow-up. diff --git a/docs/creative/task-reference/list_creatives.mdx b/docs/creative/task-reference/list_creatives.mdx index a0c4fa5208..a351a03648 100644 --- a/docs/creative/task-reference/list_creatives.mdx +++ b/docs/creative/task-reference/list_creatives.mdx @@ -327,6 +327,8 @@ In AdCP 3.3, sellers advertising `creative.supports_assignment_trackers` MUST re In AdCP 3.3, `assignments.assigned_packages[].landing_page_url` reports the effective serving destination for that creative on that package. Sellers advertising `creative.supports_assignment_landing_page` return the assignment override, or the creative's own destination when unique. Macro-bearing URLs retain the effective template. Omit the scalar when no destination exists or an unoverridden creative has multiple destinations; read those from the creative's `assets` rather than treating an arbitrary destination as applying to every click. The creative's own `status` and assignment `approval_status` remain separate. See [assignment landing pages](/docs/creative/task-reference/sync_creatives#assignment-landing-pages). +In AdCP 3.3, sellers that list the experimental `media_buy.installment_creative_assignment` return `assignments.assigned_packages[].installment_refs` on every assignment limited to some installments of the package, and omit it when the assignment applies to every installment. See [assigning to specific installments](/docs/creative/task-reference/sync_creatives#assigning-to-specific-installments-experimental). + Creative-library sellers that advertise `list_creatives` in `media_buy.relationship_notifications.projection_tasks` attach evaluated indicator state to `assignments.assigned_packages[]` and include `media_buy_id` plus `approval_status` on every row, including unknown rows. These fields mirror [`get_media_buys`](/docs/media-buy/task-reference/get_media_buys) for the same relationship. A mixed publisher/placement outcome uses `approval_status: "partially_approved"` plus complete `approval_scopes[]`; the assignment webhook remains a compact invalidation and buyers reread these scopes. Each evaluated row includes `indicator_types_evaluated`, `indicators_as_of`, and `indicators[]`; omitted types remain unknown. An assignment row MAY carry `approval_status_as_of`: when the seller last refreshed `approval_status`, `rejection_reason`, and `approval_scopes` for that relationship from its source of truth. It follows the same rules as `creative_approvals[].approval_status_as_of` on `get_media_buys`. When both reads carry it for the same relationship, the values agree. See [creative state freshness](/docs/media-buy/specification#creative-state-freshness). diff --git a/docs/creative/task-reference/sync_creatives.mdx b/docs/creative/task-reference/sync_creatives.mdx index 69d5738ba4..378caee860 100644 --- a/docs/creative/task-reference/sync_creatives.mdx +++ b/docs/creative/task-reference/sync_creatives.mdx @@ -155,7 +155,7 @@ A product can declare a separate `creative_policy.review_sla` for time to an app | `creatives` | Creative[] | Conditional | Creative assets to upload/update (max 100); at least one of `creatives`, `assignments`, or `assignment_operations` is required. | | `creative_ids` | string[] | No | Optional filter to limit sync scope to specific creative IDs. Only these creatives are affected, others remain untouched. Useful for partial updates and error recovery. | | `assignments` | array | No | Deprecated additive `{creative_id, package_id}` shorthand. Optional `weight` and `placement_ids` per assignment. It does not carry `landing_page_url` or `trackers`; send those on an `assign` operation. | -| `assignment_operations` | array | Conditional | Ordered `assign`, `unassign`, or `replace` operations for existing creative IDs, without requiring creative uploads. Only `assign` accepts capability-gated `landing_page_url` and `trackers`; `replace` transfers the replaced assignment's settings, including those, to the replacement ([Replacing a creative on a package](#replacing-a-creative-on-a-package)). The request is atomic and requires strict validation. | +| `assignment_operations` | array | Conditional | Ordered `assign`, `unassign`, or `replace` operations for existing creative IDs, without requiring creative uploads. Only `assign` accepts capability-gated `landing_page_url` and `trackers`; `replace` transfers the replaced assignment's settings, including those, to the replacement ([Replacing a creative on a package](#replacing-a-creative-on-a-package)). `assign` and `replace` also accept experimental `installment_refs` ([Assigning to specific installments](#assigning-to-specific-installments-experimental)). The request is atomic and requires strict validation. | | `dry_run` | boolean | No | When true, rehearse this exact sync operation without applying it (default: false). Use this for seller trafficking acceptance checks; use [`validate_input`](https://adcontextprotocol.org/schemas/v3/creative/validate-input-request.json) for manifest-only preflight against canonical/product format targets. | | `validation_mode` | string | No | Validation strictness: `"strict"` (default) or `"lenient"` | | `delete_missing` | boolean | No | When true, creatives not in this sync are archived (default: false). Cannot be combined with `creative_ids`. Cannot delete creatives assigned to active, non-paused packages. Sellers that declare `creative.stable_creative_ids` never archive [seller-created creatives](#seller-assigned-creative-ids); others may. | @@ -259,9 +259,10 @@ The replacement takes over the replaced assignment's authored settings, so a swa - `weight` and placement routing (`placement_ids` and `placement_refs`); - rotation settings: `rotation_mode`, `group_id`, and `sequence_position`; -- assignment [`trackers`](#assignment-trackers) and the [`landing_page_url`](#assignment-landing-pages) override. +- assignment [`trackers`](#assignment-trackers) and the [`landing_page_url`](#assignment-landing-pages) override; +- the experimental installment scope, [`installment_refs`](#assigning-to-specific-installments-experimental). -A `weight` supplied on the operation replaces the transferred weight. A `placement_ids` supplied on the operation replaces the transferred placement routing entirely: neither the replaced assignment's `placement_ids` nor its `placement_refs` transfer. To change the transferred trackers or landing page in the same request, follow the `replace` with an `assign` for the replacement creative; operations apply in order and the request is atomic. +A `weight` supplied on the operation replaces the transferred weight. So does a supplied `installment_refs` for the transferred installment scope. A `placement_ids` supplied on the operation replaces the transferred placement routing entirely: neither the replaced assignment's `placement_ids` nor its `placement_refs` transfer. To change the transferred trackers or landing page in the same request, follow the `replace` with an `assign` for the replacement creative; operations apply in order and the request is atomic. The seller MUST validate every transferred setting against the replacement, including tracker-slot capacity and events for its format, whether the landing-page override can apply to its clicks, and any destination review, and MUST reject the atomic request with the existing assignment errors rather than drop or partially apply a setting. Readback in [`list_creatives`](/docs/creative/task-reference/list_creatives) and [`get_media_buys`](/docs/media-buy/task-reference/get_media_buys) `creative_approvals[]` shows the transferred settings on the replacement's row. @@ -281,6 +282,53 @@ The seller MUST validate every transferred setting against the replacement, incl } ``` +### Assigning to specific installments (experimental) + +In AdCP 3.3, optional `installment_refs` on an `assign` or `replace` operation limits the assignment to some [installments](/docs/media-buy/product-discovery/collections-and-installments#the-installment-object) of the package: one post of a creator package, one episode's host read, one issue of a print run. Without it the assignment applies to every installment, as before. Each entry is a [canonical installment reference](https://adcontextprotocol.org/schemas/latest/core/canonical-installment-ref.json), a `collection_ref` and an `installment_id`. + +Buyers MUST NOT send `installment_refs` unless the seller lists `media_buy.installment_creative_assignment` in `experimental_features` on [`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities). A seller that ignored the field would run the creative on every installment, so it waits on the capability ([versioning rule 4](/docs/reference/versioning#what-can-change-in-a-release)). A seller that does not support it MUST reject it with [`UNSUPPORTED_FEATURE`](/docs/building/verification/compliance-catalog#error-code-unsupported-feature). Send it only on `assignment_operations`: the deprecated `assignments[]` row and the `creative_assignments` rows of the deprecated `create_media_buy` and `update_media_buy` don't carry it. + +- **Which installments.** Each ref MUST name an installment the package bought: one in the `installments` of the product its purchase references. The seller rejects any other ref with [`VALIDATION_ERROR`](/docs/building/verification/compliance-catalog#error-code-validation-error), with `error.field` at that ref, such as `assignment_operations[1].installment_refs[0]`. A run-of-collection product names no installments, so it has none to scope to. +- **Rotation and routing.** Within one installment, weight, rotation and placement routing work as they do today, over the assignments that apply to that installment. Placement routing and installment scope intersect: a creative with both serves only in those placements of those installments. An installment that no assignment applies to has no creative from this package, and the seller MUST NOT serve it a creative scoped to another installment. +- **Change cutoff.** An operation changes an installment when it adds or removes a creative there, or changes the weight or routing of one that applies there. An assignment without `installment_refs` applies to every installment, so changing it changes them all, and a `replace` changes every installment either creative applies to. When an operation changes an installment whose [`deadlines.change_cutoff`](/docs/media-buy/product-discovery/collections-and-installments#installment-deadlines) has passed, the seller MUST reject the request with [`CREATIVE_DEADLINE_EXCEEDED`](/docs/building/verification/compliance-catalog#error-code-creative-deadline-exceeded) and name the installment, with `error.field` at the ref when the operation names it, otherwise at the operation. It MUST NOT accept the change silently. The request is atomic, so it doesn't apply the change to the other installments alone either. The package's `creative_deadline` applies as before. +- **Changing the scope.** A supplied `installment_refs` replaces the assignment's installment scope, and omitting it on an `assign` upsert keeps the scope. To return an assignment to every installment, use the atomic `unassign`/`assign` [reset recipe](#assignment-landing-pages) without the field. A `replace` carries the replaced assignment's scope over unless it supplies its own. `unassign` and `merge` reject `installment_refs`: an `unassign` removes the creative from every installment it applied to, so to drop one installment, `assign` the remaining ones. +- **Readback.** Sellers that list the feature return `installment_refs` on every assignment that has a scope, in [`get_media_buys`](/docs/media-buy/task-reference/get_media_buys) `packages[].creative_approvals[]` and [`list_creatives`](/docs/creative/task-reference/list_creatives) `assignments.assigned_packages[]`. They omit it when the assignment applies to every installment. + +This request gives each post of a two-post creator package its own creative: the awareness brief for the first post and the offer, with its discount code, for the second. It uses the unreleased experimental `installment_refs`; the installment creative assignment tests validate it against the source schema. + +```json +{ + "account": { "account_id": "acct_acme" }, + "idempotency_key": "juniper-vale-posts-0001", + "assignment_operations": [ + { + "operation": "assign", + "creative_id": "juniper_awareness_brief", + "package_id": "pkg_juniper_vale", + "installment_refs": [ + { + "collection_ref": { "publisher_domain": "creatorloop.example", "collection_id": "creatorloop_channel_juniper_vale" }, + "installment_id": "juniper_vale_slot_1" + } + ] + }, + { + "operation": "assign", + "creative_id": "juniper_offer_brief", + "package_id": "pkg_juniper_vale", + "installment_refs": [ + { + "collection_ref": { "publisher_domain": "creatorloop.example", "collection_id": "creatorloop_channel_juniper_vale" }, + "installment_id": "juniper_vale_slot_2" + } + ] + } + ] +} +``` + +To change the creative for later installments once an earlier one is past its cutoff, narrow the current assignment to the installments past it and assign the new creative to the rest. A `replace` of an assignment without `installment_refs` would change the earlier installments too, and the seller would refuse it. + ### Creative object | Field | Type | Required | Description | diff --git a/docs/media-buy/product-discovery/collections-and-installments.mdx b/docs/media-buy/product-discovery/collections-and-installments.mdx index c5902ab02f..caffb7bf78 100644 --- a/docs/media-buy/product-discovery/collections-and-installments.mdx +++ b/docs/media-buy/product-discovery/collections-and-installments.mdx @@ -804,6 +804,14 @@ For each product named, the **selected set** is that product's installments with A committed proposal can read back which installments its hold covers, in `holds[]` (experimental, `media_buy.installment_holds`); see [Installment holds](/docs/media-buy/task-reference/refine_proposals#installment-holds-experimental). `holds[]` is readback about the hold finalize placed, not contract, and is not covered by `terms_digest`. +### Creatives per installment (experimental) + +A package that buys several installments can give each one its own creative. A creator package with two posts is the usual case: the first post carries the awareness brief, and the second, weeks later, carries the call to action and a discount code. The same goes for a host read that changes by episode, or a print ad that changes by issue. + +For a creator deal, the creative the buyer assigns is often the brief: talking points, claims to avoid and the required disclosure, which the creator turns into a draft. Sync each brief as a library creative, then assign it to its post with `installment_refs` on a [`sync_creatives`](/docs/creative/task-reference/sync_creatives) `assign` operation. That post's `change_cutoff` then applies to changing its brief, and its `material_deadlines` say when the seller needs the talking points, script or draft. + +Each ref names one of the installments the package bought, the `installments` of the purchased product: the selected set, when the buyer [chose installments](#choosing-installments-experimental). An assignment without `installment_refs` applies to every installment, as before. A change that reaches an installment past its `change_cutoff` is refused, never accepted silently. Sellers that support this list `media_buy.installment_creative_assignment` in `experimental_features`, and buyers send the field only to them. See [Assigning to specific installments](/docs/creative/task-reference/sync_creatives#assigning-to-specific-installments-experimental) for the full rules. + ## get_products response structure Products reference collections via `collections` selectors — the same pattern as `publisher_properties`. Each selector has a `publisher_domain` and `collection_ids` array pointing to collections declared in that publisher's `adagents.json`. When the request sets `include_collections: true`, the response can also carry the full collection objects, described [below](#collections-in-responses). diff --git a/docs/media-buy/task-reference/get_media_buys.mdx b/docs/media-buy/task-reference/get_media_buys.mdx index 959addf2d0..7390d4c73e 100644 --- a/docs/media-buy/task-reference/get_media_buys.mdx +++ b/docs/media-buy/task-reference/get_media_buys.mdx @@ -259,6 +259,7 @@ been deleted. `ext.streamhaus.ad_set_id` here is a seller-defined extension: | `creative_id` | Creative identifier | | `trackers` | Added in 3.3. Complete assignment-scoped measurement tracker list, preserving events, URL templates, order, and duplicate occurrences. Supporting sellers MUST return it, using `[]` when none exist. Excludes the creative's own trackers in `assets`; both sets fire. See [assignment trackers](/docs/creative/task-reference/sync_creatives#assignment-trackers). | | `landing_page_url` | Added in 3.3. Effective serving destination for this creative on this package, including an assignment override. Supporting sellers return the URL (or macro-bearing template) when unique; omit when no destination exists or an unoverridden creative has multiple destinations, whose URLs remain in its creative assets. See [assignment landing pages](/docs/creative/task-reference/sync_creatives#assignment-landing-pages). | +| `installment_refs` | Added in 3.3, experimental (`media_buy.installment_creative_assignment`). The installments this assignment is limited to. Sellers that list the feature return it on every assignment with an installment scope and omit it when the assignment applies to every installment. See [assigning to specific installments](/docs/creative/task-reference/sync_creatives#assigning-to-specific-installments-experimental). | | `approval_status` | `pending_review`, `approved`, `partially_approved`, or `rejected` | | `rejection_reason` | Explanation of rejection (when `approval_status` is `rejected`) | | `approval_scopes` | Complete publisher/placement outcomes when `approval_status` is `partially_approved` | diff --git a/docs/reference/experimental-status.mdx b/docs/reference/experimental-status.mdx index dd99c5e044..886f562efe 100644 --- a/docs/reference/experimental-status.mdx +++ b/docs/reference/experimental-status.mdx @@ -146,6 +146,7 @@ Schemas marked with `x-status: experimental` are the authoritative source. The c | `media_buy.collection_catalog` | [`list_collections`](/docs/media-buy/task-reference/list_collections) (schemas: `media-buy/list-collections-request.json`, `media-buy/list-collections-response.json`), its `collections_version` / `if_collections_version` tokens, and the `collection_card.updated`, `collection_card.removed` and `collection_catalog.bulk_change` webhook event types (branches of `core/wholesale-feed-event.json`, delivered in `core/wholesale-feed-webhook.json` and listed in `wholesale_feed_webhooks.event_types`) ([#8183](https://github.com/adcontextprotocol/adcp/issues/8183)) | Standalone catalog read and push channel for collection cards, with one adopter behind the card surface so far (Availnet, a creator marketplace) and no emitting agent yet. Requires `media_buy.collection_cards`, because the response carries card fields; a seller can serve cards in-band without it. Webhooks are optional within the feature and declared per type. The filter set, the reuse of the wholesale feed envelope with `collections_version` in `wholesale_feed_version`, and the bulk threshold for follower sweeps may change. Not served to callers below 3.3. Storyboards and a `seed_collection` controller scenario wait for training-agent support ([#8181](https://github.com/adcontextprotocol/adcp/issues/8181)). | | `media_buy.installment_holds` | The committed proposal's `holds` array (`core/canonical-proposal.json`): `product_id`, `installment_ref`, and, under a rankable policy, `hold_rank` for each installment the hold covers, described in [Installment holds](/docs/media-buy/task-reference/refine_proposals#installment-holds-experimental) ([#8086](https://github.com/adcontextprotocol/adcp/issues/8086)) | One named adopter so far: Availnet, a creator marketplace, where the held unit is a creator's specific upcoming video. Readback only: entries describe one all-or-none hold and share the proposal's `expires_at`, so there are no partial holds. Whether entries need their own `expires_at`, which only makes sense if partial holds are ever allowed, needs field experience. Requires `media_buy.hold_policy`; omitted for callers below 3.3. | | `media_buy.installment_refinement` | `installment_changes` on [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals#choose-installments-experimental) (`core/installment-change-set.json`, `core/installment-change.json`), `unsatisfied_installment_changes` on its results, and the `installment_changes` value of `media_buy.proposal_refinement.supported_dimensions` ([#8967](https://github.com/adcontextprotocol/adcp/issues/8967)) | One adopter so far: Availnet, a creator marketplace, through Apostra. The buyer chooses installments during negotiation, and the seller answers with a product whose `installments[]` is the chosen set, often one it derives for the selection; nothing new enters `commercial_terms`. 4.0 binds the chosen installments in the signed terms instead ([Installment selection](/docs/reference/migration/v4-compatibility#installment_selection_required_in_4_0)), so this surface may be replaced then. Not the withdrawn `media_buy.installment_selection`, which named the signed field. | +| `media_buy.installment_creative_assignment` | `installment_refs` on the `assign` and `replace` operations of [`sync_creatives`](/docs/creative/task-reference/sync_creatives) `assignment_operations`, and its readback on [`get_media_buys`](/docs/media-buy/task-reference/get_media_buys) `packages[].creative_approvals[]` and [`list_creatives`](/docs/creative/task-reference/list_creatives) `assignments.assigned_packages[]`, described in [Assigning to specific installments](/docs/creative/task-reference/sync_creatives#assigning-to-specific-installments-experimental) ([#8968](https://github.com/adcontextprotocol/adcp/issues/8968)) | New in 3.3 so a creator package's posts, or a podcast's episodes, can each carry their own creative. No seller implements it yet. It needs a capability under [versioning rule 4](/docs/reference/versioning#what-can-change-in-a-release): a seller that ignored the field would run the creative on every installment. A change past an installment's `change_cutoff` is refused, because the request is atomic and its response has no per-operation outcome to report a change that missed an installment. Date windows (`valid_from` / `valid_until`) for packages without installments are a follow-up. | | `identity.trust_json` | The `trust/v1/trust.json` and `trust/v1/trust-acknowledgements.json` schemas, described in [trust.json (experimental)](/docs/reference/trust-json) ([RFC #7809](https://github.com/adcontextprotocol/adcp/issues/7809), [#7819](https://github.com/adcontextprotocol/adcp/pull/7819)). In 3.3 its reader is the experimental Web Bot Auth signing profile, which derives a trust record from an agent's origin ([Web Bot Auth identities](/docs/building/by-layer/L1/security#web-bot-auth-identities)); grants are read at `sync_accounts` and on the freshness schedule ([Grants in trust records](/docs/building/by-layer/L2/accounts-and-agents#grants-in-trust-records-experimental), [DR-0025](https://github.com/adcontextprotocol/adcp/blob/main/governance/decisions/DR-0025-demand-side-grants-in-trust-json.md)). No one is required to publish `/.well-known/trust.json`, and publishing one is not implementing a task, so publishers declare nothing in `experimental_features`. | The document shape for agents, grants, and acknowledgements is published ahead of the behavior that would use it, so implementers can review it. Agents are identified by origin, one per origin, and the record carries no keys ([`specs/agent-identity-3-3.md`](https://github.com/adcontextprotocol/adcp/blob/main/specs/agent-identity-3-3.md)). The document name and its `/.well-known` path may change before graduation: the JournalList `trust.txt` naming outreach is still open. The record's location is derived from the agent's origin, so no capability pointer is planned. Resolution and no-downgrade rules are in the Web Bot Auth identities section of the security page. | | `media_buy.collection_cards` | The `images`, `sample_content` and `url` fields on `core/collection.json`, the `brand_ref` field on `core/talent.json`, the `sample_content` role on `core/product-card-reference-asset.json` (#8092), and the `followers` field on `core/collection-distribution.json` entries, a `core/audience-size.json` count (#8098); the top-level `collections` array (`core/response-collection.json`) on the `list_products`, `request_proposals` and `refine_proposals` responses, and the `include_collections` request flag that opts into it (#8137). The 3.3 betas also carried both on the deprecated `get_products`; they are withdrawn from it (#8501). See [collection cards](/docs/media-buy/product-discovery/collections-and-installments#collection-cards-experimental). | Display and identity slice of the creator-card RFC, with a single adopter so far (Availnet, a creator marketplace) and no field-testing across parties yet. The array bounds, the use of `published-post` references for samples, the `brand_ref`-over-`brand_url` precedence, the opt-in flag, and the closed list of fields (`images`, `sample_content`, `url`, `description`, `season`, `cadence`, `distribution[].followers`) on which a response copy wins over `adagents.json` may change. `adagents.json` publishers cannot declare `experimental_features`, so consumers treat the collection fields as experimental wherever they appear. Sellers that emit the `sample_content` role or response `collections`, or accept `include_collections`, declare the id. Whether a follower count belongs on the distribution entry, and how sellers report platform-rounded counts, may change. Audience composition (`audience_evidence`) and average views (forecast `views`) reuse existing surfaces and are not part of this surface. | | `brand.collection_links` | The `collections` field on the [`get_brand_identity`](/docs/brand-protocol/tasks/get_brand_identity) response and its `collections` value in the request `fields` and response `available_fields` lists, plus `publisher_domain` on `brand.json` brand-level collection links (#8137) | Brand-side half of the creator-card work: lets a buyer go from a talent's `brand_ref` to the person's channels and confirm the link from both sides. One adopter so far (Availnet, a creator marketplace). The link shape, the public visibility level, and whether `seller_agent_url` stays on the link may change. `brand.json` publishers cannot declare `experimental_features`, so consumers treat `publisher_domain` there as experimental. Brand agents that return `collections` declare the id. | diff --git a/package.json b/package.json index 4e11f7aa6b..6d92cac5a7 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "verify:schema-discovery": "node scripts/verify-schema-discovery.mjs", "typecheck": "tsc --project server/tsconfig.json --noEmit && npm run typecheck:fixed-trace-rollout-tests", "typecheck:fixed-trace-rollout-tests": "node scripts/typecheck-fixed-trace-rollout-tests.mjs", - "test:schemas": "node tests/schema-validation.test.cjs && node --test tests/account-ref.test.cjs tests/canonical-account-ref.test.cjs tests/image-bleed.test.cjs tests/sync-plans-reserved-names.test.cjs tests/seller-offers-contract.test.cjs tests/service-commitments.test.cjs tests/sync-platform-id.test.cjs tests/assignment-landing-page.test.cjs tests/assignment-trackers.test.cjs tests/assignment-replace.test.cjs tests/outcome-target.test.cjs tests/trusted-match-offer-creative-data.test.cjs tests/accessibility-violation-details.test.cjs tests/portfolio-routing-scope.test.cjs tests/catalog-item-availability-updates.test.cjs tests/catalog-ingestion-capabilities.test.cjs tests/sdk-implementation-metadata.test.cjs tests/seller-optimized-package-controls.test.cjs tests/compact-product-lifecycle-storyboards.test.cjs tests/timezone-resolution-storyboards.test.cjs tests/terminal-package-retention-storyboard.test.cjs tests/total-budget-redistribution-currency.test.cjs tests/dooh-allocation.test.cjs tests/x-added-in-3-3-sweep.test.cjs tests/url-pattern-loosening.test.cjs tests/performance-standard-metric-id.test.cjs tests/identity-absence-coherence.test.cjs tests/match-keys.test.cjs tests/execution-requirements.test.cjs tests/execution-readiness.test.cjs tests/schema-deprecation-metadata.test.cjs tests/products-only-brief-compatibility.test.cjs tests/async-identity-convergence.test.cjs tests/creative-rotation.test.cjs tests/creative-start-policy.test.cjs tests/canonical-forecast-point-parity.test.cjs tests/creative-revisions.test.cjs tests/creative-live-updates.test.cjs tests/creative-delivery-contracts.test.cjs tests/tracker-execution-contracts.test.cjs tests/tracker-execution-package-integration.test.cjs tests/metric-identity-coherence.test.cjs tests/print-metrics.test.cjs tests/installment-lifecycle-reporting.test.cjs tests/rights-channel-restrictions.test.cjs tests/daypart-time-of-day.test.cjs tests/audience-size-range.test.cjs tests/media-buy-lineage.test.cjs tests/delivery-projection.test.cjs tests/viewable-rate-optimization-goal.test.cjs tests/sort-contract-delivery-reporting.test.cjs tests/time-based-views-contract.test.cjs tests/metric-qualifier-parity.test.cjs tests/collection-mechanism.test.cjs tests/requested-metrics-contract.test.cjs tests/auto-breakdown-negotiation-contract.test.cjs tests/format-delivery-reporting-contract.test.cjs tests/inventory-delivery-reporting-contract.test.cjs tests/property-delivery-conformance.test.cjs tests/lint-schema-enum-drift.test.cjs tests/lint-schema-required-declared.test.cjs tests/format-asset-type-parity.test.cjs tests/canonical-manifest-constructibility.test.cjs tests/synthetic-depiction.test.cjs tests/creative-rendering-authority.test.cjs tests/buyer-reason.test.cjs tests/mutation-outcome.test.cjs tests/error-recovery-vectors.test.cjs tests/reporting-status-contract.test.cjs tests/reporting-reconciliation-fixture.test.cjs tests/reporting-core-fixture.test.cjs tests/reporting-native-version-ref-bounds.test.cjs tests/trust-json-v1.test.cjs tests/refine-proposals-expected-confirmation-at.test.cjs tests/hold-policy.test.cjs tests/hold-policy-vectors.test.cjs tests/canonical-installments.test.cjs tests/withdrawn-to-4-0.test.cjs tests/installment-refinement.test.cjs tests/get-products-deprecated-withdrawals.test.cjs tests/deprecated-task-withdrawals.test.cjs tests/collection-catalog.test.cjs tests/collection-copies-vectors.test.cjs tests/account-change-record.test.cjs tests/notification-config-payload-view.test.cjs tests/account-requests.test.cjs tests/reporting-subscriptions.test.cjs tests/reporting-subscription-probe.test.cjs tests/reporting-subscription-coverage-vectors.test.cjs tests/reporting-subscription-coverage-properties.test.cjs tests/product-roster-coverage-vectors.test.cjs && npm run test:premium-display-formats && npm run test:geo-region-targeting", + "test:schemas": "node tests/schema-validation.test.cjs && node --test tests/account-ref.test.cjs tests/canonical-account-ref.test.cjs tests/image-bleed.test.cjs tests/sync-plans-reserved-names.test.cjs tests/seller-offers-contract.test.cjs tests/service-commitments.test.cjs tests/sync-platform-id.test.cjs tests/assignment-landing-page.test.cjs tests/assignment-trackers.test.cjs tests/assignment-replace.test.cjs tests/outcome-target.test.cjs tests/trusted-match-offer-creative-data.test.cjs tests/accessibility-violation-details.test.cjs tests/portfolio-routing-scope.test.cjs tests/catalog-item-availability-updates.test.cjs tests/catalog-ingestion-capabilities.test.cjs tests/sdk-implementation-metadata.test.cjs tests/seller-optimized-package-controls.test.cjs tests/compact-product-lifecycle-storyboards.test.cjs tests/timezone-resolution-storyboards.test.cjs tests/terminal-package-retention-storyboard.test.cjs tests/total-budget-redistribution-currency.test.cjs tests/dooh-allocation.test.cjs tests/x-added-in-3-3-sweep.test.cjs tests/url-pattern-loosening.test.cjs tests/performance-standard-metric-id.test.cjs tests/identity-absence-coherence.test.cjs tests/match-keys.test.cjs tests/execution-requirements.test.cjs tests/execution-readiness.test.cjs tests/schema-deprecation-metadata.test.cjs tests/products-only-brief-compatibility.test.cjs tests/async-identity-convergence.test.cjs tests/creative-rotation.test.cjs tests/creative-start-policy.test.cjs tests/canonical-forecast-point-parity.test.cjs tests/creative-revisions.test.cjs tests/creative-live-updates.test.cjs tests/creative-delivery-contracts.test.cjs tests/tracker-execution-contracts.test.cjs tests/tracker-execution-package-integration.test.cjs tests/metric-identity-coherence.test.cjs tests/print-metrics.test.cjs tests/installment-lifecycle-reporting.test.cjs tests/rights-channel-restrictions.test.cjs tests/daypart-time-of-day.test.cjs tests/audience-size-range.test.cjs tests/media-buy-lineage.test.cjs tests/delivery-projection.test.cjs tests/viewable-rate-optimization-goal.test.cjs tests/sort-contract-delivery-reporting.test.cjs tests/time-based-views-contract.test.cjs tests/metric-qualifier-parity.test.cjs tests/collection-mechanism.test.cjs tests/requested-metrics-contract.test.cjs tests/auto-breakdown-negotiation-contract.test.cjs tests/format-delivery-reporting-contract.test.cjs tests/inventory-delivery-reporting-contract.test.cjs tests/property-delivery-conformance.test.cjs tests/lint-schema-enum-drift.test.cjs tests/lint-schema-required-declared.test.cjs tests/format-asset-type-parity.test.cjs tests/canonical-manifest-constructibility.test.cjs tests/synthetic-depiction.test.cjs tests/creative-rendering-authority.test.cjs tests/buyer-reason.test.cjs tests/mutation-outcome.test.cjs tests/error-recovery-vectors.test.cjs tests/reporting-status-contract.test.cjs tests/reporting-reconciliation-fixture.test.cjs tests/reporting-core-fixture.test.cjs tests/reporting-native-version-ref-bounds.test.cjs tests/trust-json-v1.test.cjs tests/refine-proposals-expected-confirmation-at.test.cjs tests/hold-policy.test.cjs tests/hold-policy-vectors.test.cjs tests/canonical-installments.test.cjs tests/withdrawn-to-4-0.test.cjs tests/installment-refinement.test.cjs tests/installment-creative-assignment.test.cjs tests/get-products-deprecated-withdrawals.test.cjs tests/deprecated-task-withdrawals.test.cjs tests/collection-catalog.test.cjs tests/collection-copies-vectors.test.cjs tests/account-change-record.test.cjs tests/notification-config-payload-view.test.cjs tests/account-requests.test.cjs tests/reporting-subscriptions.test.cjs tests/reporting-subscription-probe.test.cjs tests/reporting-subscription-coverage-vectors.test.cjs tests/reporting-subscription-coverage-properties.test.cjs tests/product-roster-coverage-vectors.test.cjs && npm run test:premium-display-formats && npm run test:geo-region-targeting", "test:performance-feedback": "node --test --test-force-exit --test-timeout=30000 tests/performance-feedback-contract.test.cjs", "test:dist-schema-version-ids": "node --test --test-force-exit --test-timeout=30000 tests/dist-schema-version-ids.test.cjs", "test:examples": "node tests/example-validation-simple.test.cjs && npm run test:tmp-context-merge", diff --git a/static/schemas/source/creative/list-creatives-response.json b/static/schemas/source/creative/list-creatives-response.json index e48cb8d095..d5216b0001 100644 --- a/static/schemas/source/creative/list-creatives-response.json +++ b/static/schemas/source/creative/list-creatives-response.json @@ -315,6 +315,16 @@ "description": "The landing page actually served for this creative on this package: the assignment override when present, otherwise the creative's own click-through destination when unique. Sellers supporting creative.supports_assignment_landing_page MUST return that effective destination when it is unique. Omit when no destination exists or an unoverridden creative has multiple destinations; those remain available in the creative's assets. MUST NOT select one destination arbitrarily. Macro-bearing readback retains the effective URL template under the same rules as the creative's own click-through.", "x-added-in": "3.3.0" }, + "installment_refs": { + "x-status": "experimental", + "x-added-in": "3.3.0", + "type": "array", + "items": { "$ref": "/schemas/core/canonical-installment-ref.json" }, + "minItems": 1, + "maxItems": 500, + "uniqueItems": true, + "description": "Experimental (media_buy.installment_creative_assignment). The installments this assignment is limited to, mirroring get_media_buys for the same relationship. Sellers that list the feature MUST return it on every assignment with an installment scope and omit it when the assignment applies to every installment of the package, so absent means every installment." + }, "approval_status": { "$ref": "/schemas/enums/creative-approval-status.json", "description": "Aggregate approval state for this creative in this package assignment. This mirrors the same relationship in get_media_buys. Sellers advertising list_creatives as an indicator projection task MUST include it; partially_approved requires approval_scopes." diff --git a/static/schemas/source/creative/sync-creatives-request.json b/static/schemas/source/creative/sync-creatives-request.json index b5d3175ed7..5059aa0ca6 100644 --- a/static/schemas/source/creative/sync-creatives-request.json +++ b/static/schemas/source/creative/sync-creatives-request.json @@ -250,7 +250,7 @@ "minItems": 1, "maxItems": 500, "items": { - "description": "`trackers` and `landing_page_url` belong only to assign: unassign, replace and merge reject them, and merge also rejects `weight`, which a later assign changes.", + "description": "`trackers` and `landing_page_url` belong only to assign: unassign, replace and merge reject them, and merge also rejects `weight`, which a later assign changes. `installment_refs` belongs to assign and replace: unassign and merge reject it.", "type": "object", "discriminator": { "propertyName": "operation" @@ -280,6 +280,16 @@ "items": { "type": "string", "minLength": 1 }, "minItems": 1, "uniqueItems": true + }, + "installment_refs": { + "x-status": "experimental", + "x-added-in": "3.3.0", + "type": "array", + "items": { "$ref": "/schemas/core/canonical-installment-ref.json" }, + "minItems": 1, + "maxItems": 500, + "uniqueItems": true, + "description": "Experimental (media_buy.installment_creative_assignment). Limits this assignment to these installments of the package, such as one post of a creator package. Absent: the assignment applies to every installment, as before. Within one installment, weight, rotation and placement routing work unchanged over the assignments that apply to it; with placement routing, the creative serves only in the routed placements of these installments (the intersection). An installment that no assignment applies to has none: the seller MUST NOT serve it another installment's creative. Each ref MUST name an installment the package bought, one in the installments of the product its purchase references; the seller MUST reject any other with VALIDATION_ERROR, with error.field at that ref. The installment's deadlines.change_cutoff applies: the seller MUST reject an operation that changes an installment past its cutoff with CREATIVE_DEADLINE_EXCEEDED, naming the installment, and MUST NOT accept that change silently or apply it to the other installments alone. A supplied list replaces the assignment's installment scope; omission on an additive upsert preserves it. To return to every installment, atomically unassign then assign without this field. Buyers MUST NOT send this field unless the seller lists media_buy.installment_creative_assignment in experimental_features; a seller that does not support it MUST reject it with UNSUPPORTED_FEATURE." } }, "required": ["operation", "creative_id", "package_id"], @@ -308,11 +318,11 @@ }, "required": ["operation", "creative_id", "package_id"], "additionalProperties": true, - "not": { "anyOf": [{ "required": ["trackers"] }, { "required": ["landing_page_url"] }] } + "not": { "anyOf": [{ "required": ["trackers"] }, { "required": ["landing_page_url"] }, { "required": ["installment_refs"] }] } }, { "title": "Replace assignment", - "description": "Atomically swap the currently assigned replaces_creative_id for creative_id on package_id. The replacement takes over the replaced assignment's authored settings: weight, placement routing (placement_ids and placement_refs), rotation_mode, group_id, sequence_position, assignment trackers, and the landing_page_url override. Supplied weight replaces the transferred weight. Supplied placement_ids replaces the transferred placement routing entirely: neither the replaced assignment's placement_ids nor its placement_refs transfer. The seller MUST validate every transferred setting against the replacement (format tracker slots, landing-page applicability, destination review) and MUST reject the atomic request rather than drop or partially apply one. To change transferred trackers or the landing page in the same request, follow this operation with an assign for creative_id.", + "description": "Atomically swap the currently assigned replaces_creative_id for creative_id on package_id. The replacement takes over the replaced assignment's authored settings: weight, placement routing (placement_ids and placement_refs), rotation_mode, group_id, sequence_position, assignment trackers, the landing_page_url override, and its installment scope (installment_refs, experimental). Supplied weight replaces the transferred weight. Supplied placement_ids replaces the transferred placement routing entirely: neither the replaced assignment's placement_ids nor its placement_refs transfer. The seller MUST validate every transferred setting against the replacement (format tracker slots, landing-page applicability, destination review) and MUST reject the atomic request rather than drop or partially apply one. To change transferred trackers or the landing page in the same request, follow this operation with an assign for creative_id.", "properties": { "operation": { "const": "replace" }, "creative_id": { "type": "string", "minLength": 1, "description": "Replacement creative ID.", "x-entity": "creative" }, @@ -325,6 +335,16 @@ "items": { "type": "string", "minLength": 1 }, "minItems": 1, "uniqueItems": true + }, + "installment_refs": { + "x-status": "experimental", + "x-added-in": "3.3.0", + "type": "array", + "items": { "$ref": "/schemas/core/canonical-installment-ref.json" }, + "minItems": 1, + "maxItems": 500, + "uniqueItems": true, + "description": "Experimental (media_buy.installment_creative_assignment). Installment scope for the replacement. Supplied: replaces the transferred scope. Omitted: the replaced assignment's scope transfers, so a replaced assignment without installment_refs leaves the replacement on every installment. The installment, cutoff and capability rules of assign's installment_refs apply. The cutoff covers every installment that either creative applies to before or after the swap." } }, "required": ["operation", "creative_id", "replaces_creative_id", "package_id"], @@ -347,7 +367,7 @@ }, { "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, placement routing, and installment scope 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.", "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" }, @@ -356,7 +376,7 @@ }, "required": ["operation", "creative_id", "package_id", "merged_creative_id"], "additionalProperties": true, - "not": { "anyOf": [{ "required": ["trackers"] }, { "required": ["landing_page_url"] }, { "required": ["weight"] }] }, + "not": { "anyOf": [{ "required": ["trackers"] }, { "required": ["landing_page_url"] }, { "required": ["weight"] }, { "required": ["installment_refs"] }] }, "x-added-in": "3.3.0", "x-adcp-validation": { "verifier_constraints": { diff --git a/static/schemas/source/media-buy/get-media-buys-response.json b/static/schemas/source/media-buy/get-media-buys-response.json index 45a56b7b3b..b92b6229c7 100644 --- a/static/schemas/source/media-buy/get-media-buys-response.json +++ b/static/schemas/source/media-buy/get-media-buys-response.json @@ -540,6 +540,16 @@ "description": "The landing page actually served for this creative on this package: the assignment override when present, otherwise the creative's own click-through destination when unique. Sellers supporting creative.supports_assignment_landing_page MUST return that effective destination when it is unique. Omit when no destination exists or an unoverridden creative has multiple destinations; those remain available in the creative's assets. MUST NOT select one destination arbitrarily. Macro-bearing readback retains the effective URL template under the same rules as the creative's own click-through.", "x-added-in": "3.3.0" }, + "installment_refs": { + "x-status": "experimental", + "x-added-in": "3.3.0", + "type": "array", + "items": { "$ref": "/schemas/core/canonical-installment-ref.json" }, + "minItems": 1, + "maxItems": 500, + "uniqueItems": true, + "description": "Experimental (media_buy.installment_creative_assignment). The installments this assignment is limited to, as accepted from a sync_creatives assign or replace. Sellers that list the feature MUST return it on every assignment with an installment scope and omit it when the assignment applies to every installment of the package, so absent means every installment." + }, "approval_status": { "$ref": "/schemas/enums/creative-approval-status.json" }, diff --git a/tests/installment-creative-assignment.test.cjs b/tests/installment-creative-assignment.test.cjs new file mode 100644 index 0000000000..ca483b086a --- /dev/null +++ b/tests/installment-creative-assignment.test.cjs @@ -0,0 +1,210 @@ +/** + * Contract test for assigning a creative to specific installments of a package + * (experimental media_buy.installment_creative_assignment, adcp#8968). + * + * `installment_refs` rides on the sync_creatives `assign` and `replace` operations and + * reads back on get_media_buys and list_creatives. Absent means every installment. It is + * gated on the experimental feature id because an older seller would ignore it and run the + * creative on every installment (versioning rule 4), and it stays off the deprecated + * create_media_buy / update_media_buy assignment rows and the deprecated assignments[] row. + */ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const Ajv = require('ajv'); +const addFormats = require('ajv-formats'); + +const ROOT = path.join(__dirname, '..'); +const SCHEMA_DIR = path.join(ROOT, 'static/schemas/source'); +const readSchema = relativePath => JSON.parse(fs.readFileSync(path.join(SCHEMA_DIR, relativePath), 'utf8')); +const ajv = new Ajv({ + allErrors: true, strict: false, discriminator: true, + loadSchema: async uri => readSchema(uri.replace('/schemas/', '')) +}); +addFormats(ajv); +const validators = new Map(); +function compile(relativePath) { + if (!validators.has(relativePath)) { + validators.set(relativePath, ajv.getSchema(`/schemas/${relativePath}`) || ajv.compileAsync(readSchema(relativePath))); + } + return validators.get(relativePath); +} + +const FEATURE = 'media_buy.installment_creative_assignment'; +const collection_ref = { publisher_domain: 'creatorloop.example', collection_id: 'creatorloop_channel_juniper_vale' }; +const slot = n => ({ collection_ref, installment_id: `juniper_vale_slot_${n}` }); +const requestBase = { account: { account_id: 'acct_acme' }, idempotency_key: 'installment-assignment-test-0001' }; +const assignment = { creative_id: 'juniper_offer_brief', package_id: 'pkg_juniper_vale' }; +const request = operation => ({ ...requestBase, assignment_operations: [operation] }); +const assign = { operation: 'assign', ...assignment }; +const replace = { operation: 'replace', ...assignment, replaces_creative_id: 'juniper_awareness_brief' }; + +const listCreatives = row => ({ + status: 'completed', query_summary: { total_matching: 1, returned: 1 }, pagination: { has_more: false }, + creatives: [{ creative_id: 'juniper_offer_brief', name: 'Offer brief', format_kind: 'image', status: 'approved', + created_date: '2026-11-13T12:00:00Z', updated_date: '2026-11-13T12:00:00Z', + assignments: { assignment_count: 1, returned_assignment_count: 1, assignments_truncated: false, + assigned_packages: [{ package_id: 'pkg_juniper_vale', assigned_date: '2026-11-13T12:00:00Z', ...row }] } }] +}); +const getMediaBuys = row => ({ + status: 'completed', media_buys: [{ media_buy_id: 'mb_juniper', status: 'active', currency: 'USD', + total_budget: 1000, confirmed_at: '2026-11-13T12:00:00Z', revision: 1, + packages: [{ package_id: 'pkg_juniper_vale', + creative_approvals: [{ creative_id: 'juniper_offer_brief', approval_status: 'approved', ...row }] }] }] +}); +const readbacks = [ + ['creative/list-creatives-response.json', listCreatives], + ['media-buy/get-media-buys-response.json', getMediaBuys] +]; + +const nodes = () => { + const ops = readSchema('creative/sync-creatives-request.json').properties.assignment_operations.items.oneOf; + const variant = name => ops.find(v => v.properties.operation.const === name); + return { + variant, + fields: [ + ['assign', variant('assign').properties.installment_refs], + ['replace', variant('replace').properties.installment_refs], + ['get_media_buys', readSchema('media-buy/get-media-buys-response.json').properties.media_buys.items + .properties.packages.items.properties.creative_approvals.items.properties.installment_refs], + ['list_creatives', readSchema('creative/list-creatives-response.json').properties.creatives.items + .properties.assignments.properties.assigned_packages.items.properties.installment_refs] + ] + }; +}; + +test('assign and replace accept installment_refs, and accept their absence', async () => { + const validate = await compile('creative/sync-creatives-request.json'); + for (const operation of [assign, replace]) { + assert.equal(validate(request(operation)), true, JSON.stringify(validate.errors)); + assert.equal(validate(request({ ...operation, installment_refs: [slot(2)] })), true, JSON.stringify(validate.errors)); + assert.equal(validate(request({ ...operation, installment_refs: [slot(1), slot(2)], placement_ids: ['post'] })), true, + JSON.stringify(validate.errors)); + } +}); + +test('the sync_creatives creator example validates against the source schema', async () => { + // The example carries no $schema: sync-creatives-request.json is released in v3, + // which lacks installment_refs, so this test is its validator. + const doc = fs.readFileSync(path.join(ROOT, 'docs/creative/task-reference/sync_creatives.mdx'), 'utf8'); + const section = doc.slice(doc.indexOf('### Assigning to specific installments (experimental)')); + const block = section.match(/```json\n([\s\S]*?)\n```/); + assert.ok(block, 'the section carries a JSON example'); + const example = JSON.parse(block[1]); + assert.deepEqual(example.assignment_operations.map(op => op.installment_refs.map(ref => ref.installment_id)), + [['juniper_vale_slot_1'], ['juniper_vale_slot_2']]); + const validate = await compile('creative/sync-creatives-request.json'); + assert.equal(validate(example), true, JSON.stringify(validate.errors)); +}); + +test('installment_refs are unique, nonempty, bounded canonical refs', async () => { + const validate = await compile('creative/sync-creatives-request.json'); + const many = Array.from({ length: 500 }, (_, i) => slot(i + 1)); + for (const operation of [assign, replace]) { + assert.equal(validate(request({ ...operation, installment_refs: many })), true, JSON.stringify(validate.errors)); + for (const installment_refs of [ + [slot(1), slot(1)], + [], + [...many, slot(501)], + null, + [{ installment_id: 'juniper_vale_slot_1' }], + [{ collection_ref: { collection_id: collection_ref.collection_id }, installment_id: 'juniper_vale_slot_1' }], + [{ ...slot(1), name: 'Post one' }], + [{ collection_ref: { ...collection_ref, kind: 'creator' }, installment_id: 'juniper_vale_slot_1' }], + [{ ...slot(1), installment_id: '' }], + ['juniper_vale_slot_1'] + ]) { + assert.equal(validate(request({ ...operation, installment_refs })), false, JSON.stringify(installment_refs)?.slice(0, 120)); + } + } + for (const [schema, wrap] of readbacks) { + const readback = await compile(schema); + assert.equal(readback(wrap({ installment_refs: [slot(1), slot(1)] })), false, `${schema} duplicate`); + assert.equal(readback(wrap({ installment_refs: [] })), false, `${schema} empty`); + } +}); + +test('unassign and merge reject installment_refs by name', async () => { + const validate = await compile('creative/sync-creatives-request.json'); + const unassign = { operation: 'unassign', ...assignment }; + const merge = { operation: 'merge', ...assignment, merged_creative_id: 'seller_copy_1' }; + for (const operation of [unassign, merge]) { + assert.equal(validate(request(operation)), true, JSON.stringify(validate.errors)); + assert.equal(validate(request({ ...operation, installment_refs: [slot(1)] })), false, operation.operation); + } +}); + +test('get_media_buys and list_creatives read the scope back; absent means every installment', async () => { + for (const [schema, wrap] of readbacks) { + const validate = await compile(schema); + assert.equal(validate(wrap({})), true, `${schema}: ${JSON.stringify(validate.errors)}`); + assert.equal(validate(wrap({ installment_refs: [slot(1), slot(2)] })), true, `${schema}: ${JSON.stringify(validate.errors)}`); + } +}); + +test('every installment_refs node is experimental, new in 3.3, and matches the write shape', () => { + const { fields } = nodes(); + const [, write] = fields[0]; + for (const [name, node] of fields) { + assert.ok(node, `${name} declares installment_refs`); + assert.equal(node['x-status'], 'experimental', name); + assert.equal(node['x-added-in'], '3.3.0', name); + assert.equal(node.items.$ref, '/schemas/core/canonical-installment-ref.json', name); + for (const key of ['type', 'minItems', 'maxItems', 'uniqueItems']) assert.equal(node[key], write[key], `${name} ${key}`); + assert.match(node.description, new RegExp(FEATURE.replace('.', '\\.')), `${name} names the feature id`); + } + // The product's installments cap at 500, so a scope can always name all of them. + assert.equal(write.maxItems, readSchema('core/canonical-product.json').properties.installments.maxItems); +}); + +test('the capability gate: buyers send it only to sellers listing the feature, which reject it otherwise', async () => { + const { fields } = nodes(); + for (const [name, node] of fields.slice(0, 2)) { + assert.match(node.description, /Buyers MUST NOT send this field unless the seller lists media_buy\.installment_creative_assignment in experimental_features|installment, cutoff and capability rules of assign/, name); + } + const [, write] = fields[0]; + assert.match(write.description, /MUST reject it with UNSUPPORTED_FEATURE/); + // The seller rules use existing codes only. + assert.match(write.description, /VALIDATION_ERROR/); + assert.match(write.description, /CREATIVE_DEADLINE_EXCEEDED/); + const codes = readSchema('enums/error-code.json').enum; + for (const code of ['VALIDATION_ERROR', 'CREATIVE_DEADLINE_EXCEEDED', 'UNSUPPORTED_FEATURE']) assert.ok(codes.includes(code), code); + + const featureId = await compile('core/experimental-feature-id.json'); + assert.equal(featureId(FEATURE), true, JSON.stringify(featureId.errors)); + const capabilities = await compile('protocol/get-adcp-capabilities-response.json'); + assert.equal(capabilities({ + status: 'completed', + adcp: { major_versions: [3], idempotency: { supported: false } }, + supported_protocols: ['media_buy'], + experimental_features: [FEATURE] + }), true, JSON.stringify(capabilities.errors)); + + const registry = fs.readFileSync(path.join(ROOT, 'docs/reference/experimental-status.mdx'), 'utf8'); + const row = registry.split('\n').find(line => line.startsWith(`| \`${FEATURE}\` |`)); + assert.ok(row, `experimental-status lists ${FEATURE}`); + for (const surface of ['installment_refs', 'sync_creatives', 'get_media_buys', 'list_creatives', '#8968']) { + assert.ok(row.includes(surface), `registry row names ${surface}`); + } +}); + +test('installment_refs stays off the deprecated assignment rows', () => { + const declares = node => node !== null && typeof node === 'object' && ( + Array.isArray(node) ? node.some(declares) + : (node.properties && Object.hasOwn(node.properties, 'installment_refs')) || Object.values(node).some(declares)); + for (const file of [ + 'core/creative-assignment.json', + 'media-buy/package-request.json', + 'media-buy/package-update.json', + 'core/package.json', + 'media-buy/create-media-buy-request.json', + 'media-buy/update-media-buy-request.json' + ]) { + assert.equal(declares(readSchema(file)), false, `${file} declares installment_refs`); + } + const deprecatedRow = readSchema('creative/sync-creatives-request.json').properties.assignments.items; + assert.equal(deprecatedRow.properties.installment_refs, undefined, 'deprecated sync_creatives assignments[] row'); +});