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 3f01619b8a..0e5326a7aa 100644 --- a/docs/media-buy/media-buys/optimization-reporting.mdx +++ b/docs/media-buy/media-buys/optimization-reporting.mdx @@ -1275,6 +1275,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` | @@ -1282,6 +1283,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 99b84c0a0d..163b5a1a2e 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`. Most accept optional `limit` and `sort_by`. 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. 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`. Most accept optional `limit` and `sort_by`. `format` groups rows by canonical `format_kind`, not duration or other format-option parameters, and additionally accepts `sort_direction` (`asc`/`desc`, default `desc`) with an applied-sort echo in the response. 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. 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. | @@ -886,6 +886,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 }] }, @@ -895,7 +896,7 @@ When you include `reporting_dimensions` in the request, the response includes di } ``` -Geo, device, audience, demographic, and placement dimensions accept optional `limit` and `sort_by` (any value from the `sort-metric` enum; defaults to `spend`). 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, and placement dimensions accept optional `limit` and `sort_by` (any value from the `sort-metric` enum; defaults to `spend`). The format dimension also accepts `sort_direction` (`asc` or `desc`; defaults to `desc`) — sellers MUST apply the requested direction, fall back to `spend` only when the requested metric is not reported at row grain (direction resets to `desc` on fallback), order rows lacking the applied sort metric last, and echo the applied sort on the response via `by_format_sorted_by` and `by_format_sort_direction` (MUST whenever `by_format` is present). 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. ### Available dimensions @@ -904,6 +905,7 @@ Geo, device, audience, demographic, and placement dimensions accept optional `li | 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` | @@ -911,6 +913,8 @@ Geo, device, audience, demographic, and placement dimensions accept optional `li 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 dbdcae0334..f781abfc97 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/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/schema-deprecation-metadata.test.cjs tests/creative-rotation.test.cjs tests/lint-schema-enum-drift.test.cjs tests/synthetic-depiction.test.cjs && npm run test:geo-region-targeting", + "test:schemas": "node tests/schema-validation.test.cjs && node --test 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/schema-deprecation-metadata.test.cjs tests/creative-rotation.test.cjs tests/format-delivery-reporting-contract.test.cjs tests/lint-schema-enum-drift.test.cjs tests/synthetic-depiction.test.cjs && 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", @@ -134,7 +134,7 @@ "test": "npm run test:docs-nav && npm run test:owned-links && npm run test:release-docs-nav && npm run test:rewrite-dist-redirect-links && npm run test:rewrite-dist-links-idempotency && npm run test:docs-error-handling-copy && npm run test:schemas && npm run test:performance-feedback && npm run test:mcp-schema-projection && npm run test:dist-schema-version-ids && npm run test:examples && npm run test:extensions && npm run test:extension-schemas && npm run test:error-handling && npm run test:json-schema && npm run test:audio-radio && npm run test:adagents-catalog-only && npm run test:canonical-reference-resolver && npm run test:composed && npm run test:rejection-arm-mutex && npm run test:migrations && npm run test:hmac-vectors && npm run test:hmac-signer-conformance && npm run test:webhook-signing-vectors && npm run test:webhook-receiver-envelope && npm run test:oauth-setup-vectors && npm run test:transport-errors && npm run test:targeting-overlay-vectors && npm run test:targeting-aware-discovery && npm run test:demographic-targeting && npm run test:language-targeting && npm run test:attestation-vectors && npm run test:rights-attestations && npm run test:governance-runtime-attestations && npm run test:governance-conditions-storyboard && npm run test:signal-governance-storyboard && npm run test:audience-evidence && npm run test:status-as-of-vectors && npm run test:storyboard-scoping && npm run test:storyboard-branch-sets && npm run test:storyboard-provides-state-for && npm run test:storyboard-fixture-resolution && npm run test:storyboard-contradictions && npm run test:storyboard-context-entity && npm run test:storyboard-auth-shape && npm run test:storyboard-test-kits && npm run test:compliance-packaged-refs && npm run test:compliance-source-authority && npm run test:storyboard-sample-request-schema && npm run test:storyboard-response-schema && npm run test:storyboard-context-output-paths && npm run test:storyboard-validations-paths && npm run test:storyboard-check-enum && npm run test:update-media-buy-affected-packages && npm run test:storyboard-advisory-expiry && npm run test:storyboard-raw-mode-required && npm run test:storyboard-upstream-traffic-paths && npm run test:refine-finalize-validation-ids && npm run test:run-storyboards-schema-root && npm run test:storyboard-doc-parity && npm run test:pagination-invariant && npm run test:version-envelope && npm run test:test-dynamic-imports && npm run test:sdk-shims && npm run test:format-identity-boundaries && npm run test:sdk-runner-capability-gates && npm run test:callapi-state-change && npm run test:sign-protocol-tarball && npm run test:chat-streaming-code-fences && npm run test:certification-demo-formatting && npm run test:build-schemas-hoist-enums && npm run test:build-schemas-hoist-marked && npm run test:build-schemas-async-response-refs && npm run test:release-workflow && npm run test:immutable-release-artifacts && npm run test:patch-3-0-compat-bundle && npm run test:error-codes && npm run test:compliance-snippets && npm run test:doc-compliance-drift && npm run test:substitution-vector-names && npm run test:platform-agnostic && npm run test:oneof-discriminators && npm run test:schema-utf8 && npm run test:unit && npm run test:server-unit && npm run test:openapi && npm run typecheck", "test:all": "npm run test:schemas && npm run test:examples && npm run test:extensions && npm run test:error-handling && npm run test:snippets && npm run typecheck", "precommit:server-unit": "node scripts/precommit-server-unit.cjs", - "precommit": "bash scripts/with-timeout.sh 180 npm run test:unit && npm run test:test-dynamic-imports && npm run test:format-identity-boundaries && npm run test:callapi-state-change && bash scripts/with-timeout.sh 240 npm run precommit:server-unit && npm run typecheck", + "precommit": "bash scripts/with-timeout.sh 180 npm run test:unit && npm run test:test-dynamic-imports && npm run test:format-identity-boundaries && npm run test:callapi-state-change && bash scripts/with-timeout.sh 420 npm run precommit:server-unit && npm run typecheck", "test:storyboards": "bash scripts/run-storyboards-matrix.sh", "test:storyboards:3.0-compat": "bash scripts/run-storyboards-3-0-compat.sh", "prepare": "sh scripts/configure-git-hooks.sh", 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 52122d4753..5fbedd07d5 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 @@ -236,6 +236,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 28d233b76f..14128e182e 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 @@ -400,6 +400,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_keyword": { "type": "array", "description": "Metrics broken down by keyword within this package. One row per (keyword, match_type) pair — the same keyword with different match types appears as separate rows. Keyword-grain only: rows reflect aggregate performance of each targeted keyword, not individual search queries. Rows may not sum to package totals when a single impression is attributed to the triggering keyword only. Available for search and retail media packages when the seller supports keyword-level reporting.", @@ -742,7 +779,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) + ); + }); +}); diff --git a/tests/mcp-schema-analysis.test.cjs b/tests/mcp-schema-analysis.test.cjs index 83319ac80d..35effc2ed7 100644 --- a/tests/mcp-schema-analysis.test.cjs +++ b/tests/mcp-schema-analysis.test.cjs @@ -34,8 +34,8 @@ test("input-field weight report attributes the largest transitive schema graphs" const report = analyzeInputSchemaWeights(schemas); assert.equal(report.tool_count, 16); - assert.equal(report.definition_instances, 575); - assert.equal(report.unique_definitions, 149); + assert.equal(report.definition_instances, 576); + assert.equal(report.unique_definitions, 150); assert.equal(report.repeated_definitions, 107); assert.ok(report.repeated_definition_bytes > 180_000); @@ -155,7 +155,7 @@ test("shared dictionary resolves every experimental tool schema when explicitly }); assert.equal(view.dictionary.$id, DICTIONARY_ID); - assert.equal(Object.keys(view.dictionary.$defs).length, 149); + assert.equal(Object.keys(view.dictionary.$defs).length, 150); for (const tool of Object.values(view.tools)) { assert.equal(tool.inputSchema.$defs, undefined); assert.match(