diff --git a/.changeset/withdraw-create-update-media-buy-3-3-fields.md b/.changeset/withdraw-create-update-media-buy-3-3-fields.md
new file mode 100644
index 00000000000..401c5b934f0
--- /dev/null
+++ b/.changeset/withdraw-create-update-media-buy-3-3-fields.md
@@ -0,0 +1,16 @@
+---
+"adcontextprotocol": minor
+---
+
+Keep 3.3 additions off the deprecated `create_media_buy` and `update_media_buy` tasks (adcp#8501). Both are compatibility tasks that buyers are moving off, so 3.3 adds nothing to them: a field that exists only there is one more gap for every migration. Six fields that the 3.3 betas added to them are removed from them. Where a live task (`buy_products`, `accept_proposal`, `control_media_buy`, `get_media_buys`, `sync_creatives` `assignment_operations`) has the same field, it stays there unchanged.
+
+**This is a beta-to-beta change.** No stable release carried these fields. The betas are immutable, so a buyer, seller, or SDK built from a beta bundle has them to remove. The schemas are open, so a seller on the later schema ignores them like any unknown field rather than rejecting the request.
+
+- **`predecessor_media_buy_id` and `lineage_reason` (experimental `media_buy.lineage`, #8038, from `3.3.0-beta.0`).** Removed from the `create_media_buy` request, with their paired `dependencies`. They stay on the `buy_products` and `accept_proposal` requests, on `get_media_buys` readback and its `predecessor_media_buy_id` filter, and on `core/media-buy.json`.
+- **`artifact_webhook.signing_profile` (experimental, #7894, from `3.3.0-beta.0`).** Removed from the `create_media_buy` request, with its rule against sending it next to `authentication`. No live task has `artifact_webhook`, so it lives nowhere: an `artifact_webhook` without `authentication` is signed with `adcp/webhook-signing/v1`. `signing_profile` stays on `push_notification_config`, `reporting_webhook`, and the notification configurations, and `webhook_signing.profiles` no longer lists `artifact_webhook`.
+- **`start_requires_all_creatives_approved` (#8485, from `3.3.0-beta.1`).** Removed from `media-buy/package-request.json` (`create_media_buy` packages and `update_media_buy` `new_packages`), `media-buy/package-update.json`, and `core/package.json` (the package readback in their responses). It stays on `control_media_buy` package controls (`media-buy/package-control.json`), `get_media_buys` package readback, the `update_creative_start_policy` available action, and `media_buy.features.creative_start_policy`, whose description now names only `control_media_buy`.
+- **`target_audiences[]` (experimental `media_buy.audience_evidence_targets`, #8215, from `3.3.0-beta.1`).** Removed from `media-buy/package-request.json`, with its `dependencies` entry. With the `get_products` filters copy withdrawn in the same release, no 3.3 wire form remains: sellers stop listing the feature id, and `core/audience-evidence-target.json` and `enums/audience-evidence-relationship.json` are removed, along with their `index.json` registrations and `tests/audience-evidence-targets.test.cjs`. The stable inline `relationship` enums on audience evidence are unchanged. The design and its matching rules are kept under the 4.0 check `purchase_target_audiences_required_in_4_0`, whose detection no longer depends on the retired feature id.
+- **`trackers` and `landing_page_url` on package creative assignments (#8220, #8180, from `3.3.0-beta.0`).** Removed from `core/creative-assignment.json`, which only `create_media_buy` and `update_media_buy` requests and their package readback use. They stay on `sync_creatives` `assignment_operations` `assign`, read back on `list_creatives` and `get_media_buys`, which defines its own assignment rows. `creative.supports_assignment_trackers` and `creative.supports_assignment_landing_page` now name only `sync_creatives`.
+- **`billing_measurement.counting_mechanisms[]` (experimental `media_buy.measurement_provenance`, #7940, from `3.3.0-beta.2`).** Removed from `core/measurement-terms.json`, which only `get_products` products and `create_media_buy` and `update_media_buy` packages use, so this covers all three tasks. It lives nowhere in 3.3; the canonical commercial terms, held closed until 4.0, gain it then (`canonical_counting_mechanisms_required_in_4_0`, whose design notes now record it). `vendor_metric_provenance[]` and `enums/collection-mechanism.json` stay.
+
+`expected_confirmation_at` stays on every `submitted` response, `create_media_buy` included. The training agent stops echoing the start gate on `create_media_buy` and `update_media_buy` packages. It ignores assignment `trackers` and `landing_page_url` on their creative assignments and does not echo assign-set ones in their responses. `get_media_buys` still reads back what `sync_creatives` `assign` set. The 3.3 release notes, What's new, the 3.2 to 3.3 migration guide, and the 3.3 beta page list the removals under "Withdrawn from the deprecated tasks".
diff --git a/docs/building/by-layer/L1/wba-profile.mdx b/docs/building/by-layer/L1/wba-profile.mdx
index 3dddc528531..563ca9181e1 100644
--- a/docs/building/by-layer/L1/wba-profile.mdx
+++ b/docs/building/by-layer/L1/wba-profile.mdx
@@ -197,7 +197,7 @@ An unsigned request to an operation in `required_for`, from a caller with no oth
A webhook signed under this profile is an ordinary signed request from the seller's identity. The seller signs the delivery with the rules in [Profile rules](#profile-rules). Its `Signature-Agent` member names the seller's origin, and the signature covers `content-type` and `content-digest`. The receiver resolves the seller's keys from the seller's key directory with the same one resolution rule, and applies [Verification](#verification) and [Replay protection](#replay-protection) as a seller does.
-A subscriber states which profile it verifies in the optional `signing_profile` field of every webhook registration: `push_notification_config`, `reporting_webhook`, `artifact_webhook`, `accounts[].notification_configs[]`, and the agent-level notification configuration. `web-bot-auth` selects this profile, and `adcp/webhook-signing/v1` selects the AdCP webhook profile. A registration that omits the field is signed as before: with the AdCP webhook profile, or with the legacy scheme when `authentication` is present. A registration carries `signing_profile` or `authentication`, never both, and the schemas refuse one that carries both. The field has no schema `default`, so a validator or a generated SDK that fills in defaults never adds it next to `authentication`. The seller signs every delivery to that registration with the one selected profile and never signs a delivery both ways. This keeps the mode-selection rule of [Webhook callbacks](/docs/building/by-layer/L1/security#webhook-callbacks). The two profiles encode binary header values differently: the AdCP webhook profile uses unpadded Base64URL, and this profile uses RFC 8941 Base64. A receiver therefore selects one parser from the registration and never tries both.
+A subscriber states which profile it verifies in the optional `signing_profile` field of every webhook registration: `push_notification_config`, `reporting_webhook`, `accounts[].notification_configs[]`, and the agent-level notification configuration. `artifact_webhook` on the deprecated `create_media_buy` has no `signing_profile`, so without `authentication` it is signed with the AdCP webhook profile. `web-bot-auth` selects this profile, and `adcp/webhook-signing/v1` selects the AdCP webhook profile. A registration that omits the field is signed as before: with the AdCP webhook profile, or with the legacy scheme when `authentication` is present. A registration carries `signing_profile` or `authentication`, never both, and the schemas refuse one that carries both. The field has no schema `default`, so a validator or a generated SDK that fills in defaults never adds it next to `authentication`. The seller signs every delivery to that registration with the one selected profile and never signs a delivery both ways. This keeps the mode-selection rule of [Webhook callbacks](/docs/building/by-layer/L1/security#webhook-callbacks). The two profiles encode binary header values differently: the AdCP webhook profile uses unpadded Base64URL, and this profile uses RFC 8941 Base64. A receiver therefore selects one parser from the registration and never tries both.
A seller advertises the profiles it can sign webhooks with in `webhook_signing.profiles`. A seller that does not list the profile a subscriber selects rejects the configuration as a validation failure.
diff --git a/docs/creative/creative-libraries.mdx b/docs/creative/creative-libraries.mdx
index d62bbbbbc3d..1faf05766eb 100644
--- a/docs/creative/creative-libraries.mdx
+++ b/docs/creative/creative-libraries.mdx
@@ -159,8 +159,9 @@ After upload, the creative enters the library's review process. Check `list_crea
In AdCP 3.3, a buyer can book first and assign a library creative while it is
`pending_review` or `rejected`. The assignment persists through correction and
review under the same ID. Eligible approved assignments serve by default;
-`start_requires_all_creatives_approved: true` on a package holds its first
-delivery until every assignment has package approval. See the [creative start policy](/docs/media-buy/specification#starting-with-assigned-creatives-under-review).
+`start_requires_all_creatives_approved: true`, set on a package through
+`control_media_buy`, holds its first delivery until every assignment has
+package approval. See the [creative start policy](/docs/media-buy/specification#starting-with-assigned-creatives-under-review).
### Prepare creatives before a time-sensitive launch
diff --git a/docs/creative/task-reference/sync_creatives.mdx b/docs/creative/task-reference/sync_creatives.mdx
index 5f82cdcc45e..69d5738ba48 100644
--- a/docs/creative/task-reference/sync_creatives.mdx
+++ b/docs/creative/task-reference/sync_creatives.mdx
@@ -132,8 +132,8 @@ In AdCP 3.3, `assignments` and `assignment_operations` may assign an existing
`pending_review` or `rejected` library creative. Otherwise valid assignments
remain in place through review, rejection, and correction under the same
`creative_id`; assignment is not authorization to serve. By default eligible
-approved assignments start independently. A package may instead set
-`start_requires_all_creatives_approved: true` to hold its first release for serving. See the
+approved assignments start independently. A buyer may instead set a package's
+`start_requires_all_creatives_approved: true` through [`control_media_buy`](/docs/media-buy/task-reference/control_media_buy) to hold its first release for serving. See the
[creative start policy](/docs/media-buy/specification#starting-with-assigned-creatives-under-review) for package approval, status, and resubmission rules.
For campaigns with a short launch window, [sync the creative into a seller library before booking, wait for review, then assign it to a package](/docs/creative/creative-libraries#prepare-creatives-before-a-time-sensitive-launch). Library approval and package assignment eligibility are separate checks.
@@ -167,16 +167,16 @@ seller rejects the request rather than applying last-write-wins behavior.
### Assignment landing pages
-The deprecated `assignments[]` row does not carry `landing_page_url` or `trackers`. The 3.3 betas accepted both there; they are withdrawn from that row before 3.3 GA ([adcp#8501](https://github.com/adcontextprotocol/adcp/issues/8501)), and the row rejects them by name. Send them on an `assignment_operations` `assign`.
+The deprecated `assignments[]` row does not carry `landing_page_url` or `trackers`. The 3.3 betas accepted both there; they are withdrawn from that row before 3.3 GA ([adcp#8501](https://github.com/adcontextprotocol/adcp/issues/8501)), and the row rejects them by name. Neither does a package `creative_assignments` row on the deprecated [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) or [`update_media_buy`](/docs/media-buy/task-reference/update_media_buy): that row is open, so a seller ignores them there. Send them on an `assignment_operations` `assign`.
-In AdCP 3.3, optional `landing_page_url` on the `assign` variant of `assignment_operations[]`, and package `creative_assignments` in [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) and [`update_media_buy`](/docs/media-buy/task-reference/update_media_buy) replaces the click-through destination for that one creative on that one package. The library creative does not change. The override applies to every click-through destination for that assignment, including per-state destinations; the seller MUST serve it everywhere it applies or reject the assignment through existing assignment errors, and MUST NOT silently ignore it or apply it partially. This includes clicks embedded in HTML, JavaScript, or a VAST tag that the seller cannot override. The creative's approval stands; a seller that reviews destinations reviews the assignment, not the creative.
+In AdCP 3.3, optional `landing_page_url` on the `assign` variant of `assignment_operations[]` replaces the click-through destination for that one creative on that one package. The library creative does not change. The override applies to every click-through destination for that assignment, including per-state destinations; the seller MUST serve it everywhere it applies or reject the assignment through existing assignment errors, and MUST NOT silently ignore it or apply it partially. This includes clicks embedded in HTML, JavaScript, or a VAST tag that the seller cannot override. The creative's approval stands; a seller that reviews destinations reviews the assignment, not the creative.
Buyers MUST NOT send the field unless [`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities) declares `creative.supports_assignment_landing_page: true` (default false, requiring `has_creative_library: true`); an agent without that capability MUST reject the field with [`UNSUPPORTED_FEATURE`](/docs/building/verification/compliance-catalog#error-code-unsupported-feature). The URL may contain [AdCP macros](/docs/creative/universal-macros) under the same rules as the creative's own click-through without declarations, including the existing 3.x compatibility behavior. This field introduces no assignment-level `macro_declarations`; declarations on the original creative assets do not transfer to the override, and neither URL syntax nor this capability proves declared token resolution. Existing governed catalog substitution and encoding rules still apply.
-On `assign` operations, omitting `landing_page_url` preserves an existing override. To restore inheritance without changing the library creative, atomically `unassign` then `assign` the same creative/package without the field; include any weight, placement settings, or assignment trackers that must remain on the new assignment. `null` is not accepted. For a complete `update_media_buy` package `creative_assignments` replacement, omission from a supplied assignment row restores inheritance; omitting the entire replacement array leaves assignments unchanged.
+On `assign` operations, omitting `landing_page_url` preserves an existing override. To restore inheritance without changing the library creative, atomically `unassign` then `assign` the same creative/package without the field; include any weight, placement settings, or assignment trackers that must remain on the new assignment. `null` is not accepted. Package `creative_assignments` rows on `create_media_buy` and `update_media_buy` carry no `landing_page_url`, so a complete `update_media_buy` replacement restores inheritance on every row it supplies; omitting the entire replacement array leaves assignments unchanged.
-The unassign/assign reset recreates assignment state. Its `assign` operation cannot carry `rotation_mode`, `group_id`, `sequence_position`, or structured `placement_refs`. To retain those controls, use a complete `update_media_buy` package `creative_assignments` replacement instead: retain every other assignment and its authored settings, retain the target's rotation/group/placement settings and assignment trackers, and omit only the target's `landing_page_url`. Readback URLs are effective destinations, so do not blindly copy them into override fields when reconstructing authored assignment state.
+The unassign/assign reset recreates assignment state. Its `assign` operation cannot carry `rotation_mode`, `group_id`, `sequence_position`, or structured `placement_refs`. To retain those controls, send a complete `update_media_buy` package `creative_assignments` replacement that keeps every assignment with its rotation, group, and placement settings. The replacement clears assignment `trackers` and `landing_page_url` on every row it supplies, so follow it with `assign` operations that restore the trackers and overrides that should remain; an `assign` on an existing assignment upserts the settings it carries. The two calls are not atomic, so pause the package first if it must not serve without its assignment trackers or landing-page overrides. Readback URLs are effective destinations, so do not blindly copy them into override fields when reconstructing authored assignment state.
Supporting sellers MUST return the effective serving destination in [`list_creatives`](/docs/creative/task-reference/list_creatives) assignments and [`get_media_buys`](/docs/media-buy/task-reference/get_media_buys) package creative assignment rows (`packages[].creative_approvals[]`). Return the override when present, otherwise the creative's own destination when unique; a macro-bearing destination remains the effective URL template. Omit the scalar when no destination exists or an unoverridden creative has multiple destinations, whose URLs remain in its creative assets. Sellers MUST NOT pick one arbitrarily and present it as applying to every click. Buyers reconcile assignment `approval_status` separately from the library creative's `status`.
@@ -198,7 +198,7 @@ After checking the capability and setting an override with an `assign` operation
### Assignment trackers
-In AdCP 3.3, optional `trackers` on the `assign` variant of `assignment_operations[]`, and package `creative_assignments` in [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) and [`update_media_buy`](/docs/media-buy/task-reference/update_media_buy) attaches measurement URLs to one creative on one package. A buyer can reuse a library creative across media buys while giving each assignment its own campaign and package values. The seller fires these trackers **in addition to** the creative's own trackers. Changing assignment trackers does not mutate the library creative, its revision, or its approval; any tracker policy review applies to the assignment.
+In AdCP 3.3, optional `trackers` on the `assign` variant of `assignment_operations[]` attaches measurement URLs to one creative on one package. A buyer can reuse a library creative across media buys while giving each assignment its own campaign and package values. The seller fires these trackers **in addition to** the creative's own trackers. Changing assignment trackers does not mutate the library creative, its revision, or its approval; any tracker policy review applies to the assignment.
Buyers MUST NOT send `trackers` unless [`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities) declares `creative.supports_assignment_trackers: true`. This capability defaults to false and requires `has_creative_library: true`; an unsupported agent MUST reject the field with [`UNSUPPORTED_FEATURE`](/docs/building/verification/compliance-catalog#error-code-unsupported-feature). It is independent of `creative.supports_assignment_landing_page`.
@@ -219,7 +219,7 @@ These limits apply only when the effective format declares at least one tracker
Tracker URLs use the existing [macro-bearing URL rules](/docs/creative/universal-macros#template-syntax) without declarations, including 3.x compatibility behavior. Declarations on creative assets do not transfer to these URLs. Neither URL syntax nor assignment tracker support guarantees resolution of any particular macro; check the seller's macro resolution capabilities. Buyers can put their own known buy/package values directly in the URL, avoiding a dependency on seller ID substitution. Readback preserves the accepted templates.
-Supplying `trackers` replaces that assignment's previous tracker list; it does not append on each upsert or retry. Omission on `assign` operations preserves the list. To clear it, use the atomic `unassign`/`assign` [reset recipe](#assignment-landing-pages), omitting `trackers` from the new assignment and resupplying other authored settings, including any landing-page override. For rotation/group/structured placement controls, use a complete `update_media_buy` package `creative_assignments` replacement, retaining every other assignment and setting, and omitting only the target's `trackers`. Omission from a supplied replacement row clears its assignment trackers; omitting the entire replacement array leaves assignments unchanged. Empty arrays and `null` are not accepted on writes.
+Supplying `trackers` replaces that assignment's previous tracker list; it does not append on each upsert or retry. Omission on `assign` operations preserves the list. To clear it, use the atomic `unassign`/`assign` [reset recipe](#assignment-landing-pages), omitting `trackers` from the new assignment and resupplying other authored settings, including any landing-page override. For rotation/group/structured placement controls, use the replacement-then-`assign` sequence in the [reset recipe](#assignment-landing-pages): an `update_media_buy` package `creative_assignments` replacement clears assignment trackers on every row it supplies, and omitting the entire replacement array leaves assignments unchanged. Empty arrays and `null` are not accepted on writes.
Supporting sellers MUST return `trackers` in [`list_creatives`](/docs/creative/task-reference/list_creatives) `assignments.assigned_packages[]` and [`get_media_buys`](/docs/media-buy/task-reference/get_media_buys) `packages[].creative_approvals[]`. Return the complete assignment-scoped list, preserving events, URL templates, order, and duplicate occurrences, or `[]` when there are no assignment trackers. This list excludes the creative's own trackers, which remain in its `assets` and also fire. Buyers can copy a nonempty list back as authored assignment state without duplicating the creative's trackers; use the reset recipe for an empty readback list. After an assignment `merge`, the buyer creative's package assignment trackers apply alongside that creative's trackers; the seller MUST validate the resulting combination or reject the atomic merge.
diff --git a/docs/measurement/taxonomy.mdx b/docs/measurement/taxonomy.mdx
index 4a6f13f12ef..38cfea0bf57 100644
--- a/docs/measurement/taxonomy.mdx
+++ b/docs/measurement/taxonomy.mdx
@@ -127,7 +127,7 @@ Several fields answer different questions about one number. They are not interch
| Which provider produced it? | `measurement_source` | Provider slug (`nielsen`, `videoamp`, `ivw`) | Delivery metrics, delivery forecast |
| Which party's count governs invoicing, and who is the vendor of a metric? | `billing_measurement.vendor`, `vendor_metric_values[].vendor` (BrandRef) | A brand reference | Measurement terms, delivery rows |
| How does that party relate to the seller? | `vendor_relationship` | `first_party`, `affiliated`, `third_party` | Product reporting capabilities, delivery rows |
-| What was observed to build the count? | `counting_mechanisms[]`, `vendor_metric_provenance[].mechanisms[]` (experimental) | [`collection-mechanism`](https://adcontextprotocol.org/schemas/latest/enums/collection-mechanism.json): `set_top_box`, `acr`, `panel`, `server_logs`, `client_tracker`, `sdk` | Measurement terms, vendor metric rows |
+| What was observed to build the count? | `vendor_metric_provenance[].mechanisms[]` (experimental) | [`collection-mechanism`](https://adcontextprotocol.org/schemas/latest/enums/collection-mechanism.json): `set_top_box`, `acr`, `panel`, `server_logs`, `client_tracker`, `sdk` | Vendor metric rows |
| How mature is the data? | `measurement_window` | A product-declared window id (`live`, `c7`, `post_ivt`) | Measurement terms, delivery rows |
| How was an outcome attributed? | `qualifier.attribution_methodology` | `deterministic_purchase`, `probabilistic`, `panel_based`, `modeled` | Metric qualifiers |
| Who attested the event? | `qualifier.completion_source` | `seller_attested`, `vendor_attested` | Metric qualifiers |
diff --git a/docs/media-buy/advanced-topics/billing-authority.mdx b/docs/media-buy/advanced-topics/billing-authority.mdx
index 0091bc168ba..cf86712189c 100644
--- a/docs/media-buy/advanced-topics/billing-authority.mdx
+++ b/docs/media-buy/advanced-topics/billing-authority.mdx
@@ -45,43 +45,17 @@ buyer-deposited revision task. Tracked in
| Field | Meaning |
|---|---|
| `vendor` (required, [BrandRef](/docs/brand-protocol/brand-json)) | The party whose count of the billing metric governs invoicing. Same field shape covers seller's ad server, buyer's 3PAS, or a third-party vendor — the BrandRef domain disambiguates. |
-| `counting_mechanisms[]` (optional, [`collection-mechanism`](https://adcontextprotocol.org/schemas/latest/enums/collection-mechanism.json)) | What the billing count named by `vendor` is counted from: `server_logs`, `client_tracker`, `sdk`, `set_top_box`, `acr`, `panel`. `vendor` says *who* counts; `counting_mechanisms` says *how*. See [Declaring how the billing count is built](#declaring-how-the-billing-count-is-built-experimental). |
| `max_variance_percent` | Tolerance above which the seller's and authoritative party's counts trigger resolution under `makegood_policy`. IAB default is 10%; broadcast/CTV often 5%. |
| `measurement_window` | Which maturation stage of the data is the reconciliation point (`c7`, `post_sivt`, `downloads_30d`, etc.) — references a `window_id` from the product's `reporting_capabilities.measurement_windows`. |
| `finalization_deadline_hours` | Maximum hours by which the authoritative party MUST publish a final record. When `measurement_window` is set, hours are counted from the window's close (not from `reporting_period.end`); when absent, from `reporting_period.end`. The deadline applies symmetrically to whichever party is named in `vendor`. On miss, the counterparty MAY fall back to its own attestation; the breach is handled under `makegood_policy`. |
Absence is informative: when `billing_measurement` is absent the default is seller-attested, with no contractual finalization deadline.
-## Declaring how the billing count is built (experimental)
+## Declaring how the billing count is built
-
-**Experimental in 3.3** (feature id `media_buy.measurement_provenance`). `counting_mechanisms[]` and the `collection-mechanism` enum may change within 3.x with the notice described in [Experimental status](/docs/reference/experimental-status). Sellers that emit it declare the feature id in `experimental_features`.
-
-
-Two sellers can both name themselves as `billing_measurement.vendor` and still count differently: one bills from server-side delivery logs, another from begin-to-render beacons. When variance later exceeds `max_variance_percent`, a common cause is that difference, and it was never on the wire. `counting_mechanisms[]` puts it there before the buy, so a buyer agent can compare declared mechanisms across products (client-side on `measurement_terms`; a `get_products` filter is an additive follow-up) and the field is part of what gets negotiated.
-
-- **Billing count only.** It lists the mechanisms whose events enter the billed number. Mechanisms used only for audit or cross-check are not listed. If several are listed, the merge rule is not encoded; the seller describes it in its methodology. Provenance on individual reported vendor values is [`vendor_metric_provenance`](/docs/media-buy/media-buys/optimization-reporting#collection-mechanism-provenance-on-vendor-values-experimental), which shares the vocabulary. Neither field is `measurement_source`, which names the provider.
-- **A claim, not a probe.** It is a claim by the declaring party, enforced through the contract (`vendor`, `max_variance_percent`, `makegood_policy`) rather than tested by AdCP. A count that materially differs from what was declared is handled like any other variance under those terms.
-- **Absence means undeclared.** Never read an absent field as `server_logs` or any other default. It is declared alongside `vendor`; a seller that omits `billing_measurement` declares nothing.
-- **Self-counting sellers SHOULD declare it.** When the seller (or its ad server) is the named `vendor`, declare `counting_mechanisms[]`. Buyer-attested and third-party vendors describe their own method in their measurement-agent catalog and MAY omit it.
-- **Negotiable like any other term.** On a product it is the seller's default; on a package request it is a buyer's proposed term; the seller accepts, adjusts, or rejects with `TERMS_REJECTED`. In practice a seller will rarely agree to a different method for its own count, so treat it as a handshake more than a lever.
-- **A set.** Order carries no meaning and values MUST NOT repeat.
-- **Airing evidence is not a counting mechanism.** Broadcast as-run logs and station affidavits prove that a spot ran; they are not a mechanism for audience measurement and have no value in this vocabulary. Delivery reports carry as-run data in `by_spot[]`, separate from the audience rows that `counting_mechanisms[]` and `vendor_metric_provenance` describe.
-
-```json title="Product excerpt — a CTV seller billing from its server-side logs"
-{
- "measurement_terms": {
- "billing_measurement": {
- "vendor": { "domain": "streamhaus.example" },
- "counting_mechanisms": ["server_logs"],
- "max_variance_percent": 10,
- "measurement_window": "post_ivt"
- }
- }
-}
-```
+Two sellers can both name themselves as `billing_measurement.vendor` and still count differently: one bills from server-side delivery logs, another from begin-to-render beacons. The 3.3 betas carried an experimental `billing_measurement.counting_mechanisms[]` to put that difference on the wire before the buy. It sat on `core/measurement-terms.json`, which only the deprecated `get_products`, `create_media_buy`, and `update_media_buy` use, so it is withdrawn from 3.3 ([adcp#8501](https://github.com/adcontextprotocol/adcp/issues/8501)). The canonical commercial terms, held closed until 4.0, gain it then, and its design is kept under [Canonical terms carry counting mechanisms](/docs/reference/migration/v4-compatibility#canonical_counting_mechanisms_required_in_4_0).
-A buyer whose third-party ad server counts tag fires (`client_tracker`) can see from this declaration that its count will run below the seller's, and a gap inside the stated variance is expected rather than disputed. The buyer can instead propose its own ad server as `billing_measurement.vendor` on the package request.
+Until then, read how a vendor's values were built from [`vendor_metric_provenance`](/docs/media-buy/media-buys/optimization-reporting#collection-mechanism-provenance-on-vendor-values-experimental) on delivery rows, and how the billing vendor counts from its methodology.
## Marking numbers as final
diff --git a/docs/media-buy/advanced-topics/pricing-models.mdx b/docs/media-buy/advanced-topics/pricing-models.mdx
index 7d13b85301e..7112722f18a 100644
--- a/docs/media-buy/advanced-topics/pricing-models.mdx
+++ b/docs/media-buy/advanced-topics/pricing-models.mdx
@@ -96,7 +96,7 @@ Beyond declaring a provider, sellers can publish structured terms on products
**Measurement terms fields:**
-- **`billing_measurement`** — Which vendor's count of the billing metric (determined by `pricing_model`) governs invoicing. `max_variance_percent` defines the threshold at which the non-billing party's count divergence triggers resolution. Optional, experimental `counting_mechanisms[]` declares what the billing count is counted from (`server_logs`, `client_tracker`, `sdk`, `set_top_box`, `acr`, `panel`); see [Billing authority](/docs/media-buy/advanced-topics/billing-authority#declaring-how-the-billing-count-is-built-experimental).
+- **`billing_measurement`** — Which vendor's count of the billing metric (determined by `pricing_model`) governs invoicing. `max_variance_percent` defines the threshold at which the non-billing party's count divergence triggers resolution.
- **`makegood_policy`** — Closed menu of remedies available when any performance standard or billing variance is breached. Three remedy types: `additional_delivery` (extend/add impressions, like-for-like), `credit` (toward future buys), `invoice_adjustment` (reduce current invoice). Seller proposes from this menu; buyer accepts or disputes.
**Performance standards fields:**
diff --git a/docs/media-buy/media-buys/index.mdx b/docs/media-buy/media-buys/index.mdx
index b936268b5c4..091419f3d04 100644
--- a/docs/media-buy/media-buys/index.mdx
+++ b/docs/media-buy/media-buys/index.mdx
@@ -72,7 +72,8 @@ the current seller snapshot through [`get_media_buys`](/docs/media-buy/task-refe
The [creative start policy](/docs/media-buy/specification#starting-with-assigned-creatives-under-review) allows approved assignments to start while others remain
in review. A package may instead require every assignment approved before
-first release for serving with `start_requires_all_creatives_approved: true` (AdCP 3.3).
+first release for serving with `start_requires_all_creatives_approved: true`, set
+through [`control_media_buy`](/docs/media-buy/task-reference/control_media_buy) (AdCP 3.3).
## State machine
diff --git a/docs/media-buy/media-buys/optimization-reporting.mdx b/docs/media-buy/media-buys/optimization-reporting.mdx
index c7c288afffb..e12c483f505 100644
--- a/docs/media-buy/media-buys/optimization-reporting.mdx
+++ b/docs/media-buy/media-buys/optimization-reporting.mdx
@@ -432,7 +432,7 @@ A single vendor number can be built from very different inputs: set-top-box retu
- **Not a reconciliation key.** `vendor_metric_provenance` describes how a value was produced and does not partition rows, so it stays out of the `(vendor, metric_id, qualifier)` join.
- **Protocol-defined keys, neutral values, not a provider.** `vendor_metric_provenance` says how a value was built, never who provided it. `measurement_source` on delivery metrics names the provider (`nielsen`, `videoamp`, `ivw`) and is unrelated. Buyers MAY filter or group rows on `mechanisms`; readers MUST ignore unknown keys. The values are not a quality ranking, and the same value from two vendors is not thereby comparable. Vendor-specific detail still belongs in `breakdown`.
-The same vocabulary appears at discovery time as [`billing_measurement.counting_mechanisms[]`](/docs/media-buy/advanced-topics/billing-authority#declaring-how-the-billing-count-is-built-experimental). Standard scalar metrics such as `impressions` have no report-time provenance slot in this release. Broadcast as-run logs and station affidavits are airing evidence, not a counting mechanism for audience measurement, and are not values in this vocabulary; as-run data is carried in `by_spot[]`, separately from the audience rows.
+A discovery-time declaration of the billing count's mechanisms, `billing_measurement.counting_mechanisms[]`, is [planned for 4.0](/docs/media-buy/advanced-topics/billing-authority#declaring-how-the-billing-count-is-built). Standard scalar metrics such as `impressions` have no report-time provenance slot in this release. Broadcast as-run logs and station affidavits are airing evidence, not a counting mechanism for audience measurement, and are not values in this vocabulary; as-run data is carried in `by_spot[]`, separately from the audience rows.
```json
{
diff --git a/docs/media-buy/product-discovery/media-products.mdx b/docs/media-buy/product-discovery/media-products.mdx
index 07b90b8b559..1627da4b62a 100644
--- a/docs/media-buy/product-discovery/media-products.mdx
+++ b/docs/media-buy/product-discovery/media-products.mdx
@@ -197,7 +197,7 @@ The three concepts remain separate:
When a buyer supplies evidence requirements and evidence affects product inclusion or rank, the returned Product carries `audience_evidence_selections[]` with the exact matching snapshots and `decision_use` of `eligibility` or `recommendation`. This decision readback is mandatory even when the buyer's `fields` projection omitted `audience_evidence_selections`; projection may omit the optional full evidence payload, not the explanation of an applied policy.
-At buy time, a package can repeat `audience_evidence_requirements` (and, experimentally, the sibling `target_audiences`) and can carry `audience_evidence_pins[]` copied from discovery. Every pin includes `evidence_id`, `snapshot_id`, `version`, and `content_digest`; the seller rejects a missing or mutated snapshot instead of substituting current catalog content. The confirmed Package returns a matching `audience_evidence_selections[]` entry with `decision_use: "package_construction"` for every accepted pin and every snapshot used to satisfy package requirements, and preserves that readback on later package reads.
+At buy time, a package can repeat `audience_evidence_requirements` and can carry `audience_evidence_pins[]` copied from discovery. Every pin includes `evidence_id`, `snapshot_id`, `version`, and `content_digest`; the seller rejects a missing or mutated snapshot instead of substituting current catalog content. The confirmed Package returns a matching `audience_evidence_selections[]` entry with `decision_use: "package_construction"` for every accepted pin and every snapshot used to satisfy package requirements, and preserves that readback on later package reads.
When an attestation affected either decision, readback preserves the exact reference and its `AttestationEvaluation` as one pair. That pair is valid only when the reference is published on the selected snapshot, its claim and issuer satisfy both buyer and seller policy, `reference_digest` covers that same reference, the outcome is `verified`, and both the subject digest and action-binding digest equal the evidence `content_digest`. An evaluation for one reference cannot verify a different reference. Sellers retain the full immutable snapshot and evaluations for at least as long as the package remains readable. Buyers should cache the evidence returned during discovery; AdCP 3.2 does not introduce a buyer-controlled snapshot fetch URL. Sellers may embed the full snapshot in readback, but need not duplicate it on every product or package.
diff --git a/docs/media-buy/specification.mdx b/docs/media-buy/specification.mdx
index 1767c4966b2..97e78358703 100644
--- a/docs/media-buy/specification.mdx
+++ b/docs/media-buy/specification.mdx
@@ -699,7 +699,7 @@ The account boundary is the AdCP primitive for access partitioning; `get_agent_c
A media buy MAY declare the earlier buy it succeeds — a renewal onto a new flight, a replacement, a migration, or a sunset into a successor — so buyers do not rebuild spend continuity, pacing baselines, and dispute context out of band. `history[]` records one buy's own revisions; lineage links two different buys.
-- **Forward-only.** The successor carries `predecessor_media_buy_id` and `lineage_reason`, set once at creation on [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy), [`buy_products`](/docs/media-buy/task-reference/buy_products), or [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal) (when acceptance creates a new MediaBuy). There is no field on the predecessor and no update action: the fields are not part of `update_media_buy`, and a seller MUST NOT apply them if sent there. Creating a successor MUST NOT change the predecessor's AdCP-visible state, including `status`, `revision`, `updated_at`, and `history[]`, and MUST NOT add a reverse pointer to it. Canceled and completed buys are never mutated. Creating the successor does not cancel or complete a live predecessor; the buyer does that separately, and this section does not limit how many buys name the same predecessor.
+- **Forward-only.** The successor carries `predecessor_media_buy_id` and `lineage_reason`, set once at creation on [`buy_products`](/docs/media-buy/task-reference/buy_products) or [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal) (when acceptance creates a new MediaBuy). There is no field on the predecessor and no update action: the fields are not part of the deprecated `create_media_buy` or `update_media_buy`, and a seller MUST NOT apply them if sent there. Creating a successor MUST NOT change the predecessor's AdCP-visible state, including `status`, `revision`, `updated_at`, and `history[]`, and MUST NOT add a reverse pointer to it. Canceled and completed buys are never mutated. Creating the successor does not cancel or complete a live predecessor; the buyer does that separately, and this section does not limit how many buys name the same predecessor.
- **Same seller, same account.** The value MUST be the `media_buy_id` of an existing buy owned by the same seller and the same account as the request. `media_buy_id` is seller-namespaced, so cross-seller lineage is not expressible. The predecessor MAY be in any status; it need not be terminal.
- **Invalid links are rejected.** The seller MUST reject the request with [`MEDIA_BUY_NOT_FOUND`](/docs/building/verification/compliance-catalog#error-code-media-buy-not-found) when no such buy is accessible to the caller in the request's account, and MUST NOT reveal whether the ID exists under another account. It MUST reject with [`INVALID_REQUEST`](/docs/building/verification/compliance-catalog#error-code-invalid-request) when `predecessor_media_buy_id` and `lineage_reason` are not supplied together, or when an `accept_proposal` request carries them for a proposal whose `proposal_kind` is `media_buy_update` or `media_buy_cancellation`. A rejected request creates no media buy. Because the link is set once and must name an already-existing buy, a cycle cannot form; there is no depth limit and no lineage-specific error code.
- **No silent drop.** A seller that receives a schema-valid link it cannot record MUST reject the request with [`UNSUPPORTED_FEATURE`](/docs/building/verification/compliance-catalog#error-code-unsupported-feature) and MUST NOT create an unlinked buy. Because these schemas allow additional properties, a seller that predates lineage may ignore the fields; a buyer therefore reads the echo on [`get_media_buys`](/docs/media-buy/task-reference/get_media_buys) after creation and treats its absence as "link not recorded".
@@ -890,9 +890,10 @@ With the default policy, a `partially_approved` assignment may serve only its
approved scopes; pending or rejected scopes remain ineligible.
The package boolean `start_requires_all_creatives_approved` defaults to `false`.
-Buyers MAY set it to `true` on `create_media_buy.packages[]` or
-`update_media_buy.new_packages[]` when a sequential rotation, A/B split, or
-coordinated launch requires the whole creative set at first release for serving. The seller
+Buyers MAY set it to `true` through `control_media_buy.packages[]` when a
+sequential rotation, A/B split, or coordinated launch requires the whole
+creative set at first release for serving. The deprecated `create_media_buy`
+and `update_media_buy` do not carry it. The seller
MUST still book the package and retain its existing inventory commitment, but MUST hold
its initial delivery until **every current assignment** is approved for every
scope where that assignment may serve. This includes assignments with zero
@@ -924,9 +925,8 @@ attach the complete intended launch set atomically, or keep delivery paused
throughout staged assignment. Assigning approved A in one call and pending B in
a later call without a pause can let A consume the gate before B is assigned.
-Buyers MAY change the boolean through `update_media_buy.packages[]` or
-`control_media_buy.packages[]`; omission preserves the policy and `false`
-clears the gate. This is an operational control under the structured
+Buyers MAY change the boolean through `control_media_buy.packages[]`;
+omission preserves the policy and `false` clears the gate. This is an operational control under the structured
`update_creative_start_policy` available action, outside accepted commercial
terms and their digest. A supporting seller MUST expose that package-scoped
action before release so the buyer can set or clear the policy. A seller MUST
@@ -946,7 +946,6 @@ belongs in the purchase request. For staged supply, keep the buy or package
paused until the intended set is assigned, then resume when review permits it.
Sellers MUST echo the effective boolean, including a defaulted `false`, on
-created packages, `update_media_buy.affected_packages[]`, and
`get_media_buys.packages[]`. Buyers MUST negotiate AdCP 3.3 or later and check
`get_adcp_capabilities.media_buy.features.creative_start_policy: true` before
opting in, including before committing a compact purchase. False or absent
@@ -1093,7 +1092,6 @@ Create a media buy from selected packages or execute a proposal.
**Requirements:**
- Orchestrators MUST include either `packages` array or `proposal_id`
- Orchestrators MUST include `start_time` and `end_time` for the campaign
-- Orchestrators MAY include `predecessor_media_buy_id` with `lineage_reason` to link the new buy to an earlier buy of the same seller and account; see [Media Buy Lineage](#media-buy-lineage)
- Sales agents MUST return `media_buy_id` on successful creation
- Sales agents MUST return `confirmed_at` on successful creation. The value is the seller commitment timestamp, or `null` when the response creates a provisional buy that is not yet committed.
- Sales agents MUST return `revision` on successful creation
diff --git a/docs/media-buy/task-reference/create_media_buy.mdx b/docs/media-buy/task-reference/create_media_buy.mdx
index 31ab25c6968..0edcda85a9d 100644
--- a/docs/media-buy/task-reference/create_media_buy.mdx
+++ b/docs/media-buy/task-reference/create_media_buy.mdx
@@ -202,8 +202,6 @@ npx @adcp/sdk@latest \
| `bidding` | BiddingPolicy | No | Complete media-buy bidding default inherited by packages that omit `packages[].bidding`. In shared-budget mode its goal-bound controls bind to the allocation primary goal; in fixed mode inherited cost controls require compatible package result units. |
| `paused` | boolean | No | Create the media buy with delivery held. When true and the buy would otherwise be active, `media_buy_status` is `paused`. Setup blockers still take precedence: missing creatives yield `pending_creatives`, and future flights yield `pending_start`; the hold becomes visible as `paused` after those blockers clear. |
| `invoice_recipient` | [BusinessEntity](/docs/building/by-layer/L2/accounts-and-agents#billing-entity-and-invoice-recipient) | No | Override the account's default billing entity for this buy. The seller MUST validate the recipient is authorized and include it in [`check_governance`](/docs/governance/campaign/tasks/check_governance) when governance agents are configured. |
-| `predecessor_media_buy_id` | string | No | Experimental (`media_buy.lineage`). `media_buy_id` of an existing buy of the same seller and account that this buy succeeds. Forward-only; the predecessor is never modified. Requires `lineage_reason`. Rejected with [`MEDIA_BUY_NOT_FOUND`](/docs/building/verification/compliance-catalog#error-code-media-buy-not-found) when no such buy is accessible in this account. See [Media Buy Lineage](/docs/media-buy/specification#media-buy-lineage). |
-| `lineage_reason` | string | No | `renewal`, `terms_change`, `migration`, `sunset`, or `replacement`. Required with `predecessor_media_buy_id`. |
| `po_number` | string | No | Purchase order number |
| `name` | string | No | Human-readable shared label for trafficking UI display and buyer-seller communication (maximum 255 characters). Sellers persist and echo it unchanged; it is not a purchase-order, reconciliation, or identity key. |
| `idempotency_key` | string | No | Unique key for safe retries. If a request with the same key and account has already been processed, the seller returns the existing media buy. MUST be unique per (seller, request) pair. Min 16 chars. |
@@ -273,12 +271,11 @@ capability and explicit `status: "open"`.
| `bid_price` | number | No | **Deprecated in 3.2.** Use `bidding.bid_amount` or `bidding.max_bid`. A package cannot supply both representations. |
| `optimization_goals` | [OptimizationGoal[]](/docs/media-buy/conversion-tracking/#optimization-goals) | No | Objective functions for this package: what to optimize and in what priority. Currency controls belong in `bidding`; legacy `target.cost_per` and `target.per_ad_spend` remain accepted only for migration. |
| `targeting_overlay` | TargetingInput | No | Request-only per-dimension targeting applied to the configured targeting represented by `product_id` (see [Targeting](/docs/media-buy/advanced-topics/targeting#request-mutation-semantics)). A nested `frequency_cap` owns an independent package counter; it does not share state with other packages or with a root `frequency_cap`. Omission inherits the configured/product default for a dimension, a non-null value replaces it, and `null` explicitly suppresses it. A clear cannot remove inherent product scope; the seller rejects a clear it cannot execute. The resulting package echoes the complete effective overlay with cleared dimensions omitted and no `null` values. Non-null additions must compile exactly or be rejected and require product `overlay_support` unless already accepted during discovery. The narrow exception is a placement set equal to the product's complete, explicitly enumerated `mode: "included"` set, or a `collection_selection` exactly restating the product's complete `collections` bundle (gated by `collection_targeting_allowed`, which has no `overlay_support` key); either needs no selectable support, while a partial set does. Opaque property and collection list refs have no equivalent fixed-restatement exception. For `demographics`, inspect the selected product's `demographic_targeting` declaration first. Creative assignment placement refs only route creatives. A supported selection with no current inventory returns [`PRODUCT_UNAVAILABLE`](/docs/building/verification/compliance-catalog#error-code-product-unavailable), never silent substitution or repricing. |
-| `audience_evidence_requirements` | AudienceEvidenceRequirements | No | Package-level suitability policy. Sellers reject an unsatisfied required policy rather than dropping it, and return every snapshot used to satisfy it in the confirmed package's `audience_evidence_selections` with `decision_use: "package_construction"`. This is not a targeting instruction. The experimental sibling `target_audiences` ([`get_products`](/docs/media-buy/task-reference/get_products#target-audiences-experimental)) can restate target audiences at the package level and requires `audience_evidence_requirements`. |
+| `audience_evidence_requirements` | AudienceEvidenceRequirements | No | Package-level suitability policy. Sellers reject an unsatisfied required policy rather than dropping it, and return every snapshot used to satisfy it in the confirmed package's `audience_evidence_selections` with `decision_use: "package_construction"`. This is not a targeting instruction. |
| `audience_evidence_pins` | AudienceEvidencePin[] | No | Exact discovery snapshots the buyer requires, each pinned by `evidence_id`, `snapshot_id`, `version`, and `content_digest`. Sellers reject missing, substituted, or mutated snapshots. Every accepted pin is returned in the confirmed package's `audience_evidence_selections` and retained on package readback. |
| `start_time` | string | No | ISO 8601 date-time for this package's flight start. When omitted, inherits the media buy's `start_time`. Must fall within the media buy's date range. Does not support `"asap"`. |
| `end_time` | string | No | ISO 8601 date-time for this package's flight end. When omitted, inherits the media buy's `end_time`. Must fall within the media buy's date range. |
-| `start_requires_all_creatives_approved` | boolean | No | Added in 3.3; defaults to `false`. When `true`, book this package but hold its first release for serving until every assigned creative is approved for its applicable package scopes. Pending and rejected creatives remain assigned. See the [creative start policy](/docs/media-buy/specification#starting-with-assigned-creatives-under-review). |
-| `creative_assignments` | CreativeAssignment[] | No | Assign existing library creatives, including `pending_review` or `rejected` creatives, with optional weights and placement targeting. In 3.3, optional `landing_page_url` overrides the destination for one creative on this package when `creative.supports_assignment_landing_page: true`; see [assignment landing pages](/docs/creative/task-reference/sync_creatives#assignment-landing-pages). Optional `trackers` adds package-specific measurement when `creative.supports_assignment_trackers: true`; these fire alongside creative trackers and share their format slot limits. See [assignment trackers](/docs/creative/task-reference/sync_creatives#assignment-trackers). Every reference, route, destination, and tracker is validated as part of the atomic create; one invalid entry fails the whole request. |
+| `creative_assignments` | CreativeAssignment[] | No | Assign existing library creatives, including `pending_review` or `rejected` creatives, with optional weights and placement targeting. Every reference and route is validated as part of the atomic create; one invalid entry fails the whole request. Assignment-scoped [`landing_page_url`](/docs/creative/task-reference/sync_creatives#assignment-landing-pages) and [`trackers`](/docs/creative/task-reference/sync_creatives#assignment-trackers) are set through [`sync_creatives`](/docs/creative/task-reference/sync_creatives) `assign`, and the package's [creative start policy](/docs/media-buy/specification#starting-with-assigned-creatives-under-review) through [`control_media_buy`](/docs/media-buy/task-reference/control_media_buy); this deprecated task carries neither. |
| `creatives` | CreativeAsset[] | No | Upload new creative assets inline and assign. Requires `media_buy.features.inline_creative_management: true`; when the seller also advertises `creative.has_creative_library: true`, `creative_id` must not already exist in the library. Pre-commit validation failure blocks the entire create; later review rejection does not roll it back. |
| `catalogs` | Catalog[] | No | Catalog bodies, references, or filtered selections promoted by this package. Compact purchase accepts previously synchronized catalog IDs only, so translating an inline body requires a separate, non-atomic [`sync_catalogs`](/docs/media-buy/task-reference/sync_catalogs) mutation. |
| `context` | object | No | Opaque correlation data echoed unchanged in the package response, webhooks, and read surfaces. Use to map seller-assigned `package_id` back to your internal line items, campaign structure, or tracking state. Buyers targeting mixed seller populations SHOULD include a per-package correlation value here, commonly `context.buyer_ref`, for legacy sellers that do not echo `product_id`. |
diff --git a/docs/media-buy/task-reference/get_media_buys.mdx b/docs/media-buy/task-reference/get_media_buys.mdx
index a81222929ed..959addf2d0b 100644
--- a/docs/media-buy/task-reference/get_media_buys.mdx
+++ b/docs/media-buy/task-reference/get_media_buys.mdx
@@ -527,20 +527,21 @@ For creative changes, `sync_creatives` in `valid_actions` is a legacy creative-c
### Follow a renewal chain
-A buyer renews `mb_pinnacle_q3` by creating a new buy with `predecessor_media_buy_id` and `lineage_reason`. The seller does not touch `mb_pinnacle_q3`; the link lives only on the successor. Creating the successor:
+A buyer renews `mb_pinnacle_q3` by creating a new buy with `predecessor_media_buy_id` and `lineage_reason` on [`buy_products`](/docs/media-buy/task-reference/buy_products) (or [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal)). The seller does not touch `mb_pinnacle_q3`; the link lives only on the successor. Creating the successor:
```json
{
- "$schema": "/schemas/media-buy/create-media-buy-request.json",
+ "$schema": "/schemas/media-buy/buy-products-request.json",
"idempotency_key": "6f1d0a52-7c3e-4b1a-9e0d-2a5b8c4d7e91",
"account": { "account_id": "acct_nova_brands" },
"brand": { "domain": "novabrands.example" },
+ "feed_version": "products-2026-09-15T12:00:00Z",
"start_time": "2026-10-01T00:00:00Z",
"end_time": "2026-12-31T23:59:59Z",
"predecessor_media_buy_id": "mb_pinnacle_q3",
"lineage_reason": "renewal",
"total_budget": { "amount": 40000, "currency": "USD" },
- "packages": [
+ "purchases": [
{
"product_id": "prod_d979b543",
"pricing_option_id": "cpm_usd_auction",
diff --git a/docs/media-buy/task-reference/get_products.mdx b/docs/media-buy/task-reference/get_products.mdx
index 38ae88f69f2..2e22a265716 100644
--- a/docs/media-buy/task-reference/get_products.mdx
+++ b/docs/media-buy/task-reference/get_products.mdx
@@ -484,22 +484,7 @@ Audience evidence does not populate `targeting_overlay`, `demographic_targeting`
#### Target audiences (withdrawn)
-The experimental `filters.target_audiences[]` (`media_buy.audience_evidence_targets`, [#8215](https://github.com/adcontextprotocol/adcp/pull/8215)) shipped on `get_products` in `3.3.0-beta.1` through `3.3.0-beta.5` and is withdrawn from this deprecated task ([adcp#8501](https://github.com/adcontextprotocol/adcp/issues/8501)). Buyers do not send it. `filters` accepts unknown fields, so a seller that does not evaluate it may ignore it rather than reject it, and a hard requirement in it would go unenforced. Its home is [`list_products`](/docs/media-buy/task-reference/list_products) `offer_filters` and [`buy_products`](/docs/media-buy/task-reference/buy_products) purchases in 4.0 (registry check [purchase_target_audiences_required_in_4_0](/docs/reference/migration/v4-compatibility#purchase_target_audiences_required_in_4_0)). Until then, express the audience preference in `audience_evidence_requirements` and the brief, and check the returned `audience_evidence_selections[]` yourself.
-
-The rules below still define the experimental package-level `target_audiences` on [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) packages, which `core/audience-evidence-target.json` and the package schema point to, for as long as that copy remains in 3.3. On a package, where there is no product to exclude, a seller rejects an unsatisfied `required` target rather than dropping it.
-
-Each target has an `audience` ([`audience-characteristic`](https://adcontextprotocol.org/schemas/v3/core/audience-characteristic.json)), a `relationship` (`composition`, `index`, or `reach_estimate`, the same values as `audience_evidence[].relationship`), and a `minimum_value` in the unit the relationship implies: a fraction in [0,1] for `composition`, a ratio for `index`, an integer count for `reach_estimate`.
-
-Seller behavior is normative:
-
-- A product satisfies a target only through an *admissible* `audience_evidence` item, one that passes every other constraint in the sibling `audience_evidence_requirements` object (methodology, evidence type, provider, confidence, freshness, attestation, and so on), whose `audience` matches the target exactly, whose `relationship` equals the target's, and whose `value` is greater than or equal to `minimum_value`. An inadmissible item never satisfies a target.
-- Audience match is exact. The seller compares `dimension`, `value` or `range`, and `taxonomy` as a canonical key and never compares `label`. A range matches only the same range (an absent bound matches only an absent bound), set values compare as unordered sets, and a scalar `value` never matches a set or a range, even a one-element set or a degenerate range. `taxonomy` is compared as a whole: `system` is compared as an exact string with no normalization, and an audience that carries a `taxonomy` matches only evidence with the same `system`, `version`, and `value_id`, and never evidence without one. Evidence for a superset or subset of the target range (for example 18–54 against a 25–54 target) does not satisfy it in 3.3, and the seller does not infer or adjust a share across ranges.
-- Several targets combine with AND. A product satisfies the field only when every target is satisfied. Different targets MAY be satisfied by different evidence items.
-- `requirement_mode: "required"` excludes a product that publishes evidence but does not satisfy every target. With `evidence_presence: "required"`, a product that publishes no evidence is excluded as before. With `when_available`, a product that publishes no evidence at all remains eligible, and a product that publishes evidence must satisfy every target.
-- `requirement_mode: "preferred"` never excludes. Count, per product, the targets satisfied by an admissible evidence item. A product with a higher count MUST NOT rank below a product with a lower count when the seller's other ranking inputs are equal. How the seller weighs the count against its other inputs is not defined. The seller returns every satisfying snapshot in `audience_evidence_selections[]` with `decision_use: "recommendation"` (or `"eligibility"` when the targets also decided inclusion), even if `fields` omitted that field. 3.3 does not report which target each snapshot satisfied.
-- A target is never a targeting predicate, impression-level proof, or legal-age verification. Hard delivery stays in `targeting_overlay.demographics`.
-- A seller that does not list `media_buy.audience_evidence_targets` in `experimental_features` MUST NOT apply `target_audiences`. With `requirement_mode: "required"` it MUST fail with [`UNSUPPORTED_FEATURE`](/docs/building/verification/compliance-catalog#error-code-unsupported-feature) rather than ignore the hard requirement. With `preferred` it MAY ignore the field but MUST NOT report it as applied. A 3.2 seller that validates strictly may instead return a validation error, so buyers check `experimental_features` before sending the field.
-- A required target a product misses is an exclusion, not an error. A seller MAY report it through `filter_diagnostics.excluded_by`. `index` targets carry no baseline in 3.3, so an index threshold is only comparable between sellers that publish the same baseline. Buyers that need to know why a product dropped out, or which targets a preferred result met, re-check the returned `audience_evidence_selections[]` against their own targets using the exact-match rule above.
+The experimental `filters.target_audiences[]` (`media_buy.audience_evidence_targets`, [#8215](https://github.com/adcontextprotocol/adcp/pull/8215)) shipped on `get_products` in `3.3.0-beta.1` through `3.3.0-beta.5` and is withdrawn from this deprecated task ([adcp#8501](https://github.com/adcontextprotocol/adcp/issues/8501)). Its package copy on `create_media_buy` and [`update_media_buy`](/docs/media-buy/task-reference/update_media_buy) is withdrawn too, so it has no 3.3 wire form and sellers do not list the feature id. Buyers do not send it. `filters` accepts unknown fields, so a seller may ignore it rather than reject it, and a hard requirement in it would go unenforced. Its home is [`list_products`](/docs/media-buy/task-reference/list_products) `offer_filters` and [`buy_products`](/docs/media-buy/task-reference/buy_products) purchases in 4.0, and its matching rules are kept with that check ([purchase_target_audiences_required_in_4_0](/docs/reference/migration/v4-compatibility#purchase_target_audiences_required_in_4_0)). Until then, express the audience preference in `audience_evidence_requirements` and the brief, and check the returned `audience_evidence_selections[]` yourself.
Known place IDs belong in `targeting_overlay`, so the returned product's
availability, pricing, and forecast are scoped to the effective place
diff --git a/docs/media-buy/task-reference/update_media_buy.mdx b/docs/media-buy/task-reference/update_media_buy.mdx
index 2b7a56277d7..a306fa404f4 100644
--- a/docs/media-buy/task-reference/update_media_buy.mdx
+++ b/docs/media-buy/task-reference/update_media_buy.mdx
@@ -82,7 +82,7 @@ The mapping is normative — sellers and SDKs MUST use this table to translate b
| `update_media_buy_frequency_cap` | `frequency_cap` | Replace or clear the shared root cap. Changes do not reset its counter. |
| `replace_creative` | `packages[].creatives[]` swap (assignments unchanged) | Distinct AM workflow from changing assignment set |
| `update_creative_assignments` | `packages[].creative_assignments` | Which creatives go where |
-| `update_creative_start_policy` | `packages[].start_requires_all_creatives_approved` | AdCP 3.3 structured-only operational control before package enablement. Uses [`control_media_buy`](/docs/media-buy/task-reference/control_media_buy) on the compact lifecycle; separate from creative assignment mutation. |
+| `update_creative_start_policy` | `packages[].start_requires_all_creatives_approved` | AdCP 3.3 structured-only operational control before package enablement. Routed to [`control_media_buy`](/docs/media-buy/task-reference/control_media_buy); `update_media_buy` does not carry this field. Separate from creative assignment mutation. |
| `remove_creative` | Creative omitted from `packages[].creatives[]` and/or `packages[].creative_assignments` on a replacement payload | Both arrays use replacement semantics, so removal is expressed by sending the desired post-state with the creative absent (no explicit delete primitive in 3.x). Time-sensitive; sellers SHOULD support self_serve removal even when add/swap require approval |
| `add_packages` | `new_packages[]` | |
| `remove_packages` | `packages[].canceled: true` | |
@@ -335,7 +335,6 @@ Configure automated delivery reporting for this media buy:
|-----------|------|-------------|
| `package_id` | string | Seller's package identifier to update |
| `paused` | boolean | Pause/resume specific package (`true` = paused, `false` = active) |
-| `start_requires_all_creatives_approved` | boolean | Added in 3.3. Change the package's initial-start gate before first release for serving. Omission preserves it; `false` releases it. Changes after first release for serving fail with [`INVALID_STATE`](/docs/building/verification/compliance-catalog#error-code-invalid-state). See the [creative start policy](/docs/media-buy/specification#starting-with-assigned-creatives-under-review). |
| `canceled` | boolean | Cancel this package (irreversible). Must be `true` when present. Seller may reject with `NOT_CANCELLABLE`. |
| `cancellation_reason` | string | Reason for canceling this package |
| `budget` | number \| null | Updated hard package spend cap in the media-buy currency. In seller-optimized mode this is not a current allocation; a number requires `media_buy.features.seller_optimized_package_budgets` and `null` removes the cap. A resulting fixed allocation still requires a budget on every package. |
@@ -354,7 +353,7 @@ Configure automated delivery reporting for this media buy:
| `keyword_targets_remove` | KeywordTarget[] | Keyword targets to remove by (keyword, match_type) identity. |
| `negative_keywords_add` | NegativeKeyword[] | Negative keywords to append to this package. On create, these are set as `negative_keywords` inside `targeting_overlay`. |
| `negative_keywords_remove` | NegativeKeyword[] | Negative keywords to remove from this package. |
-| `creative_assignments` | CreativeAssignment[] | Replace assigned library creatives, including `pending_review` or `rejected` creatives, with optional weights and placement routing. In 3.3, optional `landing_page_url` overrides one creative's destination on this package when `creative.supports_assignment_landing_page: true`; omission from a supplied replacement row restores the creative's destination. See [assignment landing pages](/docs/creative/task-reference/sync_creatives#assignment-landing-pages). Optional `trackers` replaces assignment-specific measurement when `creative.supports_assignment_trackers: true`; omission from a supplied replacement row clears those trackers. They fire alongside the creative's trackers and share format slot limits. See [assignment trackers](/docs/creative/task-reference/sync_creatives#assignment-trackers). When placement selection narrows in the same update, remove or reroute every reference that would be orphaned. |
+| `creative_assignments` | CreativeAssignment[] | Replace assigned library creatives, including `pending_review` or `rejected` creatives, with optional weights and placement routing. When placement selection narrows in the same update, remove or reroute every reference that would be orphaned. A replacement row carries no assignment-scoped [`landing_page_url`](/docs/creative/task-reference/sync_creatives#assignment-landing-pages) or [`trackers`](/docs/creative/task-reference/sync_creatives#assignment-trackers), so replacing assignments here clears any set through `sync_creatives` `assign`; change assignments on such a package with `sync_creatives` `assignment_operations` instead. The package's [creative start policy](/docs/media-buy/specification#starting-with-assigned-creatives-under-review) is set through [`control_media_buy`](/docs/media-buy/task-reference/control_media_buy). |
| `creatives` | CreativeAsset[] | Replace assigned inline creative bodies for this package. Requires `media_buy.features.inline_creative_management: true`; when the seller also advertises `creative.has_creative_library: true`, new `creative_id` values must not already exist in the library. |
`package_id` is required to identify the package to update.
diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx
index 5a4ce54bcd9..28e60c80d02 100644
--- a/docs/protocol/get_adcp_capabilities.mdx
+++ b/docs/protocol/get_adcp_capabilities.mdx
@@ -1096,7 +1096,7 @@ Optional media-buy features. Boolean declarations are commitments when true. Str
| Feature | Description |
|---------|-------------|
-| `creative_start_policy` | Added in 3.3. Honors the package `start_requires_all_creatives_approved` initial-serving gate through compact package controls and any advertised compatibility facades. Check before booking; `false` or absent means opt-in is unsupported. See the [creative start policy](/docs/media-buy/specification#starting-with-assigned-creatives-under-review). |
+| `creative_start_policy` | Added in 3.3. Honors the package `start_requires_all_creatives_approved` initial-serving gate through `control_media_buy` package controls. Check before booking; `false` or absent means opt-in is unsupported. See the [creative start policy](/docs/media-buy/specification#starting-with-assigned-creatives-under-review). |
| `inline_creative_management` | Deprecated 3.x compatibility capability for inline creatives in `create_media_buy` and `update_media_buy`; compact lifecycle tools never accept them |
| `property_list_filtering` | Honors `property_list` parameter in `get_products` |
| `catalog_management` | Supports [`sync_catalogs`](/docs/media-buy/task-reference/sync_catalogs) for catalog feed management |
@@ -1563,9 +1563,9 @@ Creative protocol capabilities. Only present if `creative` is in `supported_prot
| `supports_provenance` | boolean | Added in 3.3; defaults to `false`. Opts in to product provenance policy enforcement and trusted-verifier handling on `sync_creatives`. A creative library alone does not imply support. Sellers that omit this flag or set it to `false` skip the provenance storyboards. Any provenance policy a seller publishes still applies. |
| `supports_registry_policy_enforcement` | boolean | Added in 3.3; defaults to `false`. Opts in to [policy-backed creative rejections](/docs/governance/creative/policy-backed-rejections): the seller publishes the registry policy IDs it enforces in product `enforced_policies` and, on `sync_creatives`, emits one [`CREATIVE_REJECTED`](/docs/building/verification/compliance-catalog#error-code-creative-rejected) entry per violated policy with `details.policy_id`. A creative library alone does not imply support. Sellers that omit this flag or set it to `false` skip the policy-backed rejection storyboard. Any policy a seller publishes in `enforced_policies` still applies. |
| `stable_creative_ids` | boolean | When `true`, every creative that entered the library outside [`sync_creatives`](/docs/creative/task-reference/sync_creatives#seller-assigned-creative-ids) gets a seller-assigned `creative_id` that stays the same for the creative's lifetime, is unique within the account, and never reuses a buyer's ID, and `delete_missing` never archives those creatives. Lets a buyer that connects to an advertiser with existing creatives reference them reliably. Requires `has_creative_library: true`. When `false` or absent, sellers SHOULD follow the same rules but buyers MUST NOT rely on them. |
-| `supports_assignment_trackers` | boolean | Added in 3.3; defaults to `false`. When `true`, accepts assignment-scoped `trackers` in create/update media buy and sync assignment requests, fires them alongside the creative's own trackers or rejects the assignment, enforces shared format tracker-slot limits where the format declares tracker slots, and returns the assignment tracker list in 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). Independent of landing-page override support. See [assignment trackers](/docs/creative/task-reference/sync_creatives#assignment-trackers). |
+| `supports_assignment_trackers` | boolean | Added in 3.3; defaults to `false`. When `true`, accepts assignment-scoped `trackers` in `sync_creatives` assignment requests, fires them alongside the creative's own trackers or rejects the assignment, enforces shared format tracker-slot limits where the format declares tracker slots, and returns the assignment tracker list in 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). Independent of landing-page override support. See [assignment trackers](/docs/creative/task-reference/sync_creatives#assignment-trackers). |
| `supports_revisions` | boolean | When `true`, the library accepts buyer-authored immutable `revision_id` values and preserves them through sync echo, current readback, review attribution, and revision-homogeneous delivery rows. Requires `has_creative_library: true`. Does not imply revision history, rollback, or staged activation. |
-| `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). |
+| `supports_assignment_landing_page` | boolean | Added in 3.3; defaults to `false`. When `true`, accepts assignment-scoped `landing_page_url` in `sync_creatives` 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. |
| `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). |
diff --git a/docs/reference/experimental-status.mdx b/docs/reference/experimental-status.mdx
index cea278c5fa5..c575d16f4a1 100644
--- a/docs/reference/experimental-status.mdx
+++ b/docs/reference/experimental-status.mdx
@@ -138,8 +138,8 @@ Schemas marked with `x-status: experimental` are the authoritative source. The c
| `creative.broadcast_loudness` | The optional `loudness` object (`target_lufs`, `tolerance_db`, `max_true_peak_dbfs`, `standard`) on the `video_hosted` canonical format params (schema: `formats/canonical/video_hosted.json`), documented in [Broadcast creative](/docs/creative/channels/broadcast#loudness-experimental) (#8011) | New broadcast-television delivery requirement with very few adopters so far. The `standard` enum (`ebu_r128`, `atsc_a85`, `custom`) is expected to grow, and the object shape may be reconciled with the flat `loudness_*` fields on `audio_hosted` in a later 3.x release. The `mxf` container and the `mpeg2` / `avc_intra` / `xavc` codec values added alongside it are stable additive enum values, not part of this experimental surface. |
| `creative.live_updates` | The `creative.live_updates` capability block on [`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities#creative), `staged_update` on [`list_creatives`](/docs/creative/task-reference/list_creatives#staged-update-readback) and on [`sync_creatives`](/docs/creative/task-reference/sync_creatives#live-creative-updates) results, the `creative.staged_update_changed` notification type, the `CREATIVE_LIVE_UPDATE_UNSUPPORTED` error code, and the schemas `core/live-update-policy.json`, `core/creative-staged-update.json`, `creative/creative-staged-update-changed-webhook.json`, `enums/creative-change-kind.json`, `enums/live-update-serving-mode.json`, and `error-details/creative-live-update-unsupported.json` ([#8404](https://github.com/adcontextprotocol/adcp/issues/8404), folds in staged activation from [#6780](https://github.com/adcontextprotocol/adcp/issues/6780)) | New mutation contract for creatives that are serving, with one reference implementation (the training agent) and no production sellers yet. The change-kind taxonomy, the serving-mode ranking, the declaration-as-floor model, and per-package review during a staged update need field evidence from ad servers with different trafficking models before they freeze. The `comply_test_controller` behaviour that `force_creative_status` resolves a pending staged update rides on this feature id. |
| `media_buy.daypart_granularity` | The `start_time` / `end_time` clock-time fields on `core/daypart-target.json` (and so on every `daypart_targets` entry), the `time_granularity` field in `overlay_support.daypart_targets` and `required_overlay_support.daypart_targets`, and the `enums/daypart-time-granularity.json` enum ([#6707](https://github.com/adcontextprotocol/adcp/issues/6707)). See [Minute-resolution dayparts](/docs/media-buy/advanced-topics/targeting#minute-resolution-dayparts-experimental). | Minute-level schedules for linear TV (programme-junction breaks, overnight broadcast days) arrive with one named adopter and are expected to be reshaped by the network/station targeting work in [#5679](https://github.com/adcontextprotocol/adcp/issues/5679). The `HH:MM` representation, the overnight rule (a window belongs to its start day), the three-value granularity ladder, and the error code for an unsupported granularity (today `UNSUPPORTED_FEATURE`, which may be superseded by the targeting-dimension codes in [#6978](https://github.com/adcontextprotocol/adcp/issues/6978)) may change before they freeze. The whole-hour `start_hour` / `end_hour` fields stay stable, although they are no longer individually schema-required. |
-| `media_buy.measurement_provenance` | The `billing_measurement.counting_mechanisms[]` declaration on `core/measurement-terms.json`, the optional `vendor_metric_provenance[]` array (`core/vendor-metric-provenance.json`: `vendor`, `metric_id`, `qualifier?`, `mechanisms[]`, `deduplicated?`) beside `vendor_metric_values` on `core/delivery-metrics.json`, and the `enums/collection-mechanism.json` enum (#6878, #2041; see [Declaring how the billing count is built](/docs/media-buy/advanced-topics/billing-authority#declaring-how-the-billing-count-is-built-experimental) and [Collection-mechanism provenance on vendor values](/docs/media-buy/media-buys/optimization-reporting#collection-mechanism-provenance-on-vendor-values-experimental)) | New cross-party disclosure of what a count is built from, with no production adopter yet. The enum is closed and released values cannot be removed on a stable surface, so the vocabulary (for example whether `client_tracker` and `sdk` stay distinct, or whether a sensor value is needed for place-based media) is held experimental until two parties have used it. The `deduplicated` boolean may become a level enum, and the `impression_id` joinability and `seller_tracker_events[]` follow-ups may reshape the declaration. `measurement_source` on delivery metrics (the provider slug) is stable and unrelated. The closed `canonical-measurement-terms.json` and `vendor-metric-value.json` row do not carry these fields in 3.x (DR-0036): provenance is a sibling array joined on the row key, and `counting_mechanisms` reaches the canonical compact-lifecycle terms in 4.0 ([4.0 compatibility check](/docs/reference/migration/v4-compatibility#canonical_counting_mechanisms_required_in_4_0)). |
-| `media_buy.lineage` | Forward-only buy-level lineage: the `predecessor_media_buy_id` and `lineage_reason` fields on [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy), [`buy_products`](/docs/media-buy/task-reference/buy_products), and [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal) requests and on [`get_media_buys`](/docs/media-buy/task-reference/get_media_buys) items (also `core/media-buy.json`), the `predecessor_media_buy_id` filter on `get_media_buys`, and the `enums/media-buy-lineage-reason.json` enum (#5849; see [Media Buy Lineage](/docs/media-buy/specification#media-buy-lineage)) | One production sales agent has run equivalent lineage operations for months, and IAB AAMP seller-agent 2.0 (`parent_deal_id`) is prior art for the same idea in deal-scoped form, not convergence evidence. The `lineage_reason` vocabulary is closed and may grow, and the `UNSUPPORTED_FEATURE` rejection and the same-account scope may change as more sellers implement it. |
+| `media_buy.measurement_provenance` | The optional `vendor_metric_provenance[]` array (`core/vendor-metric-provenance.json`: `vendor`, `metric_id`, `qualifier?`, `mechanisms[]`, `deduplicated?`) beside `vendor_metric_values` on `core/delivery-metrics.json`, and the `enums/collection-mechanism.json` enum (#6878, #2041; see [Collection-mechanism provenance on vendor values](/docs/media-buy/media-buys/optimization-reporting#collection-mechanism-provenance-on-vendor-values-experimental)) | New cross-party disclosure of what a count is built from, with no production adopter yet. The enum is closed and released values cannot be removed on a stable surface, so the vocabulary (for example whether `client_tracker` and `sdk` stay distinct, or whether a sensor value is needed for place-based media) is held experimental until two parties have used it. The `deduplicated` boolean may become a level enum, and the `impression_id` joinability and `seller_tracker_events[]` follow-ups may reshape the declaration. `measurement_source` on delivery metrics (the provider slug) is stable and unrelated. `canonical-measurement-terms.json`, held closed with the commercial terms until 4.0 (DR-0036), does not carry these fields in 3.x, and the `vendor-metric-value.json` row does not define them: provenance is a sibling array joined on the row key. The `billing_measurement.counting_mechanisms[]` declaration shipped in the 3.3 betas on `core/measurement-terms.json`, which only the deprecated `get_products`, `create_media_buy`, and `update_media_buy` use, and is withdrawn; it arrives on the canonical compact-lifecycle terms in 4.0 ([4.0 compatibility check](/docs/reference/migration/v4-compatibility#canonical_counting_mechanisms_required_in_4_0)). |
+| `media_buy.lineage` | Forward-only buy-level lineage: the `predecessor_media_buy_id` and `lineage_reason` fields on [`buy_products`](/docs/media-buy/task-reference/buy_products) and [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal) requests and on [`get_media_buys`](/docs/media-buy/task-reference/get_media_buys) items (also `core/media-buy.json`), the `predecessor_media_buy_id` filter on `get_media_buys`, and the `enums/media-buy-lineage-reason.json` enum (#5849; see [Media Buy Lineage](/docs/media-buy/specification#media-buy-lineage)) | One production sales agent has run equivalent lineage operations for months, and IAB AAMP seller-agent 2.0 (`parent_deal_id`) is prior art for the same idea in deal-scoped form, not convergence evidence. The `lineage_reason` vocabulary is closed and may grow, and the `UNSUPPORTED_FEATURE` rejection and the same-account scope may change as more sellers implement it. |
| `media_buy.delegated_composition` | `criteria.product_ids_exclusive` on [`request_proposals`](/docs/media-buy/task-reference/request_proposals#binding-the-product-universe-with-product_ids_exclusive) and proposal refinement, the `media_buy.supports_exclusive_product_ids` capability, and `media_buy.proposal_refinement.hold_semantics` (`terms_only`) with its `committed`, `expires_at`, and `accept_proposal` semantics, described in the [delegated composition profile](/docs/media-buy/product-discovery/delegated-composition) | Zero validated adopters so far, and `terms_only` reinterprets what `committed` means for a capability-gated seller. The acceptance-only `accept_proposal` variant, the governance trigger when the MediaBuy is created on the requester, and a reference-based product universe are still open and may reshape these fields. A `terms_only` seller must not return `committed` proposals to callers below 3.3. |
| `media_buy.hold_policy` | The `hold_policy` object (schema: `core/hold-policy.json`: `available`, `min_duration`, `max_duration`, `extendable`, `max_total_duration`) as a planning hint on products (`core/product.json`, `core/canonical-product.json`) and as the binding policy on canonical proposals (`core/canonical-proposal.json`), the committed proposal's `hold_placed_at`, the per-entry resolution of non-holdable drafts to terms-only finalize, and its role overriding the agent-level `hold_semantics` default, described in [Hold policy](/docs/media-buy/task-reference/refine_proposals#hold-policy-experimental) ([#7661](https://github.com/adcontextprotocol/adcp/issues/7661)) | One named adopter so far: Availnet, a creator marketplace, where each creator's upcoming-video slot can be held while the creator confirms. `rankable` belongs to `media_buy.hold_ranking`, per-installment readback to `media_buy.installment_holds`, and `extendable` and `max_total_duration` bound `extend_hold` (`media_buy.hold_extension`). Reuses the `terms_only` semantics of `media_buy.delegated_composition` without requiring that feature id. Both fields are omitted for callers below 3.3. The `comply_test_controller` scenario `force_hold_unavailable` ([compliance test controller](/docs/building/by-layer/L3/comply-test-controller#force_hold_unavailable)) and its `forced.arm: "unable"` response echo ride on this feature id and carry no feature id of their own. |
| `media_buy.hold_ranking` | `hold_policy.rankable` (`core/hold-policy.json`) and the committed proposal's `hold_rank` (`core/canonical-proposal.json`): advisory readback of the buyer's own position in a seller's queue of holds, described in [Hold ranking](/docs/media-buy/task-reference/refine_proposals#hold-ranking-experimental) ([#8086](https://github.com/adcontextprotocol/adcp/issues/8086)) | One named adopter so far: Availnet, a creator marketplace. Ranking is advisory only: no right-of-first-refusal deadline, no rank-change webhook, and no read of a committed proposal, so `hold_rank` is a snapshot that a buyer refreshes through an `extend_hold` successor. Whether buyers need a fresher readback, and whether sellers will report exact queue depth, needs field experience. `available: true` without `rankable: true` keeps its exclusive, first-position meaning. Requires `media_buy.hold_policy`; omitted for callers below 3.3, for whom the seller treats `rankable` as `false`. |
@@ -152,7 +152,6 @@ Schemas marked with `x-status: experimental` are the authoritative source. The c
| `media_buy.collection_lists` | The `collection_lists` and `collection_lists_exclude` fields on `targeting.json` and `targeting-input.json`, and the matching `overlay_support` / `required_overlay_support` keys with `max_references` ([#7834](https://github.com/adcontextprotocol/adcp/issues/7834)). See [Combining several collection lists](/docs/governance/collection/index#combining-several-collection-lists-experimental). | One buyer-side adopter so far, and the WG has not ratified the intersection/union semantics or the `max_references` shape. The singular `collection_list` fields are stable and unchanged. |
| `signing.web_bot_auth` | The [Web Bot Auth request-signing profile](/docs/building/by-layer/L1/wba-profile): the `web-bot-auth` value in `request_signing.profiles` and `webhook_signing.profiles` on [`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities), and `signing_profile` on every webhook registration, adopted by DR-0023 | The profile follows an IETF draft that is still changing. The draft revision it pins and the `Signature-Agent` form may change before graduation. |
| `media_buy.offline_delivery_metrics` | The `ooh_metrics` delivery block on `core/delivery-metrics.json` (panels, posting periods, posting records, share of voice, modeled `estimated_impressions` with `estimation_basis`; added in 3.2 and documented in [Out-of-home](/docs/creative/channels/ooh)), and the `print_metrics` delivery block (`page_delivered`, `position_delivered`, `circulation_delivered`, `circulation_basis`, `evidence`; added in 3.3 and documented in [Print creative](/docs/creative/channels/print#delivery-evidence-and-circulation)). Both carry `x-status: experimental`, both are addressable as `ooh_metrics` / `print_metrics` in the `available-metric` enum, and both reuse the channel-neutral `core/placement-evidence.json` artifact. | Delivery reporting for channels with no play or impression event — static OOH posting records and print proof-of-insertion with circulation — rests on one named adopter per channel and on contract conventions (OAAA proof of performance, tearsheets, circulation audits) that have not been field-tested across parties. `ooh_metrics` shipped in 3.2 without a registry row; this row registers it with `print_metrics` as a clarification, so a seller on 3.3 that reports either block declares the id. A seller on a 3.2 schema that already reports `ooh_metrics` is not made non-conformant retroactively. Field shapes, the `estimation_basis` / `circulation_basis` tier vocabularies, and the shared evidence artifact may change before they freeze. |
-| `media_buy.audience_evidence_targets` | The `target_audiences[]` field, a sibling of `audience_evidence_requirements` on `media-buy/package-request.json` (`create_media_buy` packages; the 3.3 betas also carried it on `get_products` `filters`, withdrawn by #8501), and its item schema `core/audience-evidence-target.json` (`audience`, `relationship`, `minimum_value`) ([RFC #8125](https://github.com/adcontextprotocol/adcp/issues/8125)). See [audience evidence requirements](/docs/media-buy/task-reference/get_products#audience-evidence-requirements). | Structured soft and hard audience preference over `audience_evidence`, with a single adopter so far and no field-testing across parties yet. The exact-match audience rule (no superset, subset, or adjusted-share matching), AND across targets, the lack of a baseline on `index` targets, the `minimum_value` units, the `maxItems` bound, and whether per-target satisfaction is reported in `audience_evidence_selections` may change. The field is a sibling rather than a member of `audience_evidence_requirements` because the two constrain different things: which audience, and the quality of the evidence. The `buy_products` purchase input (`media-buy/product-purchase-input.json`) and the `list_products` `offer_filters` (`core/product-offer-filters.json`) are open in 3.3 but do not define it. It is defined there in 4.0 (see the [4.0 compatibility check](/docs/reference/migration/v4-compatibility#purchase_target_audiences_required_in_4_0)), together with the echo in the accepted purchase snapshot, `media-buy/product-purchase.json`, which is held closed with the commercial terms until 4.0. The `enums/audience-evidence-relationship.json` file carries the same values as the stable 3.2 `audience_evidence.relationship` enum, which stays inline and unchanged. Sellers that evaluate `target_audiences` declare the id; a seller that does not declare it MUST fail a `required` request with `UNSUPPORTED_FEATURE` rather than ignore it. |
| `account.invoice_lines` | The optional `lines[]`, `invoice_kind` (`enums/invoice-kind.json`), and `adjusts_invoice_id` on `invoices[]` items of [`get_account_financials`](/docs/accounts/tasks/get_account_financials), and the `core/invoice-line.json` shape ([#8184](https://github.com/adcontextprotocol/adcp/issues/8184)) | Ties each invoice amount to the media buys, packages, official reporting revisions, and reporting adjustments it bills, so a buyer can reconcile what it was charged for against what was finalized. Reference only: it never creates, approves, or settles invoices. Single-adopter so far, and the ids it references come from `media_buy.reporting_delivery`. May change: itemised `lines[]` keyed to `reporting_revision_ids`, credit notes as `invoice_kind: credit_note` with a non-negative `amount` rather than a negative amount. The line shape is reusable so a later account-change-feed invoice notification can carry `lines` unchanged. |
| `signals.pricing_groups` | The optional `pricing_group` field on the shared signal-listing fields (`get_signals` results and `Product.signal_targeting_options[]`), and `signal_pricing_group_rules[]` (schema: `core/signal-pricing-group-rule.json`) on `signal_targeting_rules` and the `get_signals` response (#7898) | Defines how fees of signals sharing a pricing group combine, a new commercial rule backed so far by one adopter (one adopter applies `highest` today as a platform default). Decided 2026-10-24 for 3.3 beta.1 ([#7898](https://github.com/adcontextprotocol/adcp/issues/7898)): a group with no rule is charged `each`, and `percent_of_media` is always `each` (no conversion rule). The `signal_charges[]` echo of per-signal charges on package state is a follow-up, so buyers reconcile against the published rules for now. |
diff --git a/docs/reference/migration/v4-compatibility.mdx b/docs/reference/migration/v4-compatibility.mdx
index 81182ff8741..9d0f49f12f9 100644
--- a/docs/reference/migration/v4-compatibility.mdx
+++ b/docs/reference/migration/v4-compatibility.mdx
@@ -127,17 +127,6 @@ No storyboard step inspects every annotated field, so the suite reports this che
**4.0 follow-up:** the structural bounds are the protocol's limit on the capabilities document. `MAX_CAPABILITIES_BYTES` (64 KiB) stays only as a receiver backstop against runaway or hostile documents, and 4.0 does not lean on it. With the current bounds, the largest schema-valid document is 87 MiB. The way to lower that ceiling is to tighten the nested object lists that multiply it before the annotations become keywords: the brand reference (`brand_kit_override`, `provenance`), the creative format declaration (`macro_resolution_capabilities`), the attestation issuer entry, the audience activation method, and the geographic place system.
-
-### Purchases carry target audiences
-
-**Check id:** `purchase_target_audiences_required_in_4_0` · **Change:** new requirement
-
-**Detects:** a seller that lists `media_buy.audience_evidence_targets` in `experimental_features` does not accept `target_audiences` on `buy_products` purchases and `list_products` `offer_filters`.
-
-In 3.3 the experimental `target_audiences[]` is a sibling of `audience_evidence_requirements` on `create_media_buy` packages. It was also on `get_products` filters through `3.3.0-beta.5` and is withdrawn from that deprecated task ([adcp#8501](https://github.com/adcontextprotocol/adcp/issues/8501)). A purchase records its terms in `product-purchase.json`, which is held closed with the commercial terms until 4.0 ([DR-0036](https://github.com/adcontextprotocol/adcp/blob/main/governance/decisions/DR-0036-closed-schemas-stay-closed-within-a-major.md)), so `buy_products` purchases gain the field in 4.0, and `list_products` `offer_filters` gains it with them.
-
-**Fix:** accept the sibling on those surfaces and record it in the accepted purchase snapshot. Until 4.0, a buyer that needs an audit record keeps its own copy of the requirements and targets it sent. A 3.x run cannot observe this check, so it reports as not observable.
-
## Planned for 4.0: withdrawn from 3.3
The three checks below record 3.3 features that are withdrawn and planned for 4.0. All three add terms to `commercial_terms`. The `commercial-terms` schema is held closed until 4.0 ([DR-0036](https://github.com/adcontextprotocol/adcp/blob/main/governance/decisions/DR-0036-closed-schemas-stay-closed-within-a-major.md)), so these features wait for 4.0, where `commercial_terms` gets a declared extension point with must-understand. The decision and its reasoning are in [adcp#8501](https://github.com/adcontextprotocol/adcp/issues/8501). The [3.3 migration guide](/docs/reference/migration/3-2-to-3-3#withdrawn-from-3-3-planned-for-4-0) says what changed between `3.3.0-beta.0` and `3.3.0-beta.1`.
@@ -210,16 +199,49 @@ In 3.3 a seller-initiated change to a signed buy is always a [seller offer](/doc
**Fix:** until 4.0, send a seller offer for every seller-initiated change to a signed buy. A 3.x run cannot observe this check, so it reports as not observable.
+
+### Purchases carry target audiences
+
+**Check id:** `purchase_target_audiences_required_in_4_0` · **Change:** new requirement · **Issue:** [#8125](https://github.com/adcontextprotocol/adcp/issues/8125)
+
+**Detects:** a seller does not accept `target_audiences` beside `audience_evidence_requirements` on `buy_products` purchases and `list_products` `offer_filters`, or does not record it in the accepted purchase snapshot.
+
+**What it was:** experimental `media_buy.audience_evidence_targets` ([#8215](https://github.com/adcontextprotocol/adcp/pull/8215), from `3.3.0-beta.1`), a `target_audiences[]` sibling of `audience_evidence_requirements` that names the audience a buyer wants evidence for. It shipped only on the deprecated `get_products` filters and `create_media_buy` and `update_media_buy` packages, and is withdrawn from both, because 3.3 adds nothing to deprecated tasks ([adcp#8501](https://github.com/adcontextprotocol/adcp/issues/8501)). No 3.3 wire form remains, and the feature id is not registered. The `buy_products` purchase input (`product-purchase-input.json`) and the `list_products` `offer_filters` are open in 3.3 but do not define it, and the accepted purchase snapshot (`product-purchase.json`) is held closed with the commercial terms until 4.0, so all three gain the field in 4.0.
+
+**Design to keep:**
+
+- Each target has an `audience` (`audience-characteristic`), a `relationship` (`composition`, `index`, or `reach_estimate`, the values of `audience_evidence[].relationship`), and a `minimum_value` in the unit the relationship implies: a fraction in [0,1] for `composition`, a ratio for `index`, an integer count for `reach_estimate`. The list held 1 to 10 unique targets and required the sibling `audience_evidence_requirements`.
+- A product satisfies a target only through an admissible `audience_evidence` item: one that passes every other constraint in the sibling requirements, whose `audience` matches the target exactly, whose `relationship` equals the target's, and whose `value` is at least `minimum_value`.
+- Audience match is exact. `dimension`, `value` or `range`, and `taxonomy` form a canonical key, and `label` is never compared. A range matches only the same range, set values compare as unordered sets, a scalar never matches a set or a range, and `taxonomy` (`system`, `version`, `value_id`) is compared as a whole. There is no superset, subset, or adjusted-share matching.
+- Several targets combine with AND, and different evidence items may satisfy different targets.
+- `requirement_mode: "required"` excludes a product that publishes evidence but misses a target, and `evidence_presence` decides a product that publishes none. `preferred` never excludes: a product that satisfies more targets never ranks below one that satisfies fewer when the seller's other inputs are equal, and every satisfying snapshot is returned in `audience_evidence_selections[]`. On a purchase, where there is no product to exclude, an unsatisfied required target is rejected rather than dropped.
+- A target is never a targeting predicate, impression-level proof, or legal-age verification. `index` targets carry no baseline.
+
+**Where it is preserved:** commit `d9b5417bc` (#8215) carries `core/audience-evidence-target.json`, `enums/audience-evidence-relationship.json`, the normative text in `docs/media-buy/task-reference/get_products.mdx#target-audiences-experimental`, and `tests/audience-evidence-targets.test.cjs`, whose semantics block is a reference model of these rules with worked cases. The released `3.3.0-beta.1` through `3.3.0-beta.5` bundles under `dist/` keep the schemas.
+
+**What 4.0 needs:** the field on the purchase and offer-filter schemas, its echo in the accepted purchase snapshot, and a decision on whether per-target satisfaction is reported. Until then, a buyer that needs an audit record keeps its own copy of the requirements and targets it sent. A 3.x run cannot observe this check, so it reports as not observable.
+
### Canonical terms carry counting mechanisms
**Check id:** `canonical_counting_mechanisms_required_in_4_0` · **Change:** new requirement
-**Detects:** a seller that lists `media_buy.measurement_provenance` in `experimental_features` declares `counting_mechanisms` on `measurement_terms.billing_measurement` but not on the canonical commercial terms.
+**Detects:** a seller that lists `media_buy.measurement_provenance` in `experimental_features` cannot declare `counting_mechanisms` on the canonical commercial terms' `billing_measurement`.
+
+**What it was:** the experimental `billing_measurement.counting_mechanisms[]` on `core/measurement-terms.json` ([#7940](https://github.com/adcontextprotocol/adcp/pull/7940), published from `3.3.0-beta.2`). That schema is used only by the deprecated `get_products`, `create_media_buy`, and `update_media_buy` tasks, so the field is withdrawn from 3.3: 3.3 adds nothing to deprecated tasks ([adcp#8501](https://github.com/adcontextprotocol/adcp/issues/8501)). `core/canonical-measurement-terms.json` holds terms the buyer is bound by and is held closed until 4.0, so the declaration waits for 4.0.
+
+**What stays in 3.3:** `enums/collection-mechanism.json` and `vendor_metric_provenance[].mechanisms[]` on delivery rows, under the same `media_buy.measurement_provenance` feature id.
+
+**Design to keep:**
+
+- `vendor` says who counts the billing metric; `counting_mechanisms` says what it is counted from, as a set of `collection-mechanism` values (`minItems: 1`, unique, order carries no meaning). It is not `measurement_source`, which names the provider.
+- Only mechanisms whose events enter the billed count are listed. Cross-check mechanisms are not, and the merge rule for several mechanisms is in the seller's methodology.
+- It is a claim enforced through the contract (`vendor`, `max_variance_percent`, `makegood_policy`), not a conformance probe. Absence means undeclared, never a default source.
+- On a product it is the seller's default; on a package request, the buyer's proposed term (the seller may accept, adjust, or reject with `TERMS_REJECTED`); on a confirmed package, the agreed term. A seller, or its ad server, that is itself the named `vendor` should declare it.
-In 3.3 the experimental `billing_measurement.counting_mechanisms[]` is carried on `core/measurement-terms.json` only. `core/canonical-measurement-terms.json` holds terms the buyer is bound by and is held closed until 4.0, so the canonical terms gain the field in 4.0.
+**Where it is preserved:** commit `709aa5f01` (#7940) carries the `measurement-terms.json` field and `tests/collection-mechanism.test.cjs`.
-**Fix:** declare `counting_mechanisms` on the canonical terms as well. A 3.x run cannot observe this check, so it reports as not observable.
+**What 4.0 needs:** the field on `core/canonical-measurement-terms.json`. A 3.x run cannot observe this check, so it reports as not observable.
### Canonical pricing options carry price adjustments
diff --git a/package.json b/package.json
index 8ef4e753a59..341915eeb11 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/get-products-deprecated-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/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",
@@ -88,7 +88,7 @@
"test:governance-conditions-storyboard": "node --test --test-force-exit --test-timeout=30000 tests/governance-conditions-storyboard.test.cjs",
"test:signal-governance-storyboard": "node --test --test-force-exit --test-timeout=30000 tests/signal-governance-storyboard.test.cjs",
"test:governance-task-slices": "node --test --test-force-exit --test-timeout=30000 tests/governance-task-slices.test.cjs tests/governance-storyboard-routing.test.cjs tests/provenance-tool-gates.test.cjs tests/cli-scenario-fixtures.test.cjs",
- "test:audience-evidence": "node --test --test-force-exit --test-timeout=30000 tests/audience-evidence.test.cjs tests/audience-evidence-targets.test.cjs",
+ "test:audience-evidence": "node --test --test-force-exit --test-timeout=30000 tests/audience-evidence.test.cjs",
"test:status-as-of-vectors": "node --test --test-force-exit --test-timeout=30000 tests/media-buy-status-as-of-vectors.test.cjs",
"test:delegation-chain-vectors": "node --test --test-force-exit --test-timeout=30000 tests/delegation-chain-vectors.test.cjs",
"test:storyboard-scoping": "node --test --test-force-exit --test-timeout=30000 tests/lint-storyboard-scoping.test.cjs",
diff --git a/server/src/training-agent/task-handlers.ts b/server/src/training-agent/task-handlers.ts
index d438a59ebbb..f9eca7c89a3 100644
--- a/server/src/training-agent/task-handlers.ts
+++ b/server/src/training-agent/task-handlers.ts
@@ -784,6 +784,19 @@ type LegacyCreativeAssignmentRow = {
[key: string]: unknown;
};
+/** A create_media_buy or update_media_buy assignment row, as stored from a
+ * request and as returned in their responses. `trackers` and `landing_page_url`
+ * are not fields of that row in 3.3 (adcp#8501): they are set through
+ * sync_creatives `assign` and read back on get_media_buys and list_creatives, so
+ * a request value is ignored rather than stored, and an assign-set value is not
+ * echoed here. */
+function requestAssignmentRow>(row: T): T {
+ const stored: Record = structuredClone(row);
+ delete stored.trackers;
+ delete stored.landing_page_url;
+ return stored as T;
+}
+
function inlineCreativeAssignmentRows(creatives: InlineCreativeInput[] | undefined): LegacyCreativeAssignmentRow[] {
if (!Array.isArray(creatives)) return [];
return creatives.map(creative => ({
@@ -1884,7 +1897,7 @@ function validateCreateAssignmentSemantics(
session: SessionState,
fieldPrefix: string,
packageRequirements: CanonicalPackageFormat[] = [],
- options: { declaresAssignmentSettings?: boolean } = {},
+ options: { storedAssignmentSettings?: boolean } = {},
): TaskError[] {
const errors: TaskError[] = [];
const effectiveModes = new Set(assignments.map(assignment => assignment.rotation_mode ?? 'weighted'));
@@ -1938,18 +1951,11 @@ function validateCreateAssignmentSemantics(
const creative = typeof assignment.creative_id === 'string'
? session.creatives.get(assignment.creative_id)
: undefined;
- const settings = assignment as unknown as { trackers?: unknown; landing_page_url?: unknown };
- if (options.declaresAssignmentSettings === false) {
- for (const field of ['trackers', 'landing_page_url'] as const) {
- if (settings[field] !== undefined) {
- errors.push({
- code: 'UNSUPPORTED_FEATURE',
- message: `${fieldPrefix}[${index}].${field} requires creative.supports_assignment_${field === 'trackers' ? 'trackers' : 'landing_page'}`,
- field: `${fieldPrefix}[${index}].${field}`,
- });
- }
- }
- } else if (Array.isArray(settings.trackers) && settings.trackers.length > 0) {
+ // Assignment trackers and landing pages are set only through sync_creatives
+ // assignment_operations (adcp#8501). Request rows never carry them (see
+ // requestAssignmentRow); rows stored through assign keep their tracker-slot check.
+ const settings = assignment as unknown as { trackers?: unknown };
+ if (options.storedAssignmentSettings && Array.isArray(settings.trackers) && settings.trackers.length > 0) {
const slotProblem = assignmentTrackerSlotProblem(
{ formatsToProvide: packageRequirements },
creative?.manifest,
@@ -2063,22 +2069,19 @@ function packageReadinessFields(pkg: PackageState, session: SessionState, ctx: T
/** The reference seller serves approved assignments independently and does not
* implement an initial all-creatives gate. Never acknowledge a requested hold
- * that delivery simulation would ignore. */
+ * that delivery simulation would ignore. The gate is set only through
+ * control_media_buy; create_media_buy and update_media_buy do not define it in
+ * 3.3 (adcp#8501), so they ignore it like any unknown field. */
function unsupportedCreativeStartPolicyError(request: unknown): TaskError | undefined {
- if (!isRecord(request)) return undefined;
- for (const field of ['packages', 'new_packages'] as const) {
- const packages = request[field];
- if (!Array.isArray(packages)) continue;
- const index = packages.findIndex(pkg => isRecord(pkg) && pkg.start_requires_all_creatives_approved === true);
- if (index < 0) continue;
- return {
- code: 'UNSUPPORTED_FEATURE',
- message: 'This seller does not support holding package startup until every assigned creative is approved.',
- field: `${field}[${index}].start_requires_all_creatives_approved`,
- recovery: 'correctable',
- };
- }
- return undefined;
+ if (!isRecord(request) || !Array.isArray(request.packages)) return undefined;
+ const index = request.packages.findIndex(pkg => isRecord(pkg) && pkg.start_requires_all_creatives_approved === true);
+ if (index < 0) return undefined;
+ return {
+ code: 'UNSUPPORTED_FEATURE',
+ message: 'This seller does not support holding package startup until every assigned creative is approved.',
+ field: `packages[${index}].start_requires_all_creatives_approved`,
+ recovery: 'correctable',
+ };
}
function creativeStartPolicyForWire(ctx: TrainingContext): Record {
@@ -15745,8 +15748,6 @@ export async function handleCreateMediaBuy(
) {
const invalidCollectionSelector = invalidSelectedCollectionSelector(args, ['packages']);
if (invalidCollectionSelector) return { errors: invalidCollectionSelector };
- const creativeStartPolicyError = unsupportedCreativeStartPolicyError(args);
- if (creativeStartPolicyError) return { errors: [creativeStartPolicyError] };
const proposalId = (args as unknown as Record).proposal_id;
if (typeof proposalId !== 'string') return handleCreateMediaBuyUnlocked(args, ctx, options);
@@ -17012,7 +17013,6 @@ async function handleCreateMediaBuyUnlocked(
creativeValidationSession,
`packages[${i}].creative_assignments`,
formatSnapshot.formats ?? [],
- { declaresAssignmentSettings: declaresAssignmentSettings(ctx) },
));
}
@@ -17102,7 +17102,7 @@ async function handleCreateMediaBuyUnlocked(
formatsToProvide: formatSnapshot.formats,
}),
creativeAssignments,
- creativeAssignmentDetails: requestedAssignmentRows.map(assignment => structuredClone(assignment)),
+ creativeAssignmentDetails: requestedAssignmentRows.map(assignment => requestAssignmentRow(assignment)),
targeting: targetingResult.targeting,
...(targetingResolution && { targetingResolution }),
...(propertyListApplication && { propertyListApplication }),
@@ -17396,7 +17396,6 @@ async function handleCreateMediaBuyUnlocked(
end_time: pkg.endTime,
...packageFormatSelectorForWire(pkg, ctx),
...packageReadinessFields(pkg, session, ctx),
- ...creativeStartPolicyForWire(ctx),
...(pkg.targeting && { targeting_overlay: targetingForWire(pkg.targeting) }),
...(pkg.targetingResolution && { targeting_resolution: pkg.targetingResolution }),
...(pkg.context && { context: pkg.context }),
@@ -17404,7 +17403,7 @@ async function handleCreateMediaBuyUnlocked(
ext: packageExtWithPropertyApplication(pkg.ext, pkg.propertyListApplication),
}),
...(pkg.committedMetrics && mediaBuy.confirmedAt !== null && { committed_metrics: pkg.committedMetrics }),
- creative_assignments: pkg.creativeAssignmentDetails
+ creative_assignments: pkg.creativeAssignmentDetails?.map(row => requestAssignmentRow(row))
?? pkg.creativeAssignments.map(creativeId => ({ creative_id: creativeId })),
})),
...(isRecord(req.context) && { context: req.context }),
@@ -19931,9 +19930,6 @@ async function handleUpdateMediaBuyUnlocked(
};
}
- const creativeStartPolicyError = unsupportedCreativeStartPolicyError(req);
- if (creativeStartPolicyError) return { errors: [creativeStartPolicyError] };
-
if (
!options.acceptedProposalExecution
&& Object.hasOwn(req as unknown as Record, 'invoice_recipient')
@@ -20289,7 +20285,7 @@ async function handleUpdateMediaBuyUnlocked(
creativeValidationSession,
`packages[${pkgId}].creative_assignments`,
(pkg.formatsToProvide ?? []) as CanonicalPackageFormat[],
- { declaresAssignmentSettings: declaresAssignmentSettings(ctx) },
+ { storedAssignmentSettings: update.creatives === undefined && assignments === undefined },
);
if (assignmentErrors.length) return { errors: assignmentErrors };
}
@@ -20548,7 +20544,7 @@ async function handleUpdateMediaBuyUnlocked(
if (creativeAssignments !== undefined) {
const creativeIds = creativeAssignments.map(a => a.creative_id);
pkg.creativeAssignments = creativeIds;
- pkg.creativeAssignmentDetails = structuredClone(creativeAssignments);
+ pkg.creativeAssignmentDetails = creativeAssignments.map(assignment => requestAssignmentRow(assignment));
affectedPackageIds.add(pkgId);
mb.history.push({ revision: mb.revision, timestamp: now, actor: 'buyer', action: 'creative_assignments_updated', summary: `Package ${pkgId} creative assignments replaced (${creativeIds.length} creatives)`, packageId: pkgId });
}
@@ -20708,7 +20704,6 @@ async function handleUpdateMediaBuyUnlocked(
creativeValidationSession,
`new_packages[${i}].creative_assignments`,
formatSnapshot.formats ?? [],
- { declaresAssignmentSettings: declaresAssignmentSettings(ctx) },
);
if (assignmentErrors.length) return { errors: assignmentErrors };
}
@@ -20745,7 +20740,7 @@ async function handleUpdateMediaBuyUnlocked(
formatsToProvide: formatSnapshot.formats,
}),
creativeAssignments: assignmentRows.flatMap(row => typeof row.creative_id === 'string' ? [row.creative_id] : []),
- creativeAssignmentDetails: assignmentRows.map(row => structuredClone(row)),
+ creativeAssignmentDetails: assignmentRows.map(row => requestAssignmentRow(row)),
targeting: targetingResult.targeting,
targetingResolution: packageTargetingResolution(product, targetingResult.targeting),
...(newPackageListOutcome.application && { propertyListApplication: newPackageListOutcome.application }),
@@ -20905,7 +20900,7 @@ async function handleUpdateMediaBuyUnlocked(
creativeValidationSession,
`packages[${pkg.packageId}].creative_assignments`,
(pkg.formatsToProvide ?? []) as CanonicalPackageFormat[],
- { declaresAssignmentSettings: declaresAssignmentSettings(ctx) },
+ { storedAssignmentSettings: true },
);
if (assignmentErrors.length) return { errors: assignmentErrors };
}
@@ -20984,7 +20979,6 @@ async function handleUpdateMediaBuyUnlocked(
end_time: pkg.endTime,
...packageFormatSelectorForWire(pkg, ctx),
...packageReadinessFields(pkg, session, ctx),
- ...creativeStartPolicyForWire(ctx),
...(pkg.targeting && { targeting_overlay: targetingForWire(pkg.targeting) }),
...(pkg.targetingResolution && { targeting_resolution: pkg.targetingResolution }),
...(pkg.context && { context: pkg.context }),
@@ -20992,7 +20986,7 @@ async function handleUpdateMediaBuyUnlocked(
ext: packageExtWithPropertyApplication(pkg.ext, pkg.propertyListApplication),
}),
...(pkg.committedMetrics && { committed_metrics: pkg.committedMetrics }),
- creative_assignments: pkg.creativeAssignmentDetails
+ creative_assignments: pkg.creativeAssignmentDetails?.map(row => requestAssignmentRow(row))
?? pkg.creativeAssignments.map(creativeId => ({ creative_id: creativeId })),
...(pkg.canceledAt && {
cancellation: { canceled_at: pkg.canceledAt, canceled_by: pkg.canceledBy, reason: pkg.cancellationReason },
diff --git a/server/tests/unit/assignment-operations.test.ts b/server/tests/unit/assignment-operations.test.ts
index 84bb9e357ef..7b7acd88a01 100644
--- a/server/tests/unit/assignment-operations.test.ts
+++ b/server/tests/unit/assignment-operations.test.ts
@@ -395,19 +395,69 @@ describe('assignment tracker slots', () => {
expect((await approvals(server, mediaBuyId)).map(row => row.creative_id)).toEqual(['hero_v1']);
});
- it('enforces the same limit on create_media_buy assignment rows', async () => {
+ it('ignores assignment trackers and landing pages on create_media_buy rows', async () => {
const server = createTrainingAgentServer(CTX);
await slotLimitedBuy(server);
await call(server, 'sync_creatives', { account: ACCOUNT, creatives: [creative('hero_v1')] });
+ // Two trackers would overflow the one-slot format if the row were checked; the row is not.
const buy = await call(server, 'create_media_buy', {
account: ACCOUNT, brand: BRAND, start_time: 'asap',
end_time: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
packages: [{
product_id: 'tracker_slot_product', pricing_option_id: 'tracker_slot_cpm', budget: 10000,
- creative_assignments: [{ creative_id: 'hero_v1', trackers: [pixel(1), pixel(2)] }],
+ creative_assignments: [{ creative_id: 'hero_v1', trackers: [pixel(1), pixel(2)], landing_page_url: 'https://assign-ops.example/x' }],
}],
});
- expect(JSON.stringify(buy)).toMatch(/creative_assignments\[0\]\.trackers/);
+ expect(buy.errors, JSON.stringify(buy)).toBeUndefined();
+ expect(buy.media_buy_id).toBeTruthy();
+ const rows = await approvals(server, buy.media_buy_id);
+ expect(rows.map(row => row.creative_id)).toEqual(['hero_v1']);
+ expect(rows[0].trackers ?? []).toEqual([]);
+ // The effective destination is the creative's own click-through, not the ignored request value.
+ expect(rows[0].landing_page_url).toBe('https://assign-ops.example/hero_v1');
+ });
+
+ it('ignores trackers in an update_media_buy creative_assignments replacement', async () => {
+ const server = createTrainingAgentServer(CTX);
+ const { mediaBuyId, packageId } = await slotLimitedBuy(server);
+ await call(server, 'sync_creatives', { account: ACCOUNT, creatives: [creative('hero_v1'), creative('hero_v2')] });
+ const read = await call(server, 'get_media_buys', { account: ACCOUNT, media_buy_ids: [mediaBuyId] });
+ const result = await call(server, 'update_media_buy', {
+ account: ACCOUNT, media_buy_id: mediaBuyId, revision: read.media_buys[0].revision,
+ packages: [{
+ package_id: packageId,
+ creative_assignments: [{ creative_id: 'hero_v2', trackers: [pixel(1), pixel(2)], landing_page_url: 'https://assign-ops.example/x' }],
+ }],
+ });
+ expect(result.errors, JSON.stringify(result)).toBeUndefined();
+ const rows = await approvals(server, mediaBuyId);
+ expect(rows.map(row => row.creative_id)).toEqual(['hero_v2']);
+ expect(rows[0].trackers ?? []).toEqual([]);
+ expect(rows[0].landing_page_url).toBe('https://assign-ops.example/hero_v2');
+ });
+
+ it('keeps trackers stored through sync_creatives assign across an update that changes only the budget', async () => {
+ const server = createTrainingAgentServer(CTX);
+ const { mediaBuyId, packageId } = await slotLimitedBuy(server);
+ await call(server, 'sync_creatives', { account: ACCOUNT, creatives: [creative('hero_v1')] });
+ const assigned = await call(server, 'sync_creatives', {
+ account: ACCOUNT,
+ assignment_operations: [{ operation: 'assign', creative_id: 'hero_v1', package_id: packageId, trackers: [pixel(1)] }],
+ });
+ expect(assigned.code).toBeUndefined();
+ expect((await approvals(server, mediaBuyId))[0]).toMatchObject({ creative_id: 'hero_v1', trackers: [pixel(1)] });
+ const read = await call(server, 'get_media_buys', { account: ACCOUNT, media_buy_ids: [mediaBuyId] });
+ const result = await call(server, 'update_media_buy', {
+ account: ACCOUNT, media_buy_id: mediaBuyId, revision: read.media_buys[0].revision,
+ packages: [{ package_id: packageId, budget: 8000 }],
+ });
+ expect(result.errors, JSON.stringify(result)).toBeUndefined();
+ // The deprecated update_media_buy response does not echo assign-set settings (adcp#8501).
+ const echoed = result.affected_packages[0].creative_assignments[0];
+ expect(echoed.creative_id).toBe('hero_v1');
+ expect(echoed).not.toHaveProperty('trackers');
+ expect(echoed).not.toHaveProperty('landing_page_url');
+ expect((await approvals(server, mediaBuyId))[0]).toMatchObject({ creative_id: 'hero_v1', trackers: [pixel(1)] });
});
it('refuses assignment trackers where the capability is not declared', async () => {
diff --git a/server/tests/unit/training-agent-creative-start-policy.test.ts b/server/tests/unit/training-agent-creative-start-policy.test.ts
index b51d06ba385..9cf7348488d 100644
--- a/server/tests/unit/training-agent-creative-start-policy.test.ts
+++ b/server/tests/unit/training-agent-creative-start-policy.test.ts
@@ -109,42 +109,39 @@ describe('reference seller creative start policy', () => {
afterAll(stopSessionCleanup);
- it('rejects a true create before creating a buy, library creative, or assignment', async () => {
- const before = await snapshot();
+ it('ignores the gate on create packages: no rejection, no echo, effective false on readback', async () => {
const result = await direct(handleCreateMediaBuy, createArgs({
packages: [
- { ...PRODUCT, budget: 10_000, creatives: [inlineCreative] },
{ ...PRODUCT, budget: 10_000, [FIELD]: true },
+ { ...PRODUCT, budget: 10_000, [FIELD]: false },
],
}));
- expect(result).toMatchObject({ errors: [{ code: 'UNSUPPORTED_FEATURE', field: `packages[1].${FIELD}` }] });
- expect(result.media_buy_id).toBeUndefined();
- expect(await snapshot()).toEqual(before);
+ expect(result.errors, JSON.stringify(result)).toBeUndefined();
+ for (const pkg of result.packages as Result[]) expect(pkg).not.toHaveProperty(FIELD);
+ const current = await read(result.media_buy_id as string);
+ expect((current.packages as Result[]).map(pkg => pkg[FIELD])).toEqual([false, false]);
});
- it.each(['packages', 'new_packages'])('rejects true in update.%s without any sibling mutations', async field => {
+ it.each(['packages', 'new_packages'])('ignores the gate in update.%s without rejecting the update', async field => {
const created = await direct(handleCreateMediaBuy, createArgs());
expect(created.errors).toBeUndefined();
const [firstId, secondId] = packageIds(created);
- const before = await snapshot();
const result = await direct(handleUpdateMediaBuy, {
media_buy_id: created.media_buy_id,
revision: created.revision,
- name: 'A name that must not commit',
- packages: [{ package_id: firstId, budget: 5_000, creatives: [inlineCreative] }],
...(field === 'packages' ? {
packages: [
- { package_id: firstId, budget: 5_000, creatives: [inlineCreative] },
+ { package_id: firstId, budget: 5_000 },
{ package_id: secondId, [FIELD]: true },
],
} : {
- new_packages: [{ ...PRODUCT, budget: 1_000 }, { ...PRODUCT, budget: 1_000, [FIELD]: true }],
+ new_packages: [{ ...PRODUCT, budget: 1_000, [FIELD]: true }],
}),
});
- expect(result).toMatchObject({ errors: [{ code: 'UNSUPPORTED_FEATURE', field: `${field}[1].${FIELD}` }] });
- // Covers the name, package budgets, revision, inline library entries, and
- // all package assignment sets, rather than only the response shape.
- expect(await snapshot()).toEqual(before);
+ expect(result.errors, JSON.stringify(result)).toBeUndefined();
+ for (const pkg of (result.affected_packages ?? []) as Result[]) expect(pkg).not.toHaveProperty(FIELD);
+ const current = await read(created.media_buy_id as string);
+ expect((current.packages as Result[]).every(pkg => pkg[FIELD] === false)).toBe(true);
});
it('preserves root cancellation precedence over an ignored unsupported gate', async () => {
@@ -169,12 +166,12 @@ describe('reference seller creative start policy', () => {
it.each([
['3.3 candidate', CANDIDATE_CTX, false],
['3.2 stable', STABLE_CTX, undefined],
- ] as const)('reports only the effective policy defined by the %s served release', async (_label, ctx, expected) => {
+ ] as const)('echoes the effective policy only on get_media_buys, and only on the %s served release', async (_label, ctx, expected) => {
const created = await direct(handleCreateMediaBuy, createArgs({
packages: [{ ...PRODUCT, budget: 10_000, [FIELD]: false }],
}), ctx);
expect(created.errors).toBeUndefined();
- expect((created.packages as Result[])[0]![FIELD]).toBe(expected);
+ expect(created.packages as Result[]).not.toContainEqual(expect.objectContaining({ [FIELD]: expect.anything() }));
const [packageId] = packageIds(created);
const updated = await direct(handleUpdateMediaBuy, {
media_buy_id: created.media_buy_id,
@@ -182,7 +179,7 @@ describe('reference seller creative start policy', () => {
packages: [{ package_id: packageId, budget: 8_000, [FIELD]: false }],
}, ctx);
expect(updated.errors).toBeUndefined();
- expect((updated.affected_packages as Result[])[0]![FIELD]).toBe(expected);
+ expect(updated.affected_packages as Result[]).not.toContainEqual(expect.objectContaining({ [FIELD]: expect.anything() }));
const current = await read(created.media_buy_id as string, ctx);
expect((current.packages as Result[])[0]![FIELD]).toBe(expected);
});
diff --git a/static/compliance/source/universal/v4-compatibility.yaml b/static/compliance/source/universal/v4-compatibility.yaml
index 709552580e7..fe0a4a7a4d5 100644
--- a/static/compliance/source/universal/v4-compatibility.yaml
+++ b/static/compliance/source/universal/v4-compatibility.yaml
@@ -219,22 +219,23 @@ checks:
issue: 8125
change: new_requirement
applies_to: [sellers]
- detection: "A seller that lists media_buy.audience_evidence_targets in experimental_features does not accept target_audiences on buy_products purchases and list_products offer_filters."
+ detection: "A seller does not accept target_audiences beside audience_evidence_requirements on buy_products purchases and list_products offer_filters, or does not record it in the accepted purchase snapshot."
fix: >-
- Accept the experimental target_audiences sibling of
- audience_evidence_requirements on buy_products purchases and
- list_products offer_filters, and record it in the accepted purchase
- snapshot. 3.3 carries it only on create_media_buy packages. It was
- withdrawn from the deprecated get_products filters after
- 3.3.0-beta.5 (adcp#8501). The purchase input and offer filters are open
- in 3.3 but do not define it, and the accepted purchase snapshot,
- product-purchase.json, is held closed with the commercial terms until
- 4.0.
+ Nothing in 3.3. The experimental target_audiences sibling of
+ audience_evidence_requirements (#8215) shipped in the 3.3 betas only on
+ the deprecated get_products filters and create_media_buy and
+ update_media_buy packages, and is withdrawn from both because 3.3 adds
+ nothing to deprecated tasks (adcp#8501), so 3.3 has no wire form for
+ it. 4.0 adds it to buy_products purchases and list_products
+ offer_filters, which are open in 3.3 but do not define it, and records
+ it in the accepted purchase snapshot, which is held closed with the
+ commercial terms until 4.0. Until then, a buyer that needs an audit record keeps
+ its own copy of the requirements and targets it sent.
observed_by:
none:
reason: >-
- Buyer-sent field on schemas that are closed in 3.x. A 3.x seller
- run cannot send or observe it.
+ Buyer-sent field with no 3.3 wire form. A 3.x seller run cannot
+ send or observe it.
- id: seller_held_change_terms_required_in_4_0
issue: 8087
change: new_requirement
@@ -314,17 +315,20 @@ checks:
issue: 6878
change: new_requirement
applies_to: [sellers]
- detection: "A seller that lists media_buy.measurement_provenance in experimental_features declares counting_mechanisms on measurement_terms.billing_measurement but not on the canonical commercial terms."
+ detection: "A seller that lists media_buy.measurement_provenance in experimental_features cannot declare counting_mechanisms on the canonical commercial terms' billing_measurement."
fix: >-
- Declare the experimental billing_measurement.counting_mechanisms on the
- canonical commercial terms too. 3.3 carries it only on
- core/measurement-terms.json, because canonical-measurement-terms.json is a
- closed schema and a minor release does not change a closed schema.
+ Nothing in 3.3. billing_measurement.counting_mechanisms shipped from
+ 3.3.0-beta.2 on core/measurement-terms.json, which only the deprecated
+ get_products, create_media_buy and update_media_buy tasks use, and is
+ withdrawn from 3.3 because 3.3 adds nothing to deprecated tasks
+ (adcp#8501). canonical-measurement-terms.json is held closed with the
+ commercial terms until 4.0, so 4.0 adds the declaration there. vendor_metric_provenance[].mechanisms[] on delivery
+ rows stays in 3.3.
observed_by:
none:
reason: >-
- The canonical schema is closed in 3.x and does not carry the field, so
- a 3.x seller run cannot send or observe it.
+ The canonical schema is held closed until 4.0 and 3.3 has no wire
+ form for the declaration, so a 3.x seller run cannot send or observe it.
- id: canonical_price_adjustments_required_in_4_0
issue: 7659
diff --git a/static/schemas/source/core/audience-evidence-target.json b/static/schemas/source/core/audience-evidence-target.json
deleted file mode 100644
index 2fb26c43680..00000000000
--- a/static/schemas/source/core/audience-evidence-target.json
+++ /dev/null
@@ -1,37 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-07/schema#",
- "$id": "/schemas/core/audience-evidence-target.json",
- "title": "Audience Evidence Target",
- "description": "Experimental (media_buy.audience_evidence_targets). An audience the buyer wants evidence for. Satisfied only by an admissible audience_evidence item with an exactly matching audience, the same relationship, and value >= minimum_value. Planning and ranking input, never a targeting predicate. Matching rules: get_products docs.",
- "type": "object",
- "x-status": "experimental",
- "x-added-in": "3.3.0",
- "properties": {
- "audience": {
- "$ref": "/schemas/core/audience-characteristic.json",
- "description": "Compared to audience_evidence[].audience as an exact canonical key (label ignored)."
- },
- "relationship": {
- "$ref": "/schemas/enums/audience-evidence-relationship.json",
- "description": "Only evidence with the same relationship is compared."
- },
- "minimum_value": {
- "type": "number",
- "minimum": 0,
- "description": "Smallest satisfying evidence value: a fraction for composition, a ratio for index, an integer count for reach_estimate."
- },
- "ext": { "$ref": "/schemas/core/ext.json" }
- },
- "required": ["audience", "relationship", "minimum_value"],
- "allOf": [
- {
- "if": { "properties": { "relationship": { "const": "composition" } }, "required": ["relationship"] },
- "then": { "properties": { "minimum_value": { "maximum": 1 } } }
- },
- {
- "if": { "properties": { "relationship": { "const": "reach_estimate" } }, "required": ["relationship"] },
- "then": { "properties": { "minimum_value": { "type": "integer" } } }
- }
- ],
- "additionalProperties": true
-}
diff --git a/static/schemas/source/core/creative-assignment.json b/static/schemas/source/core/creative-assignment.json
index 1b1272e20a4..b8e2a6ee897 100644
--- a/static/schemas/source/core/creative-assignment.json
+++ b/static/schemas/source/core/creative-assignment.json
@@ -10,18 +10,6 @@
"description": "Unique identifier for an existing library creative. pending_review and rejected creatives may be assigned; the seller retains the assignment through review and correction under the same creative_id. Assignment does not authorize delivery before package approval. The package start_requires_all_creatives_approved policy controls whether approved assignments may start independently.",
"x-entity": "creative"
},
- "trackers": {
- "type": "array",
- "items": { "$ref": "/schemas/core/assignment-tracker.json" },
- "minItems": 1,
- "description": "Additional measurement trackers for this creative on this package only, fired alongside the creative's own trackers without changing the library creative, its revision, or its approval. Requires creative.supports_assignment_trackers: true; buyers MUST NOT send this field otherwise and unsupported agents MUST reject it with UNSUPPORTED_FEATURE. The seller MUST fire every accepted tracker at its declared event or reject the assignment through existing assignment errors; trackers MUST NOT be silently dropped. Creative and assignment trackers together count against the effective format's tracker-slot limits, including canonical params.slots[].max and legacy repeatable-group max_count. Each assignment tracker consumes one compatible tracker-slot occurrence without moving creative assets (impression_tracker takes impression, click_tracker takes click, viewability_tracker takes viewable_* and audible_video_complete, any other pixel_tracker slot takes any event); reject if no allocation satisfies each slot's event and count constraints. These limits apply only when the effective format declares at least one tracker slot: a format that declares none makes no slot-limit claim, and the seller still fires every accepted tracker or rejects the assignment. Each accepted occurrence fires once per logical event; identical URLs are not deduplicated. A supplied list replaces the prior assignment tracker list. Omission from a complete package creative_assignments replacement clears the assignment trackers; omitting the entire replacement array leaves assignments unchanged. Empty arrays and null are not accepted.",
- "x-added-in": "3.3.0"
- },
- "landing_page_url": {
- "$ref": "/schemas/core/macro-bearing-url.json",
- "description": "Override the creative's click-through destination for this creative on this package only, without changing the library creative. The override applies to every click-through destination for this assignment, including per-state destinations; partial application MUST be rejected. AdCP macros follow the same rules as the creative's own click-through without declarations, retaining existing 3.x compatibility behavior. Declarations on the original creative assets do not transfer to this URL. Requires creative.supports_assignment_landing_page: true; buyers MUST NOT send this field otherwise and sellers without the capability MUST reject it with UNSUPPORTED_FEATURE. The seller MUST serve the override or reject the assignment through existing assignment errors, including when an embedded HTML, JavaScript, or VAST click cannot be overridden. Creative approval remains valid; destination review applies to the assignment. Omitting this field from a complete package assignment replacement restores inheritance from the creative. On readback, supporting sellers return the effective destination when it is unique; omit the scalar when no destination exists or an unoverridden creative has multiple destinations, rather than selecting one arbitrarily.",
- "x-added-in": "3.3.0"
- },
"weight": {
"type": "number",
"description": "Relative delivery weight for this creative (0–100). Valid when the package's effective rotation_mode is weighted, including the backward-compatible default when rotation_mode is omitted. Weights determine impression distribution proportionally — a creative with weight 2 gets twice the delivery of weight 1. When omitted, the creative receives equal weight with other unweighted creatives. A weight of 0 means the creative is assigned but paused (receives no delivery).",
diff --git a/static/schemas/source/core/measurement-terms.json b/static/schemas/source/core/measurement-terms.json
index 572a183451b..4345ec3859e 100644
--- a/static/schemas/source/core/measurement-terms.json
+++ b/static/schemas/source/core/measurement-terms.json
@@ -13,18 +13,6 @@
"$ref": "/schemas/core/brand-ref.json",
"description": "Vendor whose measurement of the billing metric is authoritative for invoicing (e.g., { domain: 'campaignmanager.google.com' } for buyer's DCM, { domain: 'admanager.google.com' } for seller's GAM)."
},
- "counting_mechanisms": {
- "type": "array",
- "x-status": "experimental",
- "x-added-in": "3.3.0",
- "description": "What the billing metric named by `vendor` is counted from: `vendor` says who counts, this says how. Not `measurement_source`, which names the provider. Lists only mechanisms whose events enter the billed count; cross-check mechanisms MUST NOT be listed, and the merge rule for several mechanisms is described in the seller's methodology. Billing count only (per-row provenance is `vendor_metric_values[].measurement_provenance`). A set: order carries no meaning and values MUST NOT repeat. A claim enforced through the contract (`vendor`, `max_variance_percent`, `makegood_policy`), not a conformance probe. Absence means undeclared, never a default source, and a seller that omits `billing_measurement` declares nothing. On a product it is the seller's default; on a package request, the buyer's proposed term (the seller MAY accept, adjust, or reject with `TERMS_REJECTED`); on a confirmed package, the agreed term. A seller (or its ad server) that is itself the named `vendor` SHOULD declare it; buyer-attested and third-party vendors MAY omit it.",
- "items": {
- "$ref": "/schemas/enums/collection-mechanism.json"
- },
- "minItems": 1,
- "uniqueItems": true,
- "examples": [["server_logs"], ["server_logs", "sdk"]]
- },
"max_variance_percent": {
"type": "number",
"minimum": 0,
diff --git a/static/schemas/source/core/media-buy-features.json b/static/schemas/source/core/media-buy-features.json
index 0295009dd73..78384b9e183 100644
--- a/static/schemas/source/core/media-buy-features.json
+++ b/static/schemas/source/core/media-buy-features.json
@@ -9,7 +9,7 @@
"type": "boolean",
"default": false,
"x-added-in": "3.3.0",
- "description": "Supports the package start_requires_all_creatives_approved initial-serving gate through control_media_buy package controls and any advertised create_media_buy/update_media_buy compatibility facades. When true, the seller MUST honor and echo the policy, retain pending-review and rejected assignments, and expose the package-scoped update_creative_start_policy action before first enablement so the buyer can set or clear it outside commercial proposal negotiation. Buyers MUST NOT send true without this capability. Sellers that do not declare true MUST reject an opted-in request atomically with UNSUPPORTED_FEATURE; explicit false is the ordinary default. This capability can be checked before booking; it does not alter inventory guarantees or create a reservation on non-guaranteed inventory."
+ "description": "Supports the package start_requires_all_creatives_approved initial-serving gate through control_media_buy package controls. When true, the seller MUST honor and echo the policy, retain pending-review and rejected assignments, and expose the package-scoped update_creative_start_policy action before first enablement so the buyer can set or clear it outside commercial proposal negotiation. Buyers MUST NOT send true without this capability. Sellers that do not declare true MUST reject an opted-in request atomically with UNSUPPORTED_FEATURE; explicit false is the ordinary default. This capability can be checked before booking; it does not alter inventory guarantees or create a reservation on non-guaranteed inventory."
},
"inline_creative_management": {
"type": "boolean",
diff --git a/static/schemas/source/core/package.json b/static/schemas/source/core/package.json
index 6a6f672508c..2adcc6d7951 100644
--- a/static/schemas/source/core/package.json
+++ b/static/schemas/source/core/package.json
@@ -165,7 +165,7 @@
},
"creative_assignments": {
"type": "array",
- "description": "Creative assets assigned to this package, including the committed package-scoped rotation policy. Omitted rotation_mode reads as weighted for backward compatibility; all assignments resolve to one effective mode, and sequential positions are unique within each package-local group. A pending_review or rejected library creative MAY be assigned if the reference and other assignment constraints are valid; sellers MUST NOT reject or remove an assignment solely for its review status. Only package-approved eligible creatives may serve. See the package start_requires_all_creatives_approved policy and docs/media-buy/specification.mdx#starting-with-assigned-creatives-under-review.",
+ "description": "Creative assets assigned to this package, including the committed package-scoped rotation policy. Omitted rotation_mode reads as weighted for backward compatibility; all assignments resolve to one effective mode, and sequential positions are unique within each package-local group. A pending_review or rejected library creative MAY be assigned if the reference and other assignment constraints are valid; sellers MUST NOT reject or remove an assignment solely for its review status. Only package-approved eligible creatives may serve. A package's start_requires_all_creatives_approved policy is set through control_media_buy; see docs/media-buy/specification.mdx#starting-with-assigned-creatives-under-review.",
"x-adcp-validation": {
"verifier_constraints": {
"package_rotation_policy": {
@@ -245,26 +245,6 @@
"format": "date-time",
"description": "Flight end date/time for this package in ISO 8601 format. When omitted, the package inherits the media buy's end_time. Sellers SHOULD always include the resolved value in responses, even when inherited."
},
- "start_requires_all_creatives_approved": {
- "type": "boolean",
- "description": "AdCP 3.3 initial-start policy. False (default) serves eligible approved assignments. True MUST retain booking and inventory commitment but block first enablement for serving until every current assignment is approved in all applicable package scopes; an empty set or library approval alone is insufficient. Retain pending-review/rejected assignments; same-creative_id resubmission needs no reassignment. Flight, pause, format-coverage and commitment constraints still apply. After first enablement, ordinary serving and impairment rules apply. Opt-in requires media_buy.features.creative_start_policy: true; otherwise buyers MUST NOT send true and sellers MUST reject true with UNSUPPORTED_FEATURE before mutation. Stage the full initial set atomically or while paused; the gate cannot anticipate future assignments. Sellers MUST echo the effective value on package readback, including false when defaulted.",
- "x-added-in": "3.3.0",
- "x-adcp-validation": {
- "verifier_constraints": {
- "creative_start_policy": {
- "default": "serve_approved_subset",
- "true_gate": "all_current_assignments_approved_in_all_applicable_scopes",
- "empty_assignments": "gate_unsatisfied",
- "evaluation_window": "before_first_package_enablement",
- "review_rejection": "retain_assignment",
- "readback": "echo_effective_policy",
- "unsupported_true": "UNSUPPORTED_FEATURE"
- }
- },
- "spec": "docs/media-buy/specification.mdx#starting-with-assigned-creatives-under-review"
- },
- "default": false
- },
"paused": {
"type": "boolean",
"description": "Whether this package is paused by the buyer. Paused packages do not deliver impressions. Defaults to false.",
diff --git a/static/schemas/source/enums/audience-evidence-relationship.json b/static/schemas/source/enums/audience-evidence-relationship.json
deleted file mode 100644
index 6ead73a7e10..00000000000
--- a/static/schemas/source/enums/audience-evidence-relationship.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "$schema": "http://json-schema.org/draft-07/schema#",
- "$id": "/schemas/enums/audience-evidence-relationship.json",
- "title": "Audience Evidence Relationship",
- "description": "What an audience-evidence estimate says. The vocabulary of buyer targets. Same values as the inline enum on audience_evidence.relationship, which stays inline because it is a stable 3.2 field; a test pins the two lists equal.",
- "type": "string",
- "enum": ["composition", "index", "reach_estimate"],
- "enumDescriptions": {
- "composition": "Population share, as a fraction in [0,1].",
- "index": "Ratio to a baseline population; 1.0 matches the baseline.",
- "reach_estimate": "Estimated reachable count of people."
- },
- "x-added-in": "3.3.0"
-}
diff --git a/static/schemas/source/enums/collection-mechanism.json b/static/schemas/source/enums/collection-mechanism.json
index dbddd974bed..88d0bfb3d4d 100644
--- a/static/schemas/source/enums/collection-mechanism.json
+++ b/static/schemas/source/enums/collection-mechanism.json
@@ -2,7 +2,7 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "/schemas/enums/collection-mechanism.json",
"title": "Collection Mechanism",
- "description": "Collection mechanism a count is built from — what was observed and how. Not `measurement_source`, which names the provider. Not who observed it (a BrandRef carries the party), how an outcome was attributed (`attribution_methodology`), or the maturation or IVT stage (`measurement_window`). Neutral, not a quality ranking. Shared by `billing_measurement.counting_mechanisms[]` and `measurement_provenance.mechanisms[]`. Experimental in AdCP 3.3 (`media_buy.measurement_provenance`). Broadcast as-run logs and station affidavits are airing evidence (proof that a spot ran), not a counting source for audience measurement, so they are not values; delivery reports carry as-run data in `by_spot[]`, separately from audience rows. Ownership labels are not values: ad-server and publisher logs are both `server_logs`.",
+ "description": "Collection mechanism a count is built from — what was observed and how. Not `measurement_source`, which names the provider. Not who observed it (a BrandRef carries the party), how an outcome was attributed (`attribution_methodology`), or the maturation or IVT stage (`measurement_window`). Neutral, not a quality ranking. Used by `vendor_metric_provenance[].mechanisms[]` on delivery metrics. Experimental in AdCP 3.3 (`media_buy.measurement_provenance`). Broadcast as-run logs and station affidavits are airing evidence (proof that a spot ran), not a counting source for audience measurement, so they are not values; delivery reports carry as-run data in `by_spot[]`, separately from audience rows. Ownership labels are not values: ad-server and publisher logs are both `server_logs`.",
"type": "string",
"enum": [
"set_top_box",
diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json
index 260e2a816a9..5085ec4182b 100644
--- a/static/schemas/source/index.json
+++ b/static/schemas/source/index.json
@@ -276,10 +276,6 @@
"$ref": "/schemas/core/audience-evidence-requirements.json",
"description": "Buyer-authored audience-evidence admissibility and ranking policy"
},
- "audience-evidence-target": {
- "$ref": "/schemas/core/audience-evidence-target.json",
- "description": "Experimental. Buyer-named audience and minimum evidence value inside audience-evidence requirements"
- },
"audience-size": {
"$ref": "/schemas/core/audience-size.json",
"description": "Count of people reported as a range with a stated precision (lower_bound, upper_bound, precision, optional as_of)"
@@ -1851,10 +1847,6 @@
"$ref": "/schemas/enums/daypart-timezone-mode.json",
"description": "Supported clock mode for daypart targeting"
},
- "audience-evidence-relationship": {
- "$ref": "/schemas/enums/audience-evidence-relationship.json",
- "description": "What an audience-evidence estimate says: composition, index, or reach_estimate"
- },
"daypart-time-granularity": {
"$ref": "/schemas/enums/daypart-time-granularity.json",
"description": "Finest time-of-day resolution a product honors for daypart targeting (experimental)"
diff --git a/static/schemas/source/media-buy/create-media-buy-request.json b/static/schemas/source/media-buy/create-media-buy-request.json
index d079a8d6b6b..19fb0281b98 100644
--- a/static/schemas/source/media-buy/create-media-buy-request.json
+++ b/static/schemas/source/media-buy/create-media-buy-request.json
@@ -148,21 +148,6 @@
"$ref": "/schemas/core/business-entity.json",
"description": "Override the account's default billing entity for this specific buy. When provided, the seller invoices this entity instead. The seller MUST validate the invoice recipient is authorized for this account. When governance_agents are configured, the seller MUST include invoice_recipient in the check_governance request."
},
- "predecessor_media_buy_id": {
- "x-status": "experimental",
- "x-added-in": "3.3.0",
- "type": "string",
- "minLength": 1,
- "maxLength": 255,
- "x-entity": "media_buy",
- "description": "Media buy this one succeeds (renewal, migration, or sunset). Forward-only: set once at creation; the predecessor is never modified. MUST be an existing buy of the same seller and account, else `MEDIA_BUY_NOT_FOUND`. Requires `lineage_reason`. See the Media Buy Lineage section of the media-buy specification. Experimental in AdCP 3.3: sellers that implement it list `media_buy.lineage` in `experimental_features`, buyers send it only to sellers that do, and a seller that cannot record it rejects the request with `UNSUPPORTED_FEATURE`."
- },
- "lineage_reason": {
- "x-status": "experimental",
- "x-added-in": "3.3.0",
- "$ref": "/schemas/enums/media-buy-lineage-reason.json",
- "description": "Why this buy succeeds its predecessor. Supplied with, and only with, `predecessor_media_buy_id` (`INVALID_REQUEST` otherwise). Reasons outside the closed enum go in `ext`."
- },
"io_acceptance": {
"type": "object",
"description": "Acceptance of an insertion order from a committed proposal. Required when the proposal's insertion_order has requires_signature: true. References the io_id from the proposal's insertion_order.",
@@ -260,7 +245,7 @@
"type": "object",
"deprecated": true,
"x-removed-in": "4.0.0",
- "description": "Legacy authentication for webhook delivery (A2A-compatible): Bearer or HMAC-SHA256, both deprecated and removed in AdCP 4.0 (#4288). Required on a request served below 3.3. When this block is absent, the seller signs every delivery under the profile the registration's `signing_profile` selects (`adcp/webhook-signing/v1` when it is omitted), as for `push_notification_config` (docs/building/by-layer/L1/security.mdx#webhook-callbacks).",
+ "description": "Legacy authentication for webhook delivery (A2A-compatible): Bearer or HMAC-SHA256, both deprecated and removed in AdCP 4.0 (#4288). Required on a request served below 3.3. When this block is absent, the seller signs every delivery with `adcp/webhook-signing/v1`, as for a `push_notification_config` that selects no `signing_profile` (docs/building/by-layer/L1/security.mdx#webhook-callbacks).",
"properties": {
"schemes": {
"type": "array",
@@ -299,12 +284,6 @@
],
"description": "For batched delivery, how often to push artifacts. Required when delivery_mode is 'batched'."
},
- "signing_profile": {
- "x-status": "experimental",
- "$ref": "/schemas/enums/webhook-signing-profile.json",
- "description": "Webhook-signing profile the seller uses for every delivery to this `url`. When it is omitted, the seller signs with `adcp/webhook-signing/v1`, or with the legacy scheme when `authentication` is present. A seller whose `webhook_signing.profiles` does not list it rejects the registration with `INVALID_REQUEST`. MUST NOT be combined with `authentication`.",
- "x-added-in": "3.3.0"
- },
"sampling_rate": {
"type": "number",
"minimum": 0,
@@ -316,7 +295,6 @@
"url",
"delivery_mode"
],
- "not": { "required": ["authentication", "signing_profile"] },
"additionalProperties": true
},
"context": {
@@ -466,12 +444,6 @@
"dependencies": {
"proposal_id": [
"total_budget"
- ],
- "predecessor_media_buy_id": [
- "lineage_reason"
- ],
- "lineage_reason": [
- "predecessor_media_buy_id"
]
},
"additionalProperties": true
diff --git a/static/schemas/source/media-buy/package-request.json b/static/schemas/source/media-buy/package-request.json
index ab1bb929cce..75d39135ca2 100644
--- a/static/schemas/source/media-buy/package-request.json
+++ b/static/schemas/source/media-buy/package-request.json
@@ -143,26 +143,6 @@
"format": "date-time",
"description": "Flight end date/time for this package in ISO 8601 format. When omitted, the package inherits the media buy's end_time. Must fall within the media buy's date range."
},
- "start_requires_all_creatives_approved": {
- "type": "boolean",
- "description": "AdCP 3.3 initial-start policy. False (default) serves eligible approved assignments. True MUST retain booking and inventory commitment but block first enablement for serving until every current assignment is approved in all applicable package scopes; an empty set or library approval alone is insufficient. Retain pending-review/rejected assignments; same-creative_id resubmission needs no reassignment. Flight, pause, format-coverage and commitment constraints still apply. After first enablement, ordinary serving and impairment rules apply. Opt-in requires media_buy.features.creative_start_policy: true; otherwise buyers MUST NOT send true and sellers MUST reject true with UNSUPPORTED_FEATURE before mutation. Stage the full initial set atomically or while paused; the gate cannot anticipate future assignments. This includes packages added through update_media_buy.new_packages.",
- "x-added-in": "3.3.0",
- "x-adcp-validation": {
- "verifier_constraints": {
- "creative_start_policy": {
- "default": "serve_approved_subset",
- "true_gate": "all_current_assignments_approved_in_all_applicable_scopes",
- "empty_assignments": "gate_unsatisfied",
- "evaluation_window": "before_first_package_enablement",
- "review_rejection": "retain_assignment",
- "readback": "echo_effective_policy",
- "unsupported_true": "UNSUPPORTED_FEATURE"
- }
- },
- "spec": "docs/media-buy/specification.mdx#starting-with-assigned-creatives-under-review"
- },
- "default": false
- },
"paused": {
"type": "boolean",
"description": "Whether this package should be created in a paused state. Paused packages do not deliver impressions. Defaults to false.",
@@ -191,16 +171,6 @@
"$ref": "/schemas/core/audience-evidence-requirements.json",
"description": "Buyer policy that the selected product and constructed package MUST satisfy using product audience evidence. This remains planning and suitability evidence, not a targeting instruction. Sellers MUST reject an unsatisfied required policy rather than silently drop it, and a confirmed package MUST include every evidence snapshot used to satisfy this policy in audience_evidence_selections with decision_use package_construction."
},
- "target_audiences": {
- "type": "array",
- "x-status": "experimental",
- "x-added-in": "3.3.0",
- "items": { "$ref": "/schemas/core/audience-evidence-target.json" },
- "minItems": 1,
- "maxItems": 10,
- "uniqueItems": true,
- "description": "Experimental (media_buy.audience_evidence_targets). Package-level restatement of the audiences the buyer wants evidence for, with the same semantics as target_audiences on get_products filters, evaluated against the sibling audience_evidence_requirements, which MUST be present with it. Sellers reject an unsatisfied required policy rather than dropping it. Not a targeting predicate. The same field on buy_products purchases arrives in 4.0."
- },
"audience_evidence_pins": {
"type": "array",
"description": "Exact immutable evidence snapshots selected by the buyer during discovery. The seller MUST match evidence_id, snapshot_id, version, and content_digest against one published snapshot and MUST reject catalog mutation, snapshot reuse, missing snapshots, or substitutions. Every accepted pin MUST be echoed in the confirmed package's audience_evidence_selections with decision_use package_construction.",
@@ -316,7 +286,7 @@
},
"creative_assignments": {
"type": "array",
- "description": "Assign existing library creatives to this package with optional rotation, grouping, weights, and placement targeting. rotation_mode is package-scoped: omission resolves to weighted, and every assignment MUST resolve to the same effective mode. In sequential mode, sequence_position MUST be unique within each package-local group. Sellers reject conflicts with VALIDATION_ERROR before creating the package. A pending_review or rejected library creative MAY be assigned if the reference and other assignment constraints are valid; sellers MUST NOT reject or remove an assignment solely for its review status. Only package-approved eligible creatives may serve. See the package start_requires_all_creatives_approved policy and docs/media-buy/specification.mdx#starting-with-assigned-creatives-under-review.",
+ "description": "Assign existing library creatives to this package with optional rotation, grouping, weights, and placement targeting. rotation_mode is package-scoped: omission resolves to weighted, and every assignment MUST resolve to the same effective mode. In sequential mode, sequence_position MUST be unique within each package-local group. Sellers reject conflicts with VALIDATION_ERROR before creating the package. A pending_review or rejected library creative MAY be assigned if the reference and other assignment constraints are valid; sellers MUST NOT reject or remove an assignment solely for its review status. Only package-approved eligible creatives may serve. A package's start_requires_all_creatives_approved policy is set through control_media_buy; see docs/media-buy/specification.mdx#starting-with-assigned-creatives-under-review.",
"x-adcp-validation": {
"verifier_constraints": {
"package_rotation_policy": {
@@ -365,7 +335,6 @@
"pricing_option_id"
],
"dependencies": {
- "target_audiences": ["audience_evidence_requirements"],
"params": ["format_kind"]
},
"additionalProperties": true
diff --git a/static/schemas/source/media-buy/package-update.json b/static/schemas/source/media-buy/package-update.json
index ea0739c720d..ca4f8d66612 100644
--- a/static/schemas/source/media-buy/package-update.json
+++ b/static/schemas/source/media-buy/package-update.json
@@ -58,25 +58,6 @@
"format": "date-time",
"description": "Updated flight end date/time for this package in ISO 8601 format. Must fall within the media buy's date range."
},
- "start_requires_all_creatives_approved": {
- "type": "boolean",
- "description": "AdCP 3.3 initial-start policy. False (default) serves eligible approved assignments. True MUST retain booking and inventory commitment but block first enablement for serving until every current assignment is approved in all applicable package scopes; an empty set or library approval alone is insufficient. Retain pending-review/rejected assignments; same-creative_id resubmission needs no reassignment. Flight, pause, format-coverage and commitment constraints still apply. After first enablement, ordinary serving and impairment rules apply. Opt-in requires media_buy.features.creative_start_policy: true; otherwise buyers MUST NOT send true and sellers MUST reject true with UNSUPPORTED_FEATURE before mutation. Stage the full initial set atomically or while paused; the gate cannot anticipate future assignments. Omission preserves the stored policy; false clears the gate before first enablement for serving. A change to this policy after the package has first been enabled for serving MUST be rejected with INVALID_STATE.",
- "x-added-in": "3.3.0",
- "x-adcp-validation": {
- "verifier_constraints": {
- "creative_start_policy": {
- "default": "serve_approved_subset",
- "true_gate": "all_current_assignments_approved_in_all_applicable_scopes",
- "empty_assignments": "gate_unsatisfied",
- "evaluation_window": "before_first_package_enablement",
- "review_rejection": "retain_assignment",
- "readback": "echo_effective_policy",
- "unsupported_true": "UNSUPPORTED_FEATURE"
- }
- },
- "spec": "docs/media-buy/specification.mdx#starting-with-assigned-creatives-under-review"
- }
- },
"paused": {
"type": "boolean",
"description": "Pause/resume specific package (true = paused, false = active)"
@@ -198,7 +179,7 @@
},
"creative_assignments": {
"type": "array",
- "description": "Replace creative assignments for this package with optional rotation, grouping, weights, and placement routing. Uses replacement semantics - omit to leave assignments unchanged. rotation_mode is package-scoped: omission resolves to weighted, and every assignment MUST resolve to the same effective mode. In sequential mode, sequence_position MUST be unique within each package-local group. Sellers reject conflicts with VALIDATION_ERROR before mutation. When the same mutation narrows or clears targeting_overlay.placement_selection, this complete replacement MUST remove or reroute every assignment reference that would otherwise be orphaned; the seller validates both changes atomically. A pending_review or rejected library creative MAY be assigned if the reference and other assignment constraints are valid; sellers MUST NOT reject or remove an assignment solely for its review status. Only package-approved eligible creatives may serve. See the package start_requires_all_creatives_approved policy and docs/media-buy/specification.mdx#starting-with-assigned-creatives-under-review.",
+ "description": "Replace creative assignments for this package with optional rotation, grouping, weights, and placement routing. Uses replacement semantics - omit to leave assignments unchanged. rotation_mode is package-scoped: omission resolves to weighted, and every assignment MUST resolve to the same effective mode. In sequential mode, sequence_position MUST be unique within each package-local group. Sellers reject conflicts with VALIDATION_ERROR before mutation. When the same mutation narrows or clears targeting_overlay.placement_selection, this complete replacement MUST remove or reroute every assignment reference that would otherwise be orphaned; the seller validates both changes atomically. A pending_review or rejected library creative MAY be assigned if the reference and other assignment constraints are valid; sellers MUST NOT reject or remove an assignment solely for its review status. Only package-approved eligible creatives may serve. A package's start_requires_all_creatives_approved policy is set through control_media_buy; see docs/media-buy/specification.mdx#starting-with-assigned-creatives-under-review.",
"x-adcp-validation": {
"verifier_constraints": {
"package_rotation_policy": {
diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json
index aa440150a39..9e48976db2f 100644
--- a/static/schemas/source/protocol/get-adcp-capabilities-response.json
+++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json
@@ -3282,13 +3282,13 @@
},
"supports_assignment_trackers": {
"type": "boolean",
- "description": "When true, this agent accepts trackers on creative assignments in create_media_buy, update_media_buy, and sync_creatives, fires them in addition to the creative's own trackers or rejects the assignment, enforces shared format tracker-slot limits where the format declares tracker slots, and returns the assignment-scoped tracker list in list_creatives and get_media_buys readback. Requires has_creative_library: true. When false or absent, the agent MUST reject the field with UNSUPPORTED_FEATURE and buyers MUST NOT send it.",
+ "description": "When true, this agent accepts trackers on creative assignments in sync_creatives, fires them in addition to the creative's own trackers or rejects the assignment, enforces shared format tracker-slot limits where the format declares tracker slots, and returns the assignment-scoped tracker list in list_creatives and get_media_buys readback. Requires has_creative_library: true. When false or absent, the agent MUST reject the field with UNSUPPORTED_FEATURE and buyers MUST NOT send it.",
"default": false,
"x-added-in": "3.3.0"
},
"supports_assignment_landing_page": {
"type": "boolean",
- "description": "When true, this agent accepts landing_page_url on creative assignments in create_media_buy, update_media_buy, and sync_creatives, serves that destination on the specified package or rejects the assignment, and returns the effective serving destination when unique in list_creatives and get_media_buys assignment readback. Requires has_creative_library: true. When false or absent, the agent MUST reject the field with UNSUPPORTED_FEATURE and buyers MUST NOT send it.",
+ "description": "When true, this agent accepts landing_page_url on creative assignments in sync_creatives, serves that destination on the specified package or rejects the assignment, and returns the effective serving destination when unique in list_creatives and get_media_buys assignment readback. Requires has_creative_library: true. When false or absent, the agent MUST reject the field with UNSUPPORTED_FEATURE and buyers MUST NOT send it.",
"default": false,
"x-added-in": "3.3.0"
},
@@ -3879,7 +3879,7 @@
"profiles": {
"x-status": "experimental",
"type": "array",
- "description": "Webhook-signing profiles this agent can sign outbound webhooks with, named by the RFC 9421 `tag` value. `adcp/webhook-signing/v1` is the AdCP webhook profile named in `profile`. `web-bot-auth` is the Web Bot Auth profile, experimental in 3.3, in docs/building/by-layer/L1/wba-profile.mdx (Webhooks), under which a webhook is an ordinary signed request from the seller's identity. The seller signs each delivery with the one profile the subscriber selected in the registration's `signing_profile` (on `push_notification_config`, `reporting_webhook`, `artifact_webhook`, `accounts[].notification_configs[]`, or an agent-level notification configuration), and rejects a registration that selects a profile not listed here. Omission means `[\"adcp/webhook-signing/v1\"]`. The list includes the value of `profile`.",
+ "description": "Webhook-signing profiles this agent can sign outbound webhooks with, named by the RFC 9421 `tag` value. `adcp/webhook-signing/v1` is the AdCP webhook profile named in `profile`. `web-bot-auth` is the Web Bot Auth profile, experimental in 3.3, in docs/building/by-layer/L1/wba-profile.mdx (Webhooks), under which a webhook is an ordinary signed request from the seller's identity. The seller signs each delivery with the one profile the subscriber selected in the registration's `signing_profile` (on `push_notification_config`, `reporting_webhook`, `accounts[].notification_configs[]`, or an agent-level notification configuration), and rejects a registration that selects a profile not listed here. Omission means `[\"adcp/webhook-signing/v1\"]`. The list includes the value of `profile`.",
"items": {
"$ref": "/schemas/enums/webhook-signing-profile.json"
},
diff --git a/tests/assignment-landing-page.test.cjs b/tests/assignment-landing-page.test.cjs
index fc7b3b4e194..a3be0b4d9b3 100644
--- a/tests/assignment-landing-page.test.cjs
+++ b/tests/assignment-landing-page.test.cjs
@@ -58,31 +58,14 @@ async function checkEnclosingSchemas(cases) {
}
}
-test('complete create/update requests validate optional assignment landing pages', async () => {
- await checkEnclosingSchemas([
- {
- schema: 'media-buy/create-media-buy-request.json',
- data: {
- ...requestBase,
- brand: { domain: 'acme.example' },
- start_time: '2026-11-01T00:00:00Z',
- end_time: '2026-11-30T23:59:59Z',
- packages: [{
- product_id: 'display', pricing_option_id: 'cpm', budget: 1000,
- creative_assignments: [{ creative_id: 'hero' }]
- }]
- },
- assignment: data => data.packages[0].creative_assignments[0]
- },
- {
- schema: 'media-buy/update-media-buy-request.json',
- data: {
- ...requestBase, media_buy_id: 'mb_display',
- packages: [{ package_id: 'pkg_display', creative_assignments: [{ creative_id: 'hero' }] }]
- },
- assignment: data => data.packages[0].creative_assignments[0]
- }
- ]);
+// create_media_buy and update_media_buy are deprecated tasks: 3.3 adds no assignment landing page
+// to their creative_assignments rows (adcp#8501). Only the live surfaces below keep it.
+test('the deprecated create/update creative assignment rows declare no landing page', () => {
+ assert.equal(readSchema('core/creative-assignment.json').properties.landing_page_url, undefined);
+ for (const relativePath of ['media-buy/package-request.json', 'media-buy/package-update.json', 'core/package.json']) {
+ const assignment = readSchema(relativePath).properties.creative_assignments.items;
+ assert.equal(assignment.$ref, '/schemas/core/creative-assignment.json', relativePath);
+ }
});
test('complete list/get responses validate effective assignment landing pages', async () => {
@@ -142,21 +125,6 @@ test('sync assignment reset uses existing atomic unassign/assign operations and
}), false);
});
-test('package create, update, and read assignments accept optional macro-bearing landing pages', async () => {
- for (const relativePath of ['media-buy/package-request.json', 'media-buy/package-update.json', 'core/package.json']) {
- const assignment = readSchema(relativePath).properties.creative_assignments.items;
- assert.equal(assignment.$ref, '/schemas/core/creative-assignment.json');
- const validate = await compile(assignment);
- assert.equal(validate({ creative_id: 'hero' }), true, JSON.stringify(validate.errors));
- for (const landing_page_url of urls) {
- assert.equal(validate({ creative_id: 'hero', landing_page_url }), true, JSON.stringify(validate.errors));
- }
- for (const landing_page_url of invalidUrls) {
- assert.equal(validate({ creative_id: 'hero', landing_page_url }), false, relativePath);
- }
- }
-});
-
test('assign operations validate landing pages without creative upserts; deprecated assignments[] rows reject them', async () => {
const validate = await compile(syncSchema);
const assignment = { operation: 'assign', creative_id: 'hero', package_id: 'pkg_display' };
@@ -229,7 +197,6 @@ test('assignment landing page capability defaults off and requires an explicit c
test('all input and readback fields use the creative click-through URL rules and carry 3.3 metadata', () => {
const clickthroughUrl = readSchema('core/assets/url-asset.json').properties.url;
for (const schema of [
- readSchema('core/creative-assignment.json'),
assignSchema,
listAssignment,
buyAssignment
diff --git a/tests/assignment-trackers.test.cjs b/tests/assignment-trackers.test.cjs
index 38cb21bd59b..b4d257d9810 100644
--- a/tests/assignment-trackers.test.cjs
+++ b/tests/assignment-trackers.test.cjs
@@ -24,21 +24,9 @@ const impression = { event: 'impression', url: 'https://measurement.example/imp?
const custom = { event: 'custom', custom_event_name: 'engagement_3s', url: 'https://measurement.example/engagement' };
const requestBase = { account: { account_id: 'acct_acme' }, idempotency_key: 'assignment-trackers-test-0001' };
const assignment = { creative_id: 'hero', package_id: 'pkg_display' };
+// create_media_buy and update_media_buy are deprecated tasks and carry no assignment
+// trackers in 3.3 (adcp#8501); the cases below are the surfaces that keep them.
const cases = [
- {
- schema: 'media-buy/create-media-buy-request.json', write: true,
- data: { ...requestBase, brand: { domain: 'acme.example' },
- start_time: '2026-11-01T00:00:00Z', end_time: '2026-11-30T23:59:59Z',
- packages: [{ product_id: 'display', pricing_option_id: 'cpm', budget: 1000,
- creative_assignments: [{ creative_id: 'hero' }] }] },
- row: data => data.packages[0].creative_assignments[0]
- },
- {
- schema: 'media-buy/update-media-buy-request.json', write: true,
- data: { ...requestBase, media_buy_id: 'mb_display',
- packages: [{ package_id: 'pkg_display', creative_assignments: [{ creative_id: 'hero' }] }] },
- row: data => data.packages[0].creative_assignments[0]
- },
{
schema: 'creative/sync-creatives-request.json', write: true,
data: { ...requestBase, assignment_operations: [{ operation: 'assign', ...assignment }] },
@@ -111,6 +99,16 @@ test('complete create, update, sync, list, and get messages validate optional as
}
});
+test('the deprecated create/update creative assignment rows carry no trackers (adcp#8501)', () => {
+ const assignmentSchema = readSchema('core/creative-assignment.json');
+ assert.equal(assignmentSchema.properties.trackers, undefined);
+ // The row stays open (additionalProperties), so the schema no longer declares trackers;
+ // a seller on the later schema ignores them like any unknown field.
+ for (const schema of ['media-buy/package-request.json', 'media-buy/package-update.json', 'core/package.json']) {
+ assert.equal(readSchema(schema).properties.creative_assignments.items.$ref, '/schemas/core/creative-assignment.json');
+ }
+});
+
test('sync supports multi-package assignment-only trafficking and atomic reset, limiting trackers to assign', async () => {
const validate = await compile('creative/sync-creatives-request.json');
assert.equal(validate({ ...requestBase, assignment_operations: [
@@ -172,10 +170,7 @@ test('assignment tracker capability defaults off, requires a library, and is ind
test('tracker-slot limits apply only where the format declares a tracker slot, matched by event', () => {
const assign = readSchema('creative/sync-creatives-request.json')
.properties.assignment_operations.items.oneOf.find(variant => variant.properties.operation.const === 'assign');
- const descriptions = [
- assign.properties.trackers.description,
- readSchema('core/creative-assignment.json').properties.trackers.description,
- ];
+ const descriptions = [assign.properties.trackers.description];
for (const description of descriptions) {
assert.match(description, /These limits apply only when the effective format declares at least one tracker slot/);
assert.match(description, /makes no slot-limit claim/);
diff --git a/tests/audience-evidence-targets.test.cjs b/tests/audience-evidence-targets.test.cjs
deleted file mode 100644
index f08c67d45f5..00000000000
--- a/tests/audience-evidence-targets.test.cjs
+++ /dev/null
@@ -1,320 +0,0 @@
-const fs = require('node:fs');
-const path = require('node:path');
-const { describe, it, before } = require('node:test');
-const assert = require('node:assert/strict');
-const Ajv = require('ajv');
-const addFormats = require('ajv-formats');
-
-const ROOT = path.resolve(__dirname, '..');
-const SCHEMA_ROOT = path.join(ROOT, 'static/schemas/source');
-const fixture = JSON.parse(fs.readFileSync(
- path.join(ROOT, 'static/compliance/source/test-vectors/audience-evidence/vectors.json'),
- 'utf8',
-));
-
-function readSchema(uri) {
- assert.match(uri, /^\/schemas\//);
- return JSON.parse(fs.readFileSync(path.join(SCHEMA_ROOT, uri.slice('/schemas/'.length)), 'utf8'));
-}
-
-async function compile(schema) {
- const ajv = new Ajv({ allErrors: true, strict: false, discriminator: true, loadSchema: async ref => readSchema(ref) });
- addFormats(ajv);
- return ajv.compileAsync(typeof schema === 'string' ? readSchema(schema) : schema);
-}
-
-function clone(value) {
- return structuredClone(value);
-}
-
-// The semantics block below runs a small reference model of the normative text in
-// docs/media-buy/task-reference/get_products.mdx. It pins the documented rules and
-// gives implementers worked cases; it does not exercise a seller.
-//
-// Canonical audience key for exact matching: dimension, value or range, taxonomy. label is never compared.
-// Set values are order-insensitive; a range's absent bound is distinct from a present one.
-function audienceKey(audience) {
- // Keys are inserted in a fixed order, so JSON.stringify is a stable comparison key.
- const key = { dimension: audience.dimension };
- if (audience.range) {
- key.range = {};
- for (const bound of ['min', 'max']) if (audience.range[bound] !== undefined) key.range[bound] = audience.range[bound];
- } else if (Array.isArray(audience.value)) {
- key.set = [...audience.value].sort();
- } else {
- key.value = audience.value;
- }
- if (audience.taxonomy) {
- key.taxonomy = {
- system: audience.taxonomy.system,
- version: audience.taxonomy.version ?? null,
- value_id: audience.taxonomy.value_id ?? null,
- };
- }
- return JSON.stringify(key);
-}
-
-function satisfies(target, evidenceItems) {
- return evidenceItems.some(item => audienceKey(item.audience) === audienceKey(target.audience)
- && item.relationship === target.relationship
- && item.value >= target.minimum_value);
-}
-
-// `requirements` is the effective policy: the sibling audience_evidence_requirements object
-// with the sibling target_audiences list folded in. `admissibleItems` is the evidence that
-// already passed every other constraint in audience_evidence_requirements.
-function evaluate(requirements, admissibleItems, publishedAny = admissibleItems.length > 0) {
- const targets = requirements.target_audiences || [];
- const satisfied = targets.filter(target => satisfies(target, admissibleItems)).length;
- const allSatisfied = satisfied === targets.length;
- if (requirements.requirement_mode === 'preferred') {
- return { eligible: true, satisfied, allSatisfied };
- }
- if (!publishedAny) {
- return { eligible: requirements.evidence_presence === 'when_available', satisfied, allSatisfied };
- }
- return { eligible: allSatisfied, satisfied, allSatisfied };
-}
-
-const AGE_25_54 = { dimension: 'age', range: { min: 25, max: 54 } };
-const base = { requirement_mode: 'preferred', evidence_presence: 'when_available' };
-const target = { audience: AGE_25_54, relationship: 'composition', minimum_value: 0.4 };
-
-function evidenceFor(audience, relationship, value) {
- return { audience, relationship, value };
-}
-
-describe('audience evidence target audiences (#8125)', () => {
- let validateRequirements;
- let validateCompact;
- let validateFilters;
- let validateOfferFilters;
- let validateTarget;
- let validatePackageRequest;
- let validatePurchaseInput;
- let validatePurchase;
-
- before(async () => {
- [
- validateRequirements,
- validateCompact,
- validateFilters,
- validateOfferFilters,
- validateTarget,
- validatePackageRequest,
- validatePurchaseInput,
- validatePurchase,
- ] = await Promise.all([
- compile('/schemas/core/audience-evidence-requirements.json'),
- compile('/schemas/core/product-audience-evidence-requirements.json'),
- compile('/schemas/core/product-filters.json'),
- compile('/schemas/core/product-offer-filters.json'),
- compile('/schemas/core/audience-evidence-target.json'),
- compile('/schemas/media-buy/package-request.json'),
- compile('/schemas/media-buy/product-purchase-input.json'),
- compile('/schemas/media-buy/product-purchase.json'),
- ]);
- });
-
- describe('schema', () => {
- it('no longer declares target_audiences on get_products discovery filters (withdrawn, adcp#8501)', () => {
- const filters = readSchema('/schemas/core/product-filters.json');
- assert.equal(filters.properties.target_audiences, undefined);
- assert.equal(filters.dependencies?.target_audiences, undefined);
- assert.ok(filters.properties.audience_evidence_requirements, 'audience_evidence_requirements stays');
- const requirements = { ...base, accepted_evidence_types: ['measured'], minimum_confidence: 0.8 };
- assert.equal(validateFilters({ audience_evidence_requirements: requirements }), true, JSON.stringify(validateFilters.errors));
- });
-
- it('requires the sibling audience_evidence_requirements', () => {
- const packageRequest = clone(fixture.package_request);
- delete packageRequest.audience_evidence_requirements;
- packageRequest.target_audiences = [target];
- assert.equal(validatePackageRequest(packageRequest), false);
- });
-
- it('keeps the requirements schemas unchanged: target_audiences is not a declared member', () => {
- assert.equal(validateRequirements(base), true);
- assert.equal(validateCompact(base), true);
- // Open by default (DR-0036): an unknown key validates, so target_audiences is accepted though undeclared.
- assert.equal(validateRequirements({ ...base, target_audiences: [target] }), true);
- assert.equal(validateCompact({ ...base, target_audiences: [target] }), true);
- for (const uri of ['/schemas/core/audience-evidence-requirements.json', '/schemas/core/product-audience-evidence-requirements.json']) {
- assert.equal(readSchema(uri).properties.target_audiences, undefined, uri);
- }
- });
-
- it('requires audience, relationship, and minimum_value; an unknown key on the item validates', () => {
- for (const field of ['audience', 'relationship', 'minimum_value']) {
- const invalid = clone(target);
- delete invalid[field];
- assert.equal(validateTarget(invalid), false, field);
- }
- // Open by default (DR-0036): `strength` is an unknown key and validates.
- assert.equal(validateTarget({ ...target, strength: 'required' }), true);
- assert.equal(validateTarget({ ...target, relationship: 'affinity' }), false);
- assert.equal(validateTarget({ ...target, minimum_value: -0.1 }), false);
- assert.equal(validateTarget({ ...target, audience: { dimension: 'age' } }), false);
- });
-
- it('bounds minimum_value by the unit the relationship implies', () => {
- assert.equal(validateTarget({ ...target, relationship: 'composition', minimum_value: 1.2 }), false);
- assert.equal(validateTarget({ ...target, relationship: 'composition', minimum_value: 1 }), true);
- assert.equal(validateTarget({ ...target, relationship: 'index', minimum_value: 1.2 }), true);
- assert.equal(validateTarget({ ...target, relationship: 'reach_estimate', minimum_value: 1000.5 }), false);
- assert.equal(validateTarget({ ...target, relationship: 'reach_estimate', minimum_value: 1000 }), true);
- });
-
- it('bounds the list and reuses the audience characteristic constraints', () => {
- // The discovery-filter copy is withdrawn; the bounds live on the package-level list.
- const filtersWith = target_audiences => validatePackageRequest({ ...clone(fixture.package_request), audience_evidence_requirements: base, target_audiences });
- assert.equal(filtersWith([]), false);
- assert.equal(filtersWith([target, target]), false);
- assert.equal(filtersWith(Array.from({ length: 11 }, (_, index) => ({ ...target, minimum_value: index / 10 }))), false);
- assert.equal(filtersWith(Array.from({ length: 10 }, (_, index) => ({ ...target, minimum_value: index / 10 }))), true);
- assert.equal(filtersWith([{ ...target, audience: { dimension: 'age', range: { min: -1, max: 54 } } }]), false);
- });
-
- it('accepts target_audiences on create_media_buy packages and leaves the purchase path to 4.0', () => {
- assert.equal(validatePackageRequest(fixture.package_request), true, JSON.stringify(validatePackageRequest.errors));
- const packageRequest = clone(fixture.package_request);
- packageRequest.target_audiences = [target];
- assert.equal(validatePackageRequest(packageRequest), true, JSON.stringify(validatePackageRequest.errors));
-
- // The purchase schemas do not declare the sibling in 3.3: the field is a 4.0 addition.
- // product-purchase-input.json is open by default (DR-0036); product-purchase.json stays closed (signed terms).
- for (const [validate, schemaUri, additionalProperties] of [
- [validatePurchaseInput, '/schemas/media-buy/product-purchase-input.json', true],
- [validatePurchase, '/schemas/media-buy/product-purchase.json', false],
- ]) {
- const schema = readSchema(schemaUri);
- assert.equal(schema.properties.target_audiences, undefined, schemaUri);
- assert.equal(schema.additionalProperties, additionalProperties, schemaUri);
- assert.equal(validate.schema.properties.target_audiences, undefined, schemaUri);
- }
- });
-
- it('lists the 4.0 purchase-path addition in the v4 compatibility registry', () => {
- const registry = fs.readFileSync(path.join(ROOT, 'static/compliance/source/universal/v4-compatibility.yaml'), 'utf8');
- assert.match(registry, /id: purchase_target_audiences_required_in_4_0/);
- });
- });
-
- describe('conventions', () => {
- it('marks every new surface experimental and added in 3.3.0', () => {
- const targetSchema = readSchema('/schemas/core/audience-evidence-target.json');
- assert.equal(targetSchema['x-status'], 'experimental');
- assert.equal(targetSchema['x-added-in'], '3.3.0');
- for (const uri of ['/schemas/media-buy/package-request.json']) {
- const property = readSchema(uri).properties.target_audiences;
- assert.equal(property['x-status'], 'experimental', uri);
- assert.equal(property['x-added-in'], '3.3.0', uri);
- assert.equal(property.items.$ref, '/schemas/core/audience-evidence-target.json', uri);
- }
- });
-
- it('keeps the target relationship enum equal to the published evidence enum', () => {
- const relationship = readSchema('/schemas/enums/audience-evidence-relationship.json');
- assert.equal(relationship['x-added-in'], '3.3.0');
- assert.deepEqual(relationship.enum, ['composition', 'index', 'reach_estimate']);
- assert.deepEqual(Object.keys(relationship.enumDescriptions).sort(), [...relationship.enum].sort());
- // The stable 3.2 fields keep their inline enum; the target reuses the shared file.
- assert.deepEqual(readSchema('/schemas/core/audience-evidence.json').properties.relationship.enum, relationship.enum);
- assert.deepEqual(readSchema('/schemas/core/canonical-audience-evidence.json').properties.relationship.enum, relationship.enum);
- assert.equal(readSchema('/schemas/core/audience-evidence-target.json').properties.relationship.$ref, '/schemas/enums/audience-evidence-relationship.json');
- });
-
- it('lists the feature id in the canonical experimental table', () => {
- const doc = fs.readFileSync(path.join(ROOT, 'docs/reference/experimental-status.mdx'), 'utf8');
- assert.match(doc, /^\| `media_buy\.audience_evidence_targets` \|/m);
- });
-
- it('documents the surface under the heading the other pages link to', () => {
- const doc = fs.readFileSync(path.join(ROOT, 'docs/media-buy/task-reference/get_products.mdx'), 'utf8');
- assert.match(doc, /^#### Target audiences \(withdrawn\)$/m);
- // Every link into the get_products target-audiences section resolves to an anchor on the page.
- const anchors = new Set(['target-audiences-withdrawn']);
- for (const match of doc.matchAll(/<\/a>/g)) anchors.add(match[1]);
- const walk = dir => fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => (entry.isDirectory()
- ? walk(path.join(dir, entry.name))
- : /\.mdx?$/.test(entry.name) ? [path.join(dir, entry.name)] : []));
- for (const file of walk(path.join(ROOT, 'docs'))) {
- for (const match of fs.readFileSync(file, 'utf8').matchAll(/get_products#(target-audiences-[a-z-]+)/g)) {
- assert.ok(anchors.has(match[1]), `${path.relative(ROOT, file)} links to missing anchor ${match[1]}`);
- }
- }
- });
- });
-
- describe('semantics', () => {
- const exact = evidenceFor(clone(AGE_25_54), 'composition', 0.45);
- const lowShare = evidenceFor(clone(AGE_25_54), 'composition', 0.3);
-
- it('is satisfied by exact-range evidence at or above the minimum', () => {
- assert.equal(satisfies(target, [exact]), true);
- assert.equal(satisfies(target, [evidenceFor(clone(AGE_25_54), 'composition', 0.4)]), true);
- assert.equal(satisfies(target, [lowShare]), false);
- });
-
- it('does not match a superset, subset, or shifted range', () => {
- for (const range of [{ min: 18, max: 54 }, { min: 25, max: 34 }, { min: 26, max: 54 }, { min: 25 }, { max: 54 }]) {
- assert.equal(satisfies(target, [evidenceFor({ dimension: 'age', range }, 'composition', 0.9)]), false, JSON.stringify(range));
- }
- });
-
- it('ignores label, compares sets unordered, and treats taxonomy as part of the key', () => {
- assert.equal(satisfies(target, [evidenceFor({ ...AGE_25_54, label: 'Adults 25 to 54' }, 'composition', 0.5)]), true);
-
- const setTarget = { audience: { dimension: 'https://taxonomy.example/segments', value: ['a', 'b'] }, relationship: 'index', minimum_value: 1.2 };
- assert.equal(satisfies(setTarget, [evidenceFor({ dimension: 'https://taxonomy.example/segments', value: ['b', 'a'] }, 'index', 1.5)]), true);
- assert.equal(satisfies(setTarget, [evidenceFor({ dimension: 'https://taxonomy.example/segments', value: 'a' }, 'index', 1.5)]), false);
- assert.equal(satisfies({ ...target, audience: { dimension: 'age', value: 30 } }, [evidenceFor({ dimension: 'age', range: { min: 30, max: 30 } }, 'composition', 0.9)]), false);
- assert.equal(satisfies({ ...target, audience: { dimension: 'age', value: [30] } }, [evidenceFor({ dimension: 'age', value: 30 }, 'composition', 0.9)]), false);
-
- const taxonomy = { system: 'https://taxonomy.example', version: '1', value_id: '25-54' };
- const taxonomyTarget = { ...target, audience: { ...AGE_25_54, taxonomy } };
- assert.equal(satisfies(taxonomyTarget, [evidenceFor({ ...AGE_25_54, taxonomy: { ...taxonomy } }, 'composition', 0.5)]), true);
- assert.equal(satisfies(taxonomyTarget, [evidenceFor(clone(AGE_25_54), 'composition', 0.5)]), false);
- assert.equal(satisfies(target, [evidenceFor({ ...AGE_25_54, taxonomy }, 'composition', 0.5)]), false);
- });
-
- it('compares only the same relationship', () => {
- assert.equal(satisfies(target, [evidenceFor(clone(AGE_25_54), 'index', 1.8)]), false);
- assert.equal(satisfies({ ...target, relationship: 'index', minimum_value: 1.5 }, [evidenceFor(clone(AGE_25_54), 'index', 1.8)]), true);
- });
-
- it('combines several targets with AND and lets different items satisfy different targets', () => {
- const female = { audience: { dimension: 'https://taxonomy.example/gender', value: 'female' }, relationship: 'composition', minimum_value: 0.5 };
- const requirements = { requirement_mode: 'required', evidence_presence: 'required', target_audiences: [target, female] };
- const femaleEvidence = evidenceFor(clone(female.audience), 'composition', 0.55);
- assert.deepEqual(evaluate(requirements, [exact, femaleEvidence]), { eligible: true, satisfied: 2, allSatisfied: true });
- assert.deepEqual(evaluate(requirements, [exact]), { eligible: false, satisfied: 1, allSatisfied: false });
- });
-
- it('excludes under required and only ranks under preferred', () => {
- const required = { requirement_mode: 'required', evidence_presence: 'required', target_audiences: [target] };
- assert.equal(evaluate(required, [lowShare]).eligible, false);
- assert.equal(evaluate(required, []).eligible, false);
-
- const preferred = { ...required, requirement_mode: 'preferred' };
- assert.deepEqual(evaluate(preferred, [lowShare]), { eligible: true, satisfied: 0, allSatisfied: false });
- assert.deepEqual(evaluate(preferred, [exact]), { eligible: true, satisfied: 1, allSatisfied: true });
- });
-
- it('counts satisfied targets so a higher count never ranks below a lower one', () => {
- const female = { audience: { dimension: 'https://taxonomy.example/gender', value: 'female' }, relationship: 'composition', minimum_value: 0.5 };
- const preferred = { requirement_mode: 'preferred', evidence_presence: 'when_available', target_audiences: [target, female] };
- const femaleEvidence = evidenceFor(clone(female.audience), 'composition', 0.55);
- const counts = [[], [lowShare], [exact], [exact, femaleEvidence]].map(items => evaluate(preferred, items).satisfied);
- assert.deepEqual(counts, [0, 0, 1, 2]);
- assert.deepEqual([...counts].sort((a, b) => a - b), counts);
- });
-
- it('keeps a product with no published evidence eligible under when_available', () => {
- const requirements = { requirement_mode: 'required', evidence_presence: 'when_available', target_audiences: [target] };
- assert.equal(evaluate(requirements, [], false).eligible, true);
- // Published evidence for another audience does not satisfy the targets, so the product drops out.
- assert.equal(evaluate(requirements, [evidenceFor({ dimension: 'age', range: { min: 18, max: 24 } }, 'composition', 0.9)]).eligible, false);
- });
- });
-});
diff --git a/tests/collection-mechanism.test.cjs b/tests/collection-mechanism.test.cjs
index 968ec1f7f15..7be2131d391 100644
--- a/tests/collection-mechanism.test.cjs
+++ b/tests/collection-mechanism.test.cjs
@@ -35,11 +35,6 @@ describe("collection mechanism vocabulary", () => {
assert.equal(e["x-status"], "experimental");
assert.equal(e["x-added-in"], "3.3.0");
assert.match(e.description, /Not `measurement_source`, which names the provider/);
- const terms = readSchema("/schemas/core/measurement-terms.json");
- const counting = terms.properties.billing_measurement.properties.counting_mechanisms;
- assert.equal(counting["x-status"], "experimental");
- assert.equal(counting["x-added-in"], "3.3.0");
- assert.match(counting.description, /Not `measurement_source`, which names the provider/);
const prov = readSchema("/schemas/core/vendor-metric-provenance.json");
assert.equal(prov["x-status"], "experimental");
assert.equal(prov["x-added-in"], "3.3.0");
@@ -104,27 +99,10 @@ describe("collection mechanism vocabulary", () => {
});
});
- describe("billing_measurement.counting_mechanisms[]", () => {
- for (const uri of ["/schemas/core/measurement-terms.json"]) {
- describe(uri, () => {
- let validate;
- const terms = (cs) => ({
- billing_measurement: {
- vendor: { domain: "streamhaus.example" },
- ...(cs === undefined ? {} : { counting_mechanisms: cs }),
- },
- });
- before(async () => {
- validate = await compile(uri);
- });
- it("is optional", () => assert.ok(validate(terms())));
- it("accepts a hybrid set", () => assert.ok(validate(terms(["client_tracker", "server_logs"]))));
- it("rejects empty, duplicate, and unknown values", () => {
- assert.ok(!validate(terms([])));
- assert.ok(!validate(terms(["server_logs", "server_logs"])));
- assert.ok(!validate(terms(["seller_tracker"])));
- });
- });
- }
+ describe("billing_measurement.counting_mechanisms (withdrawn, adcp#8501)", () => {
+ it("is absent from the shared measurement terms, so products, create and update packages all lack it", () => {
+ const terms = readSchema("/schemas/core/measurement-terms.json");
+ assert.equal(terms.properties.billing_measurement.properties.counting_mechanisms, undefined);
+ });
});
});
diff --git a/tests/creative-start-policy.test.cjs b/tests/creative-start-policy.test.cjs
index d0ae2e83f46..0ddfff5e894 100644
--- a/tests/creative-start-policy.test.cjs
+++ b/tests/creative-start-policy.test.cjs
@@ -12,14 +12,21 @@ function readSchema(name) {
return JSON.parse(fs.readFileSync(path.join(SCHEMA_ROOT, name), 'utf8'));
}
+// create_media_buy and update_media_buy are deprecated compatibility tasks, so 3.3
+// withdrew the gate from their package schemas (adcp#8501). control_media_buy and the
+// get_media_buys readback keep it.
+const withdrawnSurfaces = [
+ 'media-buy/package-request.json',
+ 'media-buy/package-update.json',
+ 'core/package.json',
+];
+
+// True when any object in the schema declares FIELD as a property (prose mentions don't count).
+const declaresField = node => node !== null && typeof node === 'object' &&
+ (Object.hasOwn(node, FIELD) || Object.values(node).some(declaresField));
+
const surfaces = [
- ['media-buy/package-request.json', schema => schema, {
- product_id: 'streamhaus_video', pricing_option_id: 'cpm_usd',
- creative_assignments: [{ creative_id: 'launch_video' }],
- }],
- ['media-buy/package-update.json', schema => schema, { package_id: 'pkg_launch' }],
['media-buy/package-control.json', schema => schema, { package_id: 'pkg_launch', paused: false }],
- ['core/package.json', schema => schema, { package_id: 'pkg_launch' }],
['media-buy/get-media-buys-response.json',
schema => schema.properties.media_buys.items.properties.packages.items,
{ package_id: 'pkg_launch' }],
@@ -42,7 +49,15 @@ for (const [name, select, legacy] of surfaces) {
});
}
-test('create, update, and both read surfaces carry the same initial-start contract', () => {
+test('the deprecated create/update package schemas no longer carry the creative start gate', () => {
+ for (const name of withdrawnSurfaces) {
+ assert.equal(declaresField(readSchema(name)), false, name);
+ }
+ // The get_products exact-package forecast input that mirrored the package row is withdrawn too.
+ assert.equal(fs.existsSync(path.join(SCHEMA_ROOT, 'media-buy/package-forecast-input.json')), false);
+});
+
+test('control and the get_media_buys readback carry the same initial-start contract', () => {
for (const [name, select] of surfaces) {
const schema = select(readSchema(name));
const field = schema.properties[FIELD];
@@ -70,6 +85,7 @@ test('compound create and update use the package schemas that define the gate',
const create = readSchema('media-buy/create-media-buy-request.json');
const update = readSchema('media-buy/update-media-buy-request.json');
assert.equal(create.properties.packages.items.$ref, '/schemas/media-buy/package-request.json');
+ assert.equal(declaresField(create), false);
assert.equal(update.properties.new_packages.items.$ref, '/schemas/media-buy/package-request.json');
assert.equal(update.properties.packages.items.$ref, '/schemas/media-buy/package-update.json');
const success = readSchema('media-buy/create-media-buy-response.json').oneOf[0];
diff --git a/tests/deprecated-task-withdrawals.test.cjs b/tests/deprecated-task-withdrawals.test.cjs
new file mode 100644
index 00000000000..bead104246f
--- /dev/null
+++ b/tests/deprecated-task-withdrawals.test.cjs
@@ -0,0 +1,173 @@
+/**
+ * Contract test for the 3.3-beta fields withdrawn from the deprecated tasks (adcp#8501).
+ *
+ * create_media_buy and update_media_buy are deprecated compatibility tasks, so 3.3 adds
+ * nothing to them. Each field below is absent from their request and response schemas and
+ * still lives on the live tasks named beside it. The schemas are open, so a seller on the
+ * later schema ignores a withdrawn field like any unknown field.
+ */
+'use strict';
+
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const test = require('node:test');
+const yaml = require('js-yaml');
+
+const ROOT = path.join(__dirname, '..');
+const SCHEMAS = path.join(ROOT, 'static/schemas/source');
+const readSchema = (relative) => JSON.parse(fs.readFileSync(path.join(SCHEMAS, relative), 'utf8'));
+const readDoc = (relative) => fs.readFileSync(path.join(ROOT, 'docs', relative), 'utf8');
+
+// True when any `properties` object in the schema declares `name` (prose mentions don't count).
+function declares(node, name) {
+ if (node === null || typeof node !== 'object') return false;
+ if (Array.isArray(node)) return node.some((child) => declares(child, name));
+ if (node.properties && typeof node.properties === 'object' && Object.hasOwn(node.properties, name)) return true;
+ return Object.values(node).some((child) => declares(child, name));
+}
+
+// True when any `dependencies` / `dependentRequired` map keys or lists `name`.
+function dependsOn(node, name) {
+ if (node === null || typeof node !== 'object') return false;
+ if (Array.isArray(node)) return node.some((child) => dependsOn(child, name));
+ for (const key of ['dependencies', 'dependentRequired']) {
+ const map = node[key];
+ if (map && typeof map === 'object' && (Object.hasOwn(map, name) || Object.values(map).some((v) => Array.isArray(v) && v.includes(name)))) {
+ return true;
+ }
+ }
+ return Object.values(node).some((child) => dependsOn(child, name));
+}
+
+// A schema that no longer exists declares nothing (the get_products PR removes package-forecast-input).
+const absent = (files, field) => {
+ for (const file of files) {
+ if (!fs.existsSync(path.join(SCHEMAS, file))) continue;
+ assert.equal(declares(readSchema(file), field), false, `${file} declares ${field}`);
+ }
+};
+const present = (file, pick, field) => {
+ assert.ok(pick(readSchema(file)).properties[field], `${file} keeps ${field}`);
+};
+
+const CREATE = 'media-buy/create-media-buy-request.json';
+const UPDATE = 'media-buy/update-media-buy-request.json';
+
+test('lineage is off create_media_buy and stays on the live tasks and readback', () => {
+ const create = readSchema(CREATE);
+ for (const field of ['predecessor_media_buy_id', 'lineage_reason']) {
+ assert.equal(create.properties[field], undefined, field);
+ assert.equal(dependsOn(create, field), false, `${field} dependency`);
+ assert.equal(declares(readSchema(UPDATE), field), false, `update ${field}`);
+ }
+ for (const file of ['media-buy/buy-products-request.json', 'media-buy/accept-proposal-request.json', 'core/media-buy.json']) {
+ for (const field of ['predecessor_media_buy_id', 'lineage_reason']) present(file, (s) => s, field);
+ }
+ const item = readSchema('media-buy/get-media-buys-response.json').properties.media_buys.items;
+ for (const field of ['predecessor_media_buy_id', 'lineage_reason']) assert.ok(item.properties[field], field);
+ assert.ok(readSchema('media-buy/get-media-buys-request.json').properties.predecessor_media_buy_id, 'get_media_buys filter');
+});
+
+test('artifact_webhook.signing_profile is withdrawn; signing_profile stays on the live webhook configs', () => {
+ const webhook = readSchema(CREATE).properties.artifact_webhook;
+ assert.equal(webhook.properties.signing_profile, undefined);
+ assert.equal(JSON.stringify(webhook.not || null).includes('signing_profile'), false);
+ // The notification-config copies are a separate #8501 decision, so they are not asserted here.
+ for (const file of [
+ 'core/push-notification-config.json',
+ 'core/reporting-webhook.json',
+ ]) {
+ present(file, (s) => s, 'signing_profile');
+ }
+});
+
+test('start_requires_all_creatives_approved is off create/update packages and stays on control and readback', () => {
+ const field = 'start_requires_all_creatives_approved';
+ absent([
+ 'media-buy/package-request.json',
+ 'media-buy/package-update.json',
+ 'core/package.json',
+ 'media-buy/package-forecast-input.json',
+ CREATE,
+ UPDATE,
+ 'media-buy/create-media-buy-response.json',
+ 'media-buy/update-media-buy-response.json',
+ ], field);
+ present('media-buy/package-control.json', (s) => s, field);
+ const readback = readSchema('media-buy/get-media-buys-response.json').properties.media_buys.items.properties.packages.items;
+ assert.ok(readback.properties[field], 'get_media_buys package readback');
+ assert.ok(readSchema('core/media-buy-features.json').properties.creative_start_policy, 'creative_start_policy feature');
+ assert.ok(readSchema('core/media-buy-available-action-id.json').enumMetadata.update_creative_start_policy);
+});
+
+test('target_audiences is off create/update packages', () => {
+ const request = readSchema('media-buy/package-request.json');
+ assert.equal(request.properties.target_audiences, undefined);
+ assert.equal(dependsOn(request, 'target_audiences'), false);
+ absent(['media-buy/package-forecast-input.json', 'media-buy/package-update.json', 'core/package.json', CREATE, UPDATE], 'target_audiences');
+ // core/product-filters.json (get_products) is deliberately not asserted here.
+});
+
+test('audience evidence targets have no 3.3 wire form, and their orphaned schemas are gone', () => {
+ for (const file of ['core/audience-evidence-target.json', 'enums/audience-evidence-relationship.json']) {
+ assert.equal(fs.existsSync(path.join(SCHEMAS, file)), false, `${file} is withdrawn`);
+ }
+ const index = fs.readFileSync(path.join(SCHEMAS, 'index.json'), 'utf8');
+ assert.equal(index.includes('audience-evidence-target.json'), false);
+ assert.equal(index.includes('audience-evidence-relationship.json'), false);
+ assert.doesNotMatch(readDoc('reference/experimental-status.mdx'), /^\| `media_buy\.audience_evidence_targets` \|/m);
+ // The stable inline relationship enums on audience evidence stay.
+ for (const file of ['core/audience-evidence.json', 'core/canonical-audience-evidence.json']) {
+ assert.deepEqual(readSchema(file).properties.relationship.enum, ['composition', 'index', 'reach_estimate'], file);
+ }
+ // The 4.0 check keeps its id but no longer keys off the retired feature id.
+ const registry = yaml.load(fs.readFileSync(
+ path.join(ROOT, 'static/compliance/source/universal/v4-compatibility.yaml'), 'utf8'));
+ const check = registry.checks.find((entry) => entry.id === 'purchase_target_audiences_required_in_4_0');
+ assert.ok(check);
+ assert.equal(String(check.detection).includes('audience_evidence_targets'), false);
+});
+
+test('assignment trackers and landing page are off the create/update assignment row and stay on sync and readback', () => {
+ const row = readSchema('core/creative-assignment.json');
+ assert.equal(row.properties.trackers, undefined);
+ assert.equal(row.properties.landing_page_url, undefined);
+ for (const file of ['media-buy/package-request.json', 'media-buy/package-update.json', 'core/package.json']) {
+ assert.equal(readSchema(file).properties.creative_assignments.items.$ref, '/schemas/core/creative-assignment.json', file);
+ }
+ const assign = readSchema('creative/sync-creatives-request.json').properties.assignment_operations.items.oneOf
+ .find((variant) => variant.properties.operation.const === 'assign');
+ assert.ok(assign.properties.trackers && assign.properties.landing_page_url, 'sync_creatives assign operation');
+ const listed = readSchema('creative/list-creatives-response.json').properties.creatives.items.properties.assignments.properties.assigned_packages.items;
+ const bought = readSchema('media-buy/get-media-buys-response.json').properties.media_buys.items.properties.packages.items.properties.creative_approvals.items;
+ for (const [name, readback] of [['list_creatives', listed], ['get_media_buys', bought]]) {
+ assert.ok(readback.properties.trackers && readback.properties.landing_page_url, `${name} readback`);
+ }
+});
+
+test('billing_measurement.counting_mechanisms is gone everywhere, and the enum stays for provenance', () => {
+ absent([
+ 'core/measurement-terms.json',
+ 'core/product.json',
+ 'media-buy/package-request.json',
+ 'media-buy/package-update.json',
+ 'core/package.json',
+ CREATE,
+ UPDATE,
+ ], 'counting_mechanisms');
+ assert.equal(readSchema('core/measurement-terms.json').properties.billing_measurement.properties.counting_mechanisms, undefined);
+ assert.ok(fs.existsSync(path.join(SCHEMAS, 'enums/collection-mechanism.json')));
+ assert.equal(
+ readSchema('core/vendor-metric-provenance.json').properties.mechanisms.items.$ref,
+ '/schemas/enums/collection-mechanism.json',
+ );
+});
+
+test('the 4.0 registry still carries the withdrawn designs', () => {
+ const registry = yaml.load(fs.readFileSync(
+ path.join(ROOT, 'static/compliance/source/universal/v4-compatibility.yaml'), 'utf8'));
+ for (const id of ['purchase_target_audiences_required_in_4_0', 'canonical_counting_mechanisms_required_in_4_0']) {
+ assert.ok(registry.checks.find((entry) => entry.id === id), `${id} is registered`);
+ }
+});
diff --git a/tests/media-buy-lineage.test.cjs b/tests/media-buy-lineage.test.cjs
index acfb931e666..19c79b6efb6 100644
--- a/tests/media-buy-lineage.test.cjs
+++ b/tests/media-buy-lineage.test.cjs
@@ -77,6 +77,8 @@ for (const [task, { uri, base }] of Object.entries(REQUESTS)) {
}
});
+ if (task === 'create_media_buy') continue; // deprecated task: 3.3 adds no lineage (adcp#8501)
+
test(`${task} requires the two lineage fields together and rejects off-enum reasons`, async () => {
const validate = await compile(readSchema(uri));
assert.equal(validate({ ...base, predecessor_media_buy_id: 'mb_pinnacle_q3' }), false);
@@ -92,6 +94,15 @@ for (const [task, { uri, base }] of Object.entries(REQUESTS)) {
});
}
+test('create_media_buy is a deprecated task and declares no lineage field (adcp#8501)', () => {
+ const schema = readSchema(REQUESTS.create_media_buy.uri);
+ assert.equal('predecessor_media_buy_id' in schema.properties, false);
+ assert.equal('lineage_reason' in schema.properties, false);
+ const deps = schema.dependencies || {};
+ assert.equal('predecessor_media_buy_id' in deps, false);
+ assert.equal('lineage_reason' in deps, false);
+});
+
test('the core media buy pairs the lineage fields', async () => {
const validate = await compile(readSchema('/schemas/core/media-buy.json'));
const buy = { media_buy_id: 'mb_pinnacle_q4', status: 'active', confirmed_at: '2026-09-28T15:00:00Z', revision: 1, total_budget: 40000, packages: [] };
@@ -138,7 +149,6 @@ test('get_media_buys items and the core media buy echo the lineage fields', asyn
test('every new lineage property is experimental, marked 3.3.0, and listed in the registry', () => {
const SCHEMAS = [
- ['/schemas/media-buy/create-media-buy-request.json', (d) => d.properties],
['/schemas/media-buy/buy-products-request.json', (d) => d.properties],
['/schemas/media-buy/accept-proposal-request.json', (d) => d.properties],
['/schemas/media-buy/get-media-buys-response.json', (d) => d.properties.media_buys.items.properties],
diff --git a/tests/x-added-in-3-3-sweep.test.cjs b/tests/x-added-in-3-3-sweep.test.cjs
index 82ccec22258..e908bc51e2b 100644
--- a/tests/x-added-in-3-3-sweep.test.cjs
+++ b/tests/x-added-in-3-3-sweep.test.cjs
@@ -98,7 +98,6 @@ const PROPERTIES_3_3 = [
['core/notification-config.json', ['properties', 'signing_profile']],
['core/push-notification-config.json', ['properties', 'signing_profile']],
['core/reporting-webhook.json', ['properties', 'signing_profile']],
- ['media-buy/create-media-buy-request.json', ['properties', 'artifact_webhook', 'properties', 'signing_profile']],
['protocol/get-adcp-capabilities-response.json', ['properties', 'request_signing', 'properties', 'profiles']],
['protocol/get-adcp-capabilities-response.json', ['properties', 'webhook_signing', 'properties', 'profiles']],
['protocol/get-adcp-capabilities-response.json', ['properties', 'media_buy', 'properties', 'event_source_archive']],