diff --git a/.changeset/add-spot-as-run-reporting.md b/.changeset/add-spot-as-run-reporting.md new file mode 100644 index 0000000000..5b6f172410 --- /dev/null +++ b/.changeset/add-spot-as-run-reporting.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Add opt-in spot-level as-run delivery reporting for scheduled inventory, with stable spot identity, actual airing time, and explicit product capability for metrics available at spot grain. diff --git a/docs/contributing/x-entity-annotation.md b/docs/contributing/x-entity-annotation.md index c26d485fd8..cdd2e4d43b 100644 --- a/docs/contributing/x-entity-annotation.md +++ b/docs/contributing/x-entity-annotation.md @@ -105,7 +105,7 @@ High-level groupings (see the registry for full descriptions). *Categories below |---|---| | Brand & rights | `advertiser_brand`, `rights_holder_brand`, `rights_grant` | | Account & party | `account`, `operator` | -| Media buy | `media_buy`, `package`, `product`, `proposal`, `product_pricing_option` | +| Media buy | `media_buy`, `package`, `product`, `proposal`, `product_pricing_option`, `spot_airing` | | Creative | `creative`, `creative_format` | | Data & targeting | `audience`, `audience_evidence`, `audience_evidence_snapshot`, `signal`, `signal_activation_id`, `demographic_interval_id`, `event_source` | | Lists & catalogs | `collection_list`, `property_list`, `catalog`, `property` | diff --git a/docs/media-buy/media-buys/optimization-reporting.mdx b/docs/media-buy/media-buys/optimization-reporting.mdx index 5b24d5efcb..3f01619b8a 100644 --- a/docs/media-buy/media-buys/optimization-reporting.mdx +++ b/docs/media-buy/media-buys/optimization-reporting.mdx @@ -1278,8 +1278,9 @@ Delivery data can be broken down across multiple dimensions within each package. | 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` | +| Spot/as-run | `by_spot` | `spot_id`, `aired_at` | `impressions`, `network`, `station`, `daypart`, other delivery metrics | `supports_spot_breakdown` | -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` and `supports_demographic_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. +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. 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: @@ -1308,6 +1309,10 @@ Demographic rows pair a free-form `demographic` code with its `demographic_syste Placement identity is publisher-scoped. Placement rows MAY carry `publisher_domain`, the publisher namespace from the product's `placements[]` entry, so buyers can treat `{publisher_domain, placement_id}` as the stable placement identity for multi-publisher products when it is present. Sellers SHOULD emit `publisher_domain` whenever the product placement carries it (always true for `kind: "publisher_ref"`); sellers MAY omit it only for `kind: "seller_inline"` placements in a legacy single-publisher context where the seller agent's own domain is the namespace. When `publisher_domain` is omitted, buyers MAY interpret `placement_id` relative to the seller agent's own publisher domain only in that legacy single-publisher context; otherwise buyers should not infer a cross-publisher placement key. `publisher_domain` is single-valued because each placement belongs to exactly one publisher namespace. +Spot rows are channel-neutral as-run records: TV sellers commonly populate `network`, radio sellers commonly populate `station`, and either may populate both. `spot_id` is stable across measurement-window updates; `aired_at` proves the airing. Sellers MUST order rows by `aired_at` ascending. An empty `reporting_dimensions.spot` request asks for the complete log for the reporting period; an optional `limit` may bound the response. `by_spot_truncated` indicates whether the log is complete. + +Spot metrics are optional because airing evidence and audience measurement mature independently. `supports_spot_breakdown.available_metrics` tells the buyer which metrics the product can eventually produce at spot grain. A Live row may contain only `{ spot_id, aired_at }`; the same `spot_id` can reappear under C3 or C7 with `impressions`. An absent `impressions` field means unavailable at that measurement window or not declared at spot grain. `impressions: 0` means a measured zero and is never interchangeable with omission; sellers do not emit `null`. + Breakdowns are opt-in — no dimension data is returned unless explicitly requested. Sellers that don't support a requested dimension silently omit it. An unsupported `demographic.age_ranges` value on a product that otherwise supports the dimension returns `UNSUPPORTED_FEATURE`; sellers never widen or narrow it silently. Each breakdown array has a sibling `by_*_truncated` boolean indicating whether additional non-suppressed rows exist beyond the requested `limit`. ## Targeting Consistency 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 0d926de5a0..99b84c0a0d 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`. 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. 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`, `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. | | `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. | @@ -889,12 +889,13 @@ When you include `reporting_dimensions` in the request, the response includes di "demographic": { "age_ranges": [{ "min": 26, "max": 30, "include_unknown": false }] }, - "placement": { "limit": 5, "sort_by": "roas" } + "placement": { "limit": 5, "sort_by": "roas" }, + "spot": {} } } ``` -Most 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. Breakdowns are per-dimension only — cross-dimensional intersections are not supported. +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. ### Available dimensions @@ -906,18 +907,23 @@ Most dimensions accept optional `limit` and `sort_by` (any value from the `sort- | 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` | +| Spot/as-run | `by_spot` | `spot_id`, `aired_at` | `impressions`, `network`, `station`, `daypart`, other delivery metrics | `supports_spot_breakdown` | 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. `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` and `supports_demographic_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_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. Placement identity is publisher-scoped. Placement rows MAY carry `publisher_domain` — the publisher namespace from the product's `placements[]` entry — so buyers can treat `{publisher_domain, placement_id}` as the stable placement identity for multi-publisher products when it is present. Sellers SHOULD emit `publisher_domain` whenever the product placement carries it (always true for `kind: "publisher_ref"`); sellers MAY omit it only for `kind: "seller_inline"` placements in a legacy single-publisher context where the seller agent's own domain is the namespace. When `publisher_domain` is omitted, buyers MAY interpret `placement_id` relative to the seller agent's own publisher domain only in that legacy single-publisher context; otherwise buyers should not infer a cross-publisher placement key. `publisher_domain` is single-valued because each placement belongs to exactly one publisher namespace. +Spot rows are channel-neutral as-run records. Linear TV commonly supplies `network`, radio commonly supplies `station`, and sellers MAY populate both; neither is required. `spot_id` identifies the scheduled occurrence and remains stable when the same airing is re-reported across Live, C3, C7, or another measurement-window progression. `aired_at` proves that it aired. Sellers MUST order rows by `aired_at` ascending. When `reporting_dimensions.spot.limit` is omitted, sellers SHOULD return the complete log for the requested reporting period. `by_spot_truncated` tells the buyer whether the returned log is complete. + +Delivery metrics on a spot row are optional. Omission means the metric is not yet available in the package's current `measurement_window`, or the product did not declare it in `supports_spot_breakdown.available_metrics`. A later window may return the same `spot_id` with impressions populated. An explicit `impressions: 0` is a measured zero; it does not mean pending or unavailable, and `null` is invalid. + ### Truncation -Each breakdown array has a sibling boolean flag (e.g., `by_geo_truncated`). When `true`, additional rows exist beyond the returned set. When `false`, the list is complete. Sellers MUST return the truncated flag whenever the corresponding breakdown array is present. Rows are sorted by the requested `sort_by` metric descending. +Each breakdown array has a sibling boolean flag (e.g., `by_geo_truncated`). When `true`, additional rows exist beyond the returned set. When `false`, the list is complete. Sellers MUST return the truncated flag whenever the corresponding breakdown array is present. Metric dimensions are sorted by the requested `sort_by` value descending; spot rows always use `aired_at` ascending. Demographic reporting also returns `by_demographic_suppressed`. `true` means privacy, policy, or measurement thresholds omitted otherwise reportable rows, so the visible rows may not reconcile to package totals. This is distinct from truncation caused by a row limit. diff --git a/scripts/x-entity-field-map.json b/scripts/x-entity-field-map.json index edb391ee5a..b38b911d6f 100644 --- a/scripts/x-entity-field-map.json +++ b/scripts/x-entity-field-map.json @@ -6,6 +6,7 @@ "product_id": "product", "proposal_id": "proposal", "opportunity_id": "opportunity", + "spot_id": "spot_airing", "creative_id": "creative", "audience_id": "audience", "evidence_id": "audience_evidence", diff --git a/static/schemas/source/core/canonical-reporting-capabilities.json b/static/schemas/source/core/canonical-reporting-capabilities.json index c2c7b42a5e..5c558b66cd 100644 --- a/static/schemas/source/core/canonical-reporting-capabilities.json +++ b/static/schemas/source/core/canonical-reporting-capabilities.json @@ -33,6 +33,7 @@ "supports_audience_breakdown": { "type": "boolean" }, "supports_demographic_breakdown": { "$ref": "/schemas/core/demographic-reporting-capability.json" }, "supports_placement_breakdown": { "type": "boolean" }, + "supports_spot_breakdown": { "$ref": "/schemas/core/spot-reporting-capability.json" }, "date_range_support": { "type": "string", "enum": ["date_range", "lifetime_only"] }, "windowed_pull_granularities": { "type": "array", "items": { "$ref": "/schemas/enums/reporting-frequency.json" }, "uniqueItems": true }, "measurement_windows": { "type": "array", "items": { "$ref": "/schemas/core/measurement-window.json" }, "minItems": 1, "uniqueItems": true } diff --git a/static/schemas/source/core/reporting-capabilities.json b/static/schemas/source/core/reporting-capabilities.json index 11fa75a1f5..4f40a83c6f 100644 --- a/static/schemas/source/core/reporting-capabilities.json +++ b/static/schemas/source/core/reporting-capabilities.json @@ -110,6 +110,10 @@ "type": "boolean", "description": "Whether this product supports placement breakdowns in delivery reporting (by_placement within by_package)" }, + "supports_spot_breakdown": { + "$ref": "/schemas/core/spot-reporting-capability.json", + "description": "Spot-level as-run airing-log support and metrics available at spot grain for broadcast TV, radio, and other scheduled inventory." + }, "date_range_support": { "type": "string", "enum": ["date_range", "lifetime_only"], diff --git a/static/schemas/source/core/spot-reporting-capability.json b/static/schemas/source/core/spot-reporting-capability.json new file mode 100644 index 0000000000..4a88132bde --- /dev/null +++ b/static/schemas/source/core/spot-reporting-capability.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/spot-reporting-capability.json", + "title": "Spot Reporting Capability", + "description": "Product-scoped spot-level as-run reporting capability. Presence means the seller can report stable spot identities and actual airing timestamps. available_metrics declares which delivery metrics may eventually be populated at spot grain; an empty array means the product provides airing evidence only.", + "type": "object", + "properties": { + "available_metrics": { + "type": "array", + "description": "Metrics available at spot grain. A declared metric may be absent from a provisional measurement window and appear when a later window supersedes it. A metric omitted from this declaration is not promised at spot grain even when it is available at package grain.", + "items": { + "$ref": "/schemas/enums/available-metric.json" + }, + "uniqueItems": true + } + }, + "required": ["available_metrics"], + "additionalProperties": false +} diff --git a/static/schemas/source/core/x-entity-types.json b/static/schemas/source/core/x-entity-types.json index 7b2315bb28..8cf486683d 100644 --- a/static/schemas/source/core/x-entity-types.json +++ b/static/schemas/source/core/x-entity-types.json @@ -31,6 +31,7 @@ "signal", "signal_activation_id", "demographic_interval_id", + "spot_airing", "event_source", "impairment", "collection_list", @@ -77,6 +78,7 @@ "signal": "A data signal. New discovery, activation, product targeting, and buy-time surfaces use `signal_ref`; legacy `signal_id` objects are deprecated. Opaque activation ids use `signal_activation_id` instead.", "signal_activation_id": "Opaque identifier used to select or activate a signal from a signals agent or seller-offered signal feed. `signal_agent_segment_id` in signals/* schemas and product signal targeting option declarations. Scoped to the agent that issued it; not interchangeable with `signal`.", "demographic_interval_id": "A seller-scoped enumerated demographic interval exposed by Product.demographic_targeting and echoed by Package.targeting_resolution.demographics. The identifier is meaningful only with the seller product that published the interval; its authoritative age bounds travel alongside it in the product capability.", + "spot_airing": "A seller-scoped scheduled spot occurrence. `spot_id` remains stable when the same airing is re-reported across measurement windows and may be referenced by later preemption or makegood workflows. It is occurrence identity, not creative identity.", "event_source": "A conversion pixel or event feed. `event_source_id` in media-buy/sync-event-sources-request, media-buy/log-event-request, and core/event.json.", "impairment": "An open dependency-impact entry on a media buy — `impairment_id` in core/impairment.json. Stable for the lifetime of the open impairment; doubles as `notification_id` on the impairment webhook so receivers dedupe across at-least-once delivery.", "collection_list": "A buyer-managed collection list. `list_id` on collection/* schemas.", diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index 92b1db213e..90c78793b4 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -253,6 +253,10 @@ "$ref": "/schemas/core/geo-breakdown-support.json", "description": "Geographic breakdown capability declaration for reporting" }, + "spot-reporting-capability": { + "$ref": "/schemas/core/spot-reporting-capability.json", + "description": "Spot-level as-run reporting support and available spot-grain metrics" + }, "format": { "$ref": "/schemas/core/format.json", "description": "Deprecated 3.x named-format compatibility definition; use ProductFormatDeclaration canonical contracts for new integrations.", 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 abdd74f43e..ef42f12f53 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 @@ -281,6 +281,18 @@ }, "additionalProperties": false }, + "spot": { + "type": "object", + "description": "Request a spot-level as-run airing log for broadcast TV, radio, or other scheduled inventory. Rows are ordered by aired_at ascending. When limit is omitted, sellers SHOULD return the complete log for the requested reporting period.", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "description": "Optional maximum number of spot rows to return. When the response is incomplete because of this limit or a seller-imposed maximum, by_spot_truncated is true." + } + }, + "additionalProperties": true + }, "placement": { "type": "object", "description": "Request placement 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 fd7531687d..72ba5d29ec 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 @@ -598,6 +598,56 @@ "type": "boolean", "description": "Whether by_placement was truncated. Sellers MUST return this flag whenever by_placement is present (false means the list is complete)." }, + "by_spot": { + "type": "array", + "description": "Spot-level as-run airing records for broadcast TV, radio, or other scheduled inventory. Available when the buyer requests spot breakdown and the product declares supports_spot_breakdown. Sellers MUST order rows by aired_at ascending. The same spot_id is reused when a later package measurement_window adds or revises metrics. Network and station are optional so station-direct radio and network-level TV records use the same channel-neutral shape.", + "items": { + "allOf": [ + { + "$ref": "/schemas/core/delivery-metrics.json" + }, + { + "type": "object", + "properties": { + "spot_id": { + "type": "string", + "minLength": 1, + "description": "Stable seller-scoped identifier for this scheduled spot occurrence. Reused across measurement-window updates and available to later preemption or makegood workflows. Distinct from creative_id.", + "x-entity": "spot_airing" + }, + "aired_at": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 timestamp when the spot actually aired. Its presence proves an airing; it is not inferred from impressions." + }, + "network": { + "type": "string", + "minLength": 1, + "description": "Optional network or syndicated service name, for example 'USA Network' or 'Westwood One'." + }, + "station": { + "type": "string", + "minLength": 1, + "description": "Optional station identifier or call sign, for example 'WABC-TV' or 'WNYC-FM'." + }, + "daypart": { + "type": "string", + "minLength": 1, + "description": "Optional seller-defined daypart label, for example 'prime_time' or 'morning_drive'." + } + }, + "required": [ + "spot_id", + "aired_at" + ] + } + ] + } + }, + "by_spot_truncated": { + "type": "boolean", + "description": "Whether by_spot is incomplete because of the requested limit or a seller-imposed maximum. Sellers MUST return this flag whenever by_spot is present (false means the as-run log is complete for the requested reporting period)." + }, "daily_breakdown": { "type": "array", "description": "Day-by-day delivery for this package. Only present when include_package_daily_breakdown is true in the request. Enables per-package pacing analysis and line-item monitoring.", diff --git a/tests/mcp-schema-analysis.test.cjs b/tests/mcp-schema-analysis.test.cjs index 74df9b912d..bf7afe6849 100644 --- a/tests/mcp-schema-analysis.test.cjs +++ b/tests/mcp-schema-analysis.test.cjs @@ -215,7 +215,7 @@ test("experiment report keeps all alternatives smaller than standalone model con assert.equal(report.status, "non-normative"); assert.equal(report.prompt_cleanup_adapter.required, true); assert.equal(report.selection.tools.length, 16); - assert.equal(variants.standalone.context_bytes, 285_144); + assert.equal(variants.standalone.context_bytes, 285_239); assert.ok( variants.prompt_cleanup.context_bytes < variants.standalone.context_bytes * 0.82 diff --git a/tests/spot-delivery-reporting-contract.test.cjs b/tests/spot-delivery-reporting-contract.test.cjs new file mode 100644 index 0000000000..03b1b0c868 --- /dev/null +++ b/tests/spot-delivery-reporting-contract.test.cjs @@ -0,0 +1,146 @@ +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("spot-level as-run delivery reporting", () => { + let validateRequest; + let validateSpot; + 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, validateSpot, validateCapabilities] = await Promise.all([ + compile(request), + compile(byPackageExtension.properties.by_spot.items), + compile(readSchema("/schemas/core/reporting-capabilities.json")), + ]); + }); + + it("accepts an opt-in complete spot log request and rejects invalid limits", () => { + assert.equal(validateRequest({ reporting_dimensions: { spot: {} } }), true); + assert.equal( + validateRequest({ reporting_dimensions: { spot: { limit: 100 } } }), + true + ); + assert.equal( + validateRequest({ reporting_dimensions: { spot: { limit: 0 } } }), + false + ); + }); + + it("uses one channel-neutral row shape for TV and radio airings", () => { + for (const row of [ + { + spot_id: "spot_tv_001", + aired_at: "2026-08-15T20:14:00Z", + network: "USA Network", + station: "WABC-TV", + daypart: "prime_time", + impressions: 40000, + spend: 1250, + }, + { + spot_id: "spot_radio_001", + aired_at: "2026-08-15T07:30:00Z", + station: "WNYC-FM", + daypart: "morning_drive", + impressions: 18000, + }, + ]) { + assert.equal(validateSpot(row), true, JSON.stringify(validateSpot.errors)); + } + }); + + it("requires stable identity and an RFC 3339 airing timestamp, not impressions", () => { + const asRunOnly = { + spot_id: "spot_live_001", + aired_at: "2026-08-15T20:14:00Z", + }; + assert.equal( + validateSpot(asRunOnly), + true, + JSON.stringify(validateSpot.errors) + ); + assert.equal( + validateSpot({ ...asRunOnly, impressions: 0 }), + true, + "measured zero is explicit" + ); + assert.equal( + validateSpot({ ...asRunOnly, impressions: null }), + false, + "unavailable is omitted, not null" + ); + assert.equal(validateSpot({ spot_id: "spot_bad", aired_at: "not-a-time" }), false); + assert.equal(validateSpot({ aired_at: "2026-08-15T20:14:00Z" }), false); + assert.equal(validateSpot({ spot_id: "spot_missing_time" }), false); + }); + + it("advertises spot 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_spot_breakdown.$ref, + "/schemas/core/spot-reporting-capability.json", + 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_spot_breakdown: { available_metrics: ["impressions"] }, + }; + assert.equal( + validateCapabilities(capabilities), + true, + JSON.stringify(validateCapabilities.errors) + ); + capabilities.supports_spot_breakdown.available_metrics = []; + assert.equal( + validateCapabilities(capabilities), + true, + "airing-only reporting is explicit" + ); + }); +});