diff --git a/.changeset/add-format-delivery-breakdown.md b/.changeset/add-format-delivery-breakdown.md new file mode 100644 index 0000000000..5bbf38a3cf --- /dev/null +++ b/.changeset/add-format-delivery-breakdown.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Add capability-gated delivery breakdowns by canonical creative `format_kind` to `get_media_buy_delivery`, including explicit GET-only scope, `custom` aggregation, truncation disclosure, independent reconciliation from creative-level rows, and the full sort contract (`sort_direction` plus the `by_format_sorted_by`/`by_format_sort_direction` applied-sort echo with row-grain fallback and nulls-last semantics). diff --git a/docs/media-buy/media-buys/optimization-reporting.mdx b/docs/media-buy/media-buys/optimization-reporting.mdx index a25af222ec..5399cd2791 100644 --- a/docs/media-buy/media-buys/optimization-reporting.mdx +++ b/docs/media-buy/media-buys/optimization-reporting.mdx @@ -1278,6 +1278,7 @@ Delivery data can be broken down across multiple dimensions within each package. | Geography | `by_geo` | `geo_level`, `geo_code`, `impressions`, `spend` | `system`, `country`, `geo_name` | `supports_geo_breakdown` | | Device type | `by_device_type` | `device_type`, `impressions`, `spend` | — | `supports_device_type_breakdown` | | Device platform | `by_device_platform` | `device_platform`, `impressions`, `spend` | — | `supports_device_platform_breakdown` | +| Creative format | `by_format` | `format_kind`, `impressions`, `spend` | Other delivery metrics | `supports_format_breakdown` | | Audience | `by_audience` | `audience_id`, `audience_source`, `impressions`, `spend` | `audience_name` | `supports_audience_breakdown` | | Demographic | `by_demographic` | `demographic`, `demographic_system`, `impressions`, `spend` | `age`, other delivery metrics | `supports_demographic_breakdown` | | Placement | `by_placement` | `placement_id`, `impressions`, `spend` | `publisher_domain`, `placement_name` | `supports_placement_breakdown` | @@ -1285,6 +1286,8 @@ Delivery data can be broken down across multiple dimensions within each package. Each breakdown entry inherits all fields from `delivery-metrics` (clicks, conversions, and other optional metrics) plus its dimension-specific fields. Every entry requires the fields shown in the required fields column. Check `reporting_capabilities` on the product to discover which dimensions are available; product-level capabilities are authoritative because different products from the same seller may support different breakdowns. `supports_geo_breakdown`, `supports_demographic_breakdown`, and `supports_spot_breakdown` are structured declarations; the other capability declarations in this table are boolean flags. Within `supports_geo_breakdown`, `country` and `region` are booleans, while `metro` is keyed by `metro-system` values and native `postal_area` is keyed by ISO 3166-1 alpha-2 country with arrays of country-local `postal-system` values. Geo rows use `system` for `geo_level: "metro"` and `"postal_area"`; native postal rows also include `country`. Deprecated country-fused postal systems remain accepted for compatibility. +Format breakdowns use the AdCP 3.1 canonical `format_kind` vocabulary and are negotiated on the GET path. Reporting webhook configuration does not negotiate or guarantee dimensional breakdowns, although webhook payloads may carry the same fields as extensions. They compare creative shapes such as `video_hosted`, `native_in_feed`, and `image`; duration, dimensions, and other format-option parameters are outside this grain. Sellers combine adopter-defined shapes into one `custom` row. When `by_format_truncated` is false, additive metrics such as impressions and spend should reconcile to package totals, subject to each metric's measurement and attribution semantics. Buyers must not expect row-level correspondence between `by_format` and `by_creative` because they are independently produced at different grains. + Demographic targeting and demographic reporting are separate product capabilities. A product may execute an exact buyer predicate while its measurement source reports only fixed buckets. For example, a social product might declare continuous targeting for ages 18–65 but the following reporting capability: ```json test=false diff --git a/docs/media-buy/task-reference/get_media_buy_delivery.mdx b/docs/media-buy/task-reference/get_media_buy_delivery.mdx index 4c7a811106..ba9eb1e937 100644 --- a/docs/media-buy/task-reference/get_media_buy_delivery.mdx +++ b/docs/media-buy/task-reference/get_media_buy_delivery.mdx @@ -26,7 +26,7 @@ Retrieve comprehensive delivery metrics and performance data for media buy repor | `status_filter` | string \| string[] | No | Status filter: `"pending_creatives"`, `"pending_start"`, `"active"`, `"paused"`, `"completed"`. Defaults to `["active"]` when omitted. | | `start_date` | string | No | Report start date (YYYY-MM-DD), inclusive. Omit for campaign lifetime data. Only accepted when product supports `date_range`. | | `end_date` | string | No | Report end date (YYYY-MM-DD), **exclusive**. Omit for campaign lifetime data. Only accepted when product supports `date_range`. | -| `reporting_dimensions` | object | No | Request dimensional breakdowns within `by_package`. Include a key as an empty object (e.g., `"device_type": {}`) to activate with defaults. Keys: `geo`, `device_type`, `device_platform`, `audience`, `demographic`, `placement`, `spot`, `creative`, `keyword`, `catalog_item`. Most accept optional `limit`, `sort_by`, and `sort_direction`. Demographic also accepts canonical `age_ranges`, each of which must match the product's independent demographic reporting capability exactly. `spot` accepts only `limit`, returns rows by `aired_at` ascending, and defaults to the complete log for the reporting period. Geo requires `geo_level`; include `system` for metro/postal levels. `creative`, `keyword`, and `catalog_item` are returned automatically when the seller supports them, even when omitted from this object — including one of their keys is optional and upgrades that breakdown to a negotiated contract (`limit`, `sort_by`, `sort_direction`, and a binding truncation/sort-echo disclosure) without changing the automatic default. Unsupported dimensions are silently omitted; unsupported requested demographic ranges return [`UNSUPPORTED_FEATURE`](/docs/building/verification/compliance-catalog#error-code-unsupported-feature); malformed requests return a validation error. | +| `reporting_dimensions` | object | No | Request dimensional breakdowns within `by_package`. Include a key as an empty object (e.g., `"device_type": {}`) to activate with defaults. Keys: `geo`, `device_type`, `device_platform`, `format`, `audience`, `demographic`, `placement`, `spot`, `creative`, `keyword`, `catalog_item`. Most accept optional `limit`, `sort_by`, and `sort_direction`. `format` groups rows by canonical `format_kind`, not duration or other format-option parameters. Demographic also accepts canonical `age_ranges`, each of which must match the product's independent demographic reporting capability exactly. `spot` accepts only `limit`, returns rows by `aired_at` ascending, and defaults to the complete log for the reporting period. Geo requires `geo_level`; include `system` for metro/postal levels. `creative`, `keyword`, and `catalog_item` are returned automatically when the seller supports them, even when omitted from this object — including one of their keys is optional and upgrades that breakdown to a negotiated contract (`limit`, `sort_by`, `sort_direction`, and a binding truncation/sort-echo disclosure) without changing the automatic default. Unsupported dimensions are silently omitted; unsupported requested demographic ranges return [`UNSUPPORTED_FEATURE`](/docs/building/verification/compliance-catalog#error-code-unsupported-feature); malformed requests return a validation error. | | `time_granularity` | string | No | Per-window slice granularity for pull recovery, matching `reporting_webhook.reporting_frequency` vocabulary (`hourly`, `daily`, `weekly`, `monthly`, `quarterly`, `post_campaign`). When set, the response includes `windows[]` slices shape-aligned with webhook fires at the same granularity. Capability-scoped — value MUST be in the product's `reporting_capabilities.windowed_pull_granularities`. See [Windowed pull recovery](#windowed-pull-recovery). | | `include_window_breakdown` | boolean | No | When `true` (and `time_granularity` is set), include the `windows[]` array on each media buy. Defaults to `false`. Ignored when `time_granularity` is omitted. | | `requested_metrics` | string[] | No | Narrow the response to a subset of metrics — same semantics as `reporting_webhook.requested_metrics`. Applies to `totals`, `by_package`, daily/window slices, and breakdown rows. When omitted, all available metrics are included (unchanged behavior). See [Requesting a metric subset](#requesting-a-metric-subset). | @@ -895,6 +895,7 @@ When you include `reporting_dimensions` in the request, the response includes di "reporting_dimensions": { "geo": { "geo_level": "metro", "system": "nielsen_dma", "limit": 10 }, "device_type": {}, + "format": { "sort_by": "spend" }, "demographic": { "age_ranges": [{ "min": 26, "max": 30, "include_unknown": false }] }, @@ -904,7 +905,7 @@ When you include `reporting_dimensions` in the request, the response includes di } ``` -Geo, device, audience, demographic, placement, creative, keyword, and catalog_item dimensions accept optional `limit`, `sort_by` (any value from the `sort-metric` enum; defaults to `spend`), and `sort_direction` (`asc` or `desc`; defaults to `desc`, largest first). The sort vocabulary includes leaf metric identities that order rows by a value nested inside an object-shaped metric — `sort_by: "quartile_100"` orders by `quartile_data.q4_views`, `sort_by: "viewable_rate"` orders by `viewability.viewable_rate` — so queries like "top creatives by completion" need no flat duplicate fields. Survey/model-based lift scalars are excluded from sorting; see the `sort-metric` enum description. Setting `sort_direction: "asc"` enables bottom-N queries — for example, the 25 worst placements by `viewable_rate` — that cannot be recovered from a truncated descending pull. The seller echoes the sort it actually applied on each breakdown: `by_geo_sorted_by`/`by_geo_sort_direction`, `by_device_type_sorted_by`/`by_device_type_sort_direction`, and the equivalent pair for `device_platform`, `audience`, `demographic`, `placement`, `creative`, `keyword`, and `catalog_item`. For every dimension requested via `reporting_dimensions`, sellers MUST return both echo fields whenever the corresponding breakdown array is present, including when they fall back to sorting by `spend` because they don't report the requested metric — the echo makes that fallback visible instead of leaving the buyer to misread the rows as ordered by the requested metric. (When `creative`, `keyword`, or `catalog_item` rows are returned automatically without a request key, the echo and truncation fields are RECOMMENDED, not required — see the automatic-breakdown contract below.) Geo requires `geo_level` (`country`, `region`, `metro`, `postal_area`). Demographic accepts optional canonical `age_ranges`. Each requested range must be supported by the product's `supports_demographic_breakdown.age`: `exact_predicates` accepts ranges within its declared bounds, while `enumerated_intervals` accepts only exact declared intervals. Unsupported ranges return [`UNSUPPORTED_FEATURE`](/docs/building/verification/compliance-catalog#error-code-unsupported-feature); the seller never substitutes a wider or narrower bucket. Omitting `age_ranges` requests the product's native declared breakdown. The spot log accepts only `limit`; its canonical order is `aired_at` ascending. Breakdowns are per-dimension only — cross-dimensional intersections are not supported. +Geo, device, format, audience, demographic, placement, creative, keyword, and catalog_item dimensions accept optional `limit`, `sort_by` (any value from the `sort-metric` enum; defaults to `spend`), and `sort_direction` (`asc` or `desc`; defaults to `desc`, largest first). The sort vocabulary includes leaf metric identities that order rows by a value nested inside an object-shaped metric — `sort_by: "quartile_100"` orders by `quartile_data.q4_views`, `sort_by: "viewable_rate"` orders by `viewability.viewable_rate` — so queries like "top creatives by completion" need no flat duplicate fields. Survey/model-based lift scalars are excluded from sorting; see the `sort-metric` enum description. Setting `sort_direction: "asc"` enables bottom-N queries — for example, the 25 worst placements by `viewable_rate` — that cannot be recovered from a truncated descending pull. The seller echoes the sort it actually applied on each breakdown: `by_geo_sorted_by`/`by_geo_sort_direction`, `by_device_type_sorted_by`/`by_device_type_sort_direction`, and the equivalent pair for `device_platform`, `audience`, `demographic`, `placement`, `creative`, `keyword`, and `catalog_item`. For every dimension requested via `reporting_dimensions`, sellers MUST return both echo fields whenever the corresponding breakdown array is present, including when they fall back to sorting by `spend` because they don't report the requested metric — the echo makes that fallback visible instead of leaving the buyer to misread the rows as ordered by the requested metric. (When `creative`, `keyword`, or `catalog_item` rows are returned automatically without a request key, the echo and truncation fields are RECOMMENDED, not required — see the automatic-breakdown contract below.) Geo requires `geo_level` (`country`, `region`, `metro`, `postal_area`). Demographic accepts optional canonical `age_ranges`. Each requested range must be supported by the product's `supports_demographic_breakdown.age`: `exact_predicates` accepts ranges within its declared bounds, while `enumerated_intervals` accepts only exact declared intervals. Unsupported ranges return [`UNSUPPORTED_FEATURE`](/docs/building/verification/compliance-catalog#error-code-unsupported-feature); the seller never substitutes a wider or narrower bucket. Omitting `age_ranges` requests the product's native declared breakdown. The spot log accepts only `limit`; its canonical order is `aired_at` ascending. Breakdowns are per-dimension only — cross-dimensional intersections are not supported. `creative`, `keyword`, and `catalog_item` are different from the other dimensions in one respect: sellers return them automatically whenever they support the underlying capability, whether or not the buyer's `reporting_dimensions` mentions them. Omitting the key preserves that automatic default — rows show up with no completeness contract, and `by_creative_truncated`/`by_keyword_truncated`/`by_catalog_item_truncated` and their sort echoes are only RECOMMENDED. Including the key (even as `{}`) negotiates the breakdown: `limit` and `sort_by`/`sort_direction` are honored, and the truncation flag and applied-sort echo become MUST-return fields, the same as any other requested dimension. @@ -915,6 +916,7 @@ Geo, device, audience, demographic, placement, creative, keyword, and catalog_it | Geography | `by_geo` | `geo_level`, `geo_code`, `impressions`, `spend` | `system`, `country`, `geo_name` | `supports_geo_breakdown` | | Device type | `by_device_type` | `device_type`, `impressions`, `spend` | — | `supports_device_type_breakdown` | | Device platform | `by_device_platform` | `device_platform`, `impressions`, `spend` | — | `supports_device_platform_breakdown` | +| Creative format | `by_format` | `format_kind`, `impressions`, `spend` | Other delivery metrics | `supports_format_breakdown` | | Audience | `by_audience` | `audience_id`, `audience_source`, `impressions`, `spend` | `audience_name` | `supports_audience_breakdown` | | Demographic | `by_demographic` | `demographic`, `demographic_system`, `impressions`, `spend` | `age`, other delivery metrics | `supports_demographic_breakdown` | | Placement | `by_placement` | `placement_id`, `impressions`, `spend` | `publisher_domain`, `placement_name` | `supports_placement_breakdown` | @@ -925,6 +927,8 @@ Geo, device, audience, demographic, placement, creative, keyword, and catalog_it Check `reporting_capabilities` on the product to discover which dimensions are available. Product-level capabilities are authoritative since different products from the same seller may support different breakdowns. +`by_format` uses the AdCP 3.1 canonical `format_kind` vocabulary. It answers cross-kind questions such as how `video_hosted`, `native_in_feed`, and `image` delivery compare; it does not distinguish two creatives that share a format kind but differ by duration, dimensions, or another format-option parameter. Sellers MUST combine adopter-defined shapes into one `custom` row. When `by_format_truncated` is false, additive metrics such as impressions and spend SHOULD reconcile to package totals, subject to each metric's measurement and attribution semantics. `by_format_sorted_by` and `by_format_sort_direction` echo the sort actually applied, so a fallback to `spend` is visible rather than silent. Buyers MUST NOT expect row-level correspondence between `by_format` and `by_creative` because they are independently produced at different grains. Requested dimensional breakdowns, including `by_format`, are negotiated on the GET path. Reporting webhook configuration does not negotiate or guarantee them, although webhook payloads MAY carry the same fields as extensions. Requested dimensions do not apply to `windows[].by_package`; those rows remain shape-aligned with webhook fires for pull recovery. + `demographic_targeting` declares what the product can execute; `supports_demographic_breakdown` separately declares what it can measure and report. A product can therefore target 26–30 but disclose fixed 25–34 reporting. Buyers MUST NOT infer exact reporting from targeting capability or parse equivalence from a native label. `demographic` uses the notation of its accompanying `demographic_system`; canonical `age`, when present, is the authoritative machine-comparable interval. `supports_geo_breakdown`, `supports_demographic_breakdown`, and `supports_spot_breakdown` are structured capability objects; the other declarations in this table are boolean flags. Demographic capability declares exact predicates versus fixed intervals, available measurement systems, and whether small cells may be suppressed. `supports_spot_breakdown.available_metrics` lists metrics available at spot grain; an empty array means the product supplies as-run airing evidence but no per-spot delivery metrics. diff --git a/package.json b/package.json index 5f65058979..a1f774b446 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "deploy:cdn-artifacts-cutover:dry-run": "wrangler deploy --config workers/artifact-cdn/wrangler.cutover.toml --dry-run", "verify:cdn-artifacts-cutover": "node scripts/verify-cdn-artifacts-cutover.mjs", "typecheck": "tsc --project server/tsconfig.json --noEmit", - "test:schemas": "node tests/schema-validation.test.cjs && node --test 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/compact-product-lifecycle-storyboards.test.cjs tests/timezone-resolution-storyboards.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/metric-identity-coherence.test.cjs tests/sort-contract-delivery-reporting.test.cjs tests/time-based-views-contract.test.cjs tests/metric-qualifier-parity.test.cjs tests/requested-metrics-contract.test.cjs tests/auto-breakdown-negotiation-contract.test.cjs tests/lint-schema-enum-drift.test.cjs tests/synthetic-depiction.test.cjs tests/creative-rendering-authority.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/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/compact-product-lifecycle-storyboards.test.cjs tests/timezone-resolution-storyboards.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/metric-identity-coherence.test.cjs tests/sort-contract-delivery-reporting.test.cjs tests/time-based-views-contract.test.cjs tests/metric-qualifier-parity.test.cjs tests/requested-metrics-contract.test.cjs tests/auto-breakdown-negotiation-contract.test.cjs tests/format-delivery-reporting-contract.test.cjs tests/lint-schema-enum-drift.test.cjs tests/synthetic-depiction.test.cjs tests/creative-rendering-authority.test.cjs && npm run test:premium-display-formats && npm run test:geo-region-targeting", "test:performance-feedback": "node --test --test-force-exit --test-timeout=30000 tests/performance-feedback-contract.test.cjs", "test:dist-schema-version-ids": "node --test --test-force-exit --test-timeout=30000 tests/dist-schema-version-ids.test.cjs", "test:examples": "node tests/example-validation-simple.test.cjs && npm run test:tmp-context-merge", diff --git a/static/schemas/source/core/canonical-reporting-capabilities.json b/static/schemas/source/core/canonical-reporting-capabilities.json index 5c558b66cd..a0b8cb1433 100644 --- a/static/schemas/source/core/canonical-reporting-capabilities.json +++ b/static/schemas/source/core/canonical-reporting-capabilities.json @@ -26,6 +26,7 @@ } }, "supports_creative_breakdown": { "type": "boolean" }, + "supports_format_breakdown": { "type": "boolean" }, "supports_keyword_breakdown": { "type": "boolean" }, "supports_geo_breakdown": { "$ref": "/schemas/core/geo-breakdown-support.json" }, "supports_device_type_breakdown": { "type": "boolean" }, diff --git a/static/schemas/source/core/reporting-capabilities.json b/static/schemas/source/core/reporting-capabilities.json index 4f40a83c6f..ec84502373 100644 --- a/static/schemas/source/core/reporting-capabilities.json +++ b/static/schemas/source/core/reporting-capabilities.json @@ -82,6 +82,10 @@ "type": "boolean", "description": "Whether this product supports creative-level metric breakdowns in delivery reporting (by_creative within by_package)" }, + "supports_format_breakdown": { + "type": "boolean", + "description": "Whether this product supports canonical creative-format breakdowns in GET delivery reporting (by_format within by_package, keyed by format_kind). This is independent from supports_creative_breakdown because a seller may expose aggregate format-grain reporting without exposing individual creative performance." + }, "supports_keyword_breakdown": { "type": "boolean", "description": "Whether this product supports keyword-level metric breakdowns in delivery reporting (by_keyword within by_package)" diff --git a/static/schemas/source/media-buy/get-media-buy-delivery-request.json b/static/schemas/source/media-buy/get-media-buy-delivery-request.json index 132e154ce9..23c78e6d6c 100644 --- a/static/schemas/source/media-buy/get-media-buy-delivery-request.json +++ b/static/schemas/source/media-buy/get-media-buy-delivery-request.json @@ -324,6 +324,28 @@ }, "additionalProperties": true }, + "format": { + "type": "object", + "description": "Request delivery broken down by canonical creative format kind. This dimension is negotiated on the GET path. Reporting webhook configuration does not negotiate or guarantee dimensional breakdowns, although a webhook payload may carry the same fields as an extension.", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of format rows to return. When omitted, all rows are returned because the canonical format-kind vocabulary is small and bounded." + }, + "sort_by": { + "$ref": "/schemas/enums/sort-metric.json", + "description": "Metric to sort breakdown rows by, in `sort_direction` order (descending by default). Falls back to 'spend' when the seller does not report the requested metric at this breakdown's row grain; on fallback the sort direction resets to 'desc'. Rows lacking a value for the applied sort metric order last regardless of direction. The applied sort is echoed in the response.", + "default": "spend" + }, + "sort_direction": { + "$ref": "/schemas/enums/sort-direction.json", + "description": "Direction for sort_by ordering. Defaults to 'desc' (largest first). 'asc' enables bottom-N queries (e.g., the 25 worst placements by viewable_rate) that cannot be recovered from a truncated descending pull. Sellers MUST apply the requested direction to the applied sort metric — direction has no availability fallback.", + "default": "desc" + } + }, + "additionalProperties": true + }, "audience": { "type": "object", "description": "Request audience segment breakdown.", diff --git a/static/schemas/source/media-buy/get-media-buy-delivery-response.json b/static/schemas/source/media-buy/get-media-buy-delivery-response.json index 39d8f4c2f8..927e38a2c7 100644 --- a/static/schemas/source/media-buy/get-media-buy-delivery-response.json +++ b/static/schemas/source/media-buy/get-media-buy-delivery-response.json @@ -412,6 +412,43 @@ "$ref": "/schemas/core/creative-delivery-metrics.json" } }, + "by_format": { + "type": "array", + "description": "Delivery by canonical creative format kind within this package. Negotiated on the GET path when the buyer requests reporting_dimensions.format and the product declares supports_format_breakdown; reporting webhook configuration does not negotiate or guarantee this breakdown. Each row aggregates every served creative of that format kind. Sellers MUST aggregate all delivery using adopter-defined shapes into one format_kind 'custom' row. When by_format_truncated is false, additive metrics such as impressions and spend across the rows SHOULD reconcile to the corresponding package totals, subject to the measurement and attribution semantics of each metric. Buyers MUST NOT expect row-level correspondence between by_format and by_creative because the two breakdowns are independently produced at different grains.", + "items": { + "allOf": [ + { + "$ref": "/schemas/core/delivery-metrics.json" + }, + { + "type": "object", + "properties": { + "format_kind": { + "$ref": "/schemas/core/canonical-format-kind.json", + "description": "Canonical creative format kind aggregated by this row. This identifies creative shape, not duration or other format-option parameters." + } + }, + "required": [ + "format_kind", + "impressions", + "spend" + ] + } + ] + } + }, + "by_format_truncated": { + "type": "boolean", + "description": "Whether by_format was truncated due to the requested limit or a seller-imposed maximum. Sellers MUST return this flag whenever by_format is present (false means the list is complete)." + }, + "by_format_sorted_by": { + "$ref": "/schemas/enums/sort-metric.json", + "description": "The metric actually used to order by_format rows. Sellers MUST return this field whenever by_format is present. When the seller cannot sort by the requested sort_by metric it falls back to 'spend'; this echo makes the fallback visible instead of silently returning rows the buyer will misread as ordered by the requested metric." + }, + "by_format_sort_direction": { + "$ref": "/schemas/enums/sort-direction.json", + "description": "The direction actually applied to by_format ordering. Sellers MUST return this field whenever by_format is present." + }, "by_creative_truncated": { "type": "boolean", "description": "Whether by_creative was truncated due to the requested limit or a seller-imposed maximum. Sellers MUST return this flag whenever by_creative is present and the request included reporting_dimensions.creative (false means the list is complete). When the breakdown was returned automatically without a request key, the flag is RECOMMENDED but not required — automatic rows carry no completeness contract." @@ -826,7 +863,7 @@ }, "by_package": { "type": "array", - "description": "Per-package metrics for this window slice. Same shape as the parent media_buy_deliveries[].by_package row but scoped to the window. Sellers MAY omit when per-package window-level data is unavailable; when present, package_id values MUST match the parent by_package entries.", + "description": "Per-package metrics for this window slice, using the same metric envelope and package identity as the parent media_buy_deliveries[].by_package row but scoped to the window. Requested reporting_dimensions do not apply to these webhook-aligned recovery rows; sellers may include dimensional fields only as webhook payload extensions, not as a guaranteed result of the GET request. Sellers MAY omit by_package when per-package window-level data is unavailable; when present, package_id values MUST match the parent by_package entries.", "items": { "allOf": [ { diff --git a/tests/format-delivery-reporting-contract.test.cjs b/tests/format-delivery-reporting-contract.test.cjs new file mode 100644 index 0000000000..983e5126f0 --- /dev/null +++ b/tests/format-delivery-reporting-contract.test.cjs @@ -0,0 +1,212 @@ +const fs = require("fs"); +const path = require("path"); +const Ajv = require("ajv"); +const addFormats = require("ajv-formats"); +const { describe, it, before } = require("node:test"); +const assert = require("node:assert/strict"); + +const SCHEMA_ROOT = path.join(__dirname, "..", "static", "schemas", "source"); + +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, + loadSchema: async (ref) => readSchema(ref), + }); + addFormats(ajv); + return ajv.compileAsync(schema); +} + +describe("canonical format delivery reporting", () => { + let validateRequest; + let validateFormatRow; + let validateByPackageExtension; + let validateCapabilities; + + before(async () => { + const request = readSchema( + "/schemas/media-buy/get-media-buy-delivery-request.json" + ); + const response = readSchema( + "/schemas/media-buy/get-media-buy-delivery-response.json" + ); + const byPackage = + response.properties.media_buy_deliveries.items.properties.by_package.items; + const byPackageExtension = byPackage.allOf.find( + (schema) => schema.properties + ); + + [ + validateRequest, + validateFormatRow, + validateByPackageExtension, + validateCapabilities, + ] = + await Promise.all([ + compile(request), + compile(byPackageExtension.properties.by_format.items), + compile(byPackageExtension), + compile(readSchema("/schemas/core/reporting-capabilities.json")), + ]); + }); + + it("accepts an opt-in format breakdown request and rejects invalid limits", () => { + assert.equal(validateRequest({ reporting_dimensions: { format: {} } }), true); + assert.equal( + validateRequest({ + reporting_dimensions: { + format: { limit: 10, sort_by: "impressions" }, + }, + }), + true, + JSON.stringify(validateRequest.errors) + ); + assert.equal( + validateRequest({ + reporting_dimensions: { + format: { sort_by: "completed_views", sort_direction: "asc" }, + }, + }), + true, + JSON.stringify(validateRequest.errors) + ); + assert.equal( + validateRequest({ + reporting_dimensions: { format: { sort_direction: "ascending" } }, + }), + false + ); + assert.equal( + validateRequest({ reporting_dimensions: { format: { limit: 0 } } }), + false + ); + }); + + it("echoes the applied sort for format rows", () => { + const response = readSchema( + "/schemas/media-buy/get-media-buy-delivery-response.json" + ); + const extension = response.properties.media_buy_deliveries.items.properties + .by_package.items.allOf.find((schema) => schema.properties); + assert.equal( + extension.properties.by_format_sorted_by.$ref, + "/schemas/enums/sort-metric.json" + ); + assert.equal( + extension.properties.by_format_sort_direction.$ref, + "/schemas/enums/sort-direction.json" + ); + assert.match( + extension.properties.by_format_sorted_by.description, + /MUST return this field whenever by_format is present/ + ); + }); + + it("requires canonical format identity plus impressions and spend", () => { + for (const row of [ + { format_kind: "video_hosted", impressions: 40000, spend: 1250 }, + { format_kind: "image", impressions: 18000, spend: 450 }, + { format_kind: "custom", impressions: 1200, spend: 90 }, + ]) { + assert.equal( + validateFormatRow(row), + true, + JSON.stringify(validateFormatRow.errors) + ); + } + + assert.equal( + validateFormatRow({ format_kind: "video", impressions: 10, spend: 1 }), + false, + "format rows use canonical format_kind rather than a coarse media family" + ); + assert.equal( + validateFormatRow({ format_kind: "image", impressions: 10 }), + false, + "spend is required" + ); + }); + + it("exposes a boolean truncation disclosure for format rows", () => { + const packageBase = { + package_id: "pkg_format_example", + spend: 1700, + pricing_model: "cpm", + rate: 20, + currency: "USD", + by_format: [ + { format_kind: "video_hosted", impressions: 40000, spend: 1250 }, + { format_kind: "image", impressions: 18000, spend: 450 }, + ], + }; + + assert.equal( + validateByPackageExtension({ + ...packageBase, + by_format_truncated: false, + }), + true, + JSON.stringify(validateByPackageExtension.errors) + ); + assert.equal( + validateByPackageExtension({ + ...packageBase, + by_format_truncated: "false", + }), + false, + "by_format_truncated must be boolean" + ); + + const response = readSchema( + "/schemas/media-buy/get-media-buy-delivery-response.json" + ); + const byPackage = + response.properties.media_buy_deliveries.items.properties.by_package.items; + const extension = byPackage.allOf.find((schema) => schema.properties); + assert.equal( + extension.properties.by_format_truncated.type, + "boolean", + "the response schema exposes the required truncation disclosure" + ); + assert.match( + extension.properties.by_format_truncated.description, + /MUST return this flag whenever by_format is present/ + ); + }); + + it("advertises format support on both reporting capability surfaces", () => { + for (const uri of [ + "/schemas/core/reporting-capabilities.json", + "/schemas/core/canonical-reporting-capabilities.json", + ]) { + const schema = readSchema(uri); + assert.equal( + schema.properties.supports_format_breakdown.type, + "boolean", + uri + ); + } + + const capabilities = { + available_reporting_frequencies: ["daily"], + expected_delay_minutes: 60, + timezone: "UTC", + supports_webhooks: true, + available_metrics: ["impressions", "spend"], + date_range_support: "date_range", + supports_format_breakdown: true, + }; + assert.equal( + validateCapabilities(capabilities), + true, + JSON.stringify(validateCapabilities.errors) + ); + }); +});