From 7a0b5785c53c3df69905e26cf66a1182bf285277 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 17 Aug 2026 00:35:22 +0200 Subject: [PATCH 1/6] feat(reporting): add leaf metric identities for nested delivery values quartile_25..quartile_100 and the viewability leaves (viewable_rate, viewable_impressions, measurable_impressions, viewed_seconds) become first-class metric identities in available-metric and sort-metric, resolving to their nested canonical values with no duplicate flat fields. Closes the existing contradiction where committed-metric qualifier rules and the delivery-metric-aggregate viewable_rate conditional referenced unrepresentable metric_ids and shipped examples failed their own schemas. Also restores sort-metric's claimed coverage of flat numeric delivery metrics (cpm, cost_per_completed_view, downloads, units_sold, new_to_brand_units, plays, commissionable_value), with lift scalars documented as intentionally sort-excluded. Adds a metric-identity coherence contract test enforcing enum/schema/example agreement. Co-Authored-By: Claude Fable 5 --- .changeset/metric-leaf-identities.md | 5 + .../media-buys/optimization-reporting.mdx | 2 + .../task-reference/get_media_buy_delivery.mdx | 2 +- package.json | 2 +- .../schemas/source/core/delivery-metrics.json | 4 +- .../source/enums/available-metric.json | 10 +- static/schemas/source/enums/sort-metric.json | 19 +- tests/metric-identity-coherence.test.cjs | 164 ++++++++++++++++++ 8 files changed, 201 insertions(+), 7 deletions(-) create mode 100644 .changeset/metric-leaf-identities.md create mode 100644 tests/metric-identity-coherence.test.cjs diff --git a/.changeset/metric-leaf-identities.md b/.changeset/metric-leaf-identities.md new file mode 100644 index 0000000000..04dbd639a8 --- /dev/null +++ b/.changeset/metric-leaf-identities.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Add leaf metric identities so nested delivery values are individually declarable, committable, aggregatable, and sortable: `quartile_25`–`quartile_100` (resolving to `quartile_data.q1_views`–`q4_views`) and `viewable_rate`, `viewable_impressions`, `measurable_impressions`, `viewed_seconds` (resolving to the same-named `viewability` fields) join `available-metric` and `sort-metric`. This closes an existing contradiction: `committed-metric` qualifier rules and `delivery-metric-aggregate` conditionals already referenced these metric_ids, and the shipped `committed_metrics` / `metric_aggregates` examples were invalid against their own schemas. Also adds the missing flat transactional scalars (`commissionable_value`, `plays`, `cost_per_completed_view`, `cpm`, `downloads`, `units_sold`, `new_to_brand_units`) to `sort-metric`, with survey/model-based lift scalars documented as intentionally sort-excluded. Leaf identities resolve to the nested canonical values — no duplicate flat response fields are introduced. A metric-identity coherence contract test now enforces enum/schema/example agreement. diff --git a/docs/media-buy/media-buys/optimization-reporting.mdx b/docs/media-buy/media-buys/optimization-reporting.mdx index 3f01619b8a..0020f50bb5 100644 --- a/docs/media-buy/media-buys/optimization-reporting.mdx +++ b/docs/media-buy/media-buys/optimization-reporting.mdx @@ -313,6 +313,8 @@ Buyers receive the intersection of both. `impressions` and `spend` are always re - **`profile_visits`**: Visits to the brand's in-platform page - **`viewability`**: Viewability data (measurable_impressions, viewable_impressions, viewable_rate, viewed_seconds, standard, vendor). Separates MRC and GroupM standards. `viewed_seconds` is the average in-view duration per measurable impression — reporting-side counterpart to the `viewed_seconds` optimization goal, governed by the same `standard` threshold as `viewable_rate`. The optional `vendor` field carries a `BrandRef` so the row is self-describing — buyer agents reading delivery in isolation can attribute the numbers to a measurement vendor without joining back to `package.committed_metrics` or `package.performance_standards`. - **`quartile_data`**: Video quartile completion data (q1-q4); `null` when not applicable (e.g. a non-video buy) + +The numeric values inside `viewability` and `quartile_data` are individually addressable through leaf metric identities — `viewable_rate`, `viewable_impressions`, `measurable_impressions`, `viewed_seconds`, and `quartile_25`/`quartile_50`/`quartile_75`/`quartile_100` (which resolve to `quartile_data.q1_views`–`q4_views`). Leaf identities appear in `available_metrics` declarations, `committed_metrics` contracts, `metric_aggregates` rows, and breakdown `sort_by`; the nested object remains the only carrier of the values in delivery reports. Note that `quartile_100` counts 100%-of-duration completions (VAST `complete`), which is distinct from `completed_views` when the buy defines a shorter billable view threshold. - **`dooh_metrics`**: DOOH-specific metrics (loop plays, screens, venue breakdown) - **`cost_per_click`**: Cost per click (`spend / clicks`) - **`cost_per_completed_view`**: Cost per completed view (`spend / completed_views`); CPCV pricing scalar for video/audio inventory 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..11ee035952 100644 --- a/docs/media-buy/task-reference/get_media_buy_delivery.mdx +++ b/docs/media-buy/task-reference/get_media_buy_delivery.mdx @@ -895,7 +895,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, audience, demographic, and placement dimensions accept optional `limit` and `sort_by` (any value from the `sort-metric` enum; defaults to `spend`). 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 placements by in-view rate" need no flat duplicate fields. Survey/model-based lift scalars are excluded from sorting; see the `sort-metric` enum description. 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 diff --git a/package.json b/package.json index 058ddff60b..ce5dc7da71 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/metric-identity-coherence.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", diff --git a/static/schemas/source/core/delivery-metrics.json b/static/schemas/source/core/delivery-metrics.json index a193f5095f..65d778045b 100644 --- a/static/schemas/source/core/delivery-metrics.json +++ b/static/schemas/source/core/delivery-metrics.json @@ -189,7 +189,7 @@ }, "quartile_data": { "type": ["object", "null"], - "description": "Audio/video quartile completion data. Null indicates the metric is not applicable to this package/buy (e.g. quartile data on a non-video buy).", + "description": "Audio/video quartile completion data. Null indicates the metric is not applicable to this package/buy (e.g. quartile data on a non-video buy). Individual quartiles are addressable via the leaf metric identities `quartile_25` (q1_views), `quartile_50` (q2_views), `quartile_75` (q3_views), and `quartile_100` (q4_views) for declaration, commitments, aggregates, and breakdown sorting; this object remains the canonical carrier of the values. Quartiles are player-fired events (VAST firstQuartile/midpoint/thirdQuartile/complete). `quartile_100` counts 100%-of-duration completions and is distinct from `completed_views`, which counts completions at the seller's billable view threshold (`view_duration_seconds`) when one is set.", "properties": { "q1_views": { "type": "number", @@ -288,7 +288,7 @@ }, "viewability": { "type": "object", - "description": "Viewability metrics. Viewable rate should be calculated as viewable_impressions / measurable_impressions (not total impressions), since some environments cannot measure viewability. Includes `viewed_seconds` — average in-view duration — since duration is governed by the same viewability threshold (`standard`) and shares the same `measurable_impressions` denominator. Sellers SHOULD include `standard` whenever measured viewability values are reported because MRC and GroupM rows are not interchangeable.", + "description": "Viewability metrics. Viewable rate should be calculated as viewable_impressions / measurable_impressions (not total impressions), since some environments cannot measure viewability. Includes `viewed_seconds` — average in-view duration — since duration is governed by the same viewability threshold (`standard`) and shares the same `measurable_impressions` denominator. Sellers SHOULD include `standard` whenever measured viewability values are reported because MRC and GroupM rows are not interchangeable. The numeric leaves are addressable via the leaf metric identities `viewable_rate`, `viewable_impressions`, `measurable_impressions`, and `viewed_seconds` for declaration, commitments, aggregates, and breakdown sorting; this object remains the canonical carrier of the values. When a buy reports under more than one standard, contract a specific standard via the `viewability_standard` qualifier on `committed_metrics`.", "properties": { "vendor": { "$ref": "/schemas/core/brand-ref.json", diff --git a/static/schemas/source/enums/available-metric.json b/static/schemas/source/enums/available-metric.json index f21dd004c4..f33a02932c 100644 --- a/static/schemas/source/enums/available-metric.json +++ b/static/schemas/source/enums/available-metric.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/enums/available-metric.json", "title": "Available Metric", - "description": "Standard delivery and performance metrics available for reporting", + "description": "Standard delivery and performance metrics available for reporting. Most values name flat numeric fields of `core/delivery-metrics.json`. Leaf identities address a single numeric value nested inside an object-shaped metric so it can be declared, committed, aggregated, and sorted individually: `quartile_25`/`quartile_50`/`quartile_75`/`quartile_100` resolve to `quartile_data.q1_views`–`q4_views`, and `viewable_rate`/`viewable_impressions`/`measurable_impressions`/`viewed_seconds` resolve to the same-named fields of `viewability`. The nested object remains the canonical carrier of the reported value — leaf identities never introduce duplicate flat response fields. The object-shaped tokens (`viewability`, `quartile_data`, `dooh_metrics`) declare the whole nested surface at once.", "type": "string", "enum": [ "impressions", @@ -28,7 +28,15 @@ "saves", "profile_visits", "viewability", + "viewable_rate", + "viewable_impressions", + "measurable_impressions", + "viewed_seconds", "quartile_data", + "quartile_25", + "quartile_50", + "quartile_75", + "quartile_100", "dooh_metrics", "cost_per_click", "cost_per_completed_view", diff --git a/static/schemas/source/enums/sort-metric.json b/static/schemas/source/enums/sort-metric.json index 6ee1283aba..7582d4c769 100644 --- a/static/schemas/source/enums/sort-metric.json +++ b/static/schemas/source/enums/sort-metric.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/enums/sort-metric.json", "title": "Sort Metric", - "description": "Numeric delivery metrics available for sorting breakdown rows. Subset of delivery-metrics fields that are flat numeric values (excludes nested objects like quartile_data, dooh_metrics, viewability, by_event_type, by_action_source).", + "description": "Numeric delivery metrics available for sorting breakdown rows. Contains the flat numeric fields of `core/delivery-metrics.json` plus leaf identities that resolve to a single numeric value nested inside an object-shaped metric: `quartile_25`–`quartile_100` order rows by `quartile_data.q1_views`–`q4_views`; `viewable_rate`, `viewable_impressions`, `measurable_impressions`, and `viewed_seconds` order rows by the same-named fields of `viewability`. Sorting by a leaf identity orders on the nested value — the nested object remains the only carrier of the value in responses; no duplicate flat fields exist. Excludes object/array containers (`quartile_data`, `dooh_metrics`, `viewability`, `by_event_type`, `by_action_source`, `vendor_metric_values`) and the survey/model-based lift scalars (`incremental_sales_lift`, `brand_lift`, `foot_traffic`, `conversion_lift`, `brand_search_lift`), which are package-grain estimates rather than row-grain orderings.", "type": "string", "enum": [ "impressions", @@ -26,6 +26,21 @@ "saves", "profile_visits", "engagement_rate", - "cost_per_click" + "cost_per_click", + "commissionable_value", + "plays", + "cost_per_completed_view", + "cpm", + "downloads", + "units_sold", + "new_to_brand_units", + "viewable_rate", + "viewable_impressions", + "measurable_impressions", + "viewed_seconds", + "quartile_25", + "quartile_50", + "quartile_75", + "quartile_100" ] } diff --git a/tests/metric-identity-coherence.test.cjs b/tests/metric-identity-coherence.test.cjs new file mode 100644 index 0000000000..3893e541d4 --- /dev/null +++ b/tests/metric-identity-coherence.test.cjs @@ -0,0 +1,164 @@ +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); +} + +// Leaf metric identities address one numeric value nested inside an +// object-shaped delivery metric. The nested object stays the canonical +// carrier; the identity exists so commitments, aggregates, and sorts can +// reference the single value. +const LEAF_IDENTITIES = { + quartile_25: ["quartile_data", "q1_views"], + quartile_50: ["quartile_data", "q2_views"], + quartile_75: ["quartile_data", "q3_views"], + quartile_100: ["quartile_data", "q4_views"], + viewable_rate: ["viewability", "viewable_rate"], + viewable_impressions: ["viewability", "viewable_impressions"], + measurable_impressions: ["viewability", "measurable_impressions"], + viewed_seconds: ["viewability", "viewed_seconds"], +}; + +// Package-grain survey/model-based estimates: reportable and committable, +// but excluded from row sorting per sort-metric.json's description. +const SORT_EXCLUDED_LIFT_METRICS = [ + "incremental_sales_lift", + "brand_lift", + "foot_traffic", + "conversion_lift", + "brand_search_lift", +]; + +describe("metric identity coherence", () => { + let deliveryMetrics; + let availableMetrics; + let sortMetrics; + + before(() => { + deliveryMetrics = readSchema("/schemas/core/delivery-metrics.json"); + availableMetrics = new Set(readSchema("/schemas/enums/available-metric.json").enum); + sortMetrics = new Set(readSchema("/schemas/enums/sort-metric.json").enum); + }); + + function flatNumericDeliveryMetrics() { + return Object.entries(deliveryMetrics.properties) + .filter(([, schema]) => { + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + return types.includes("number") || types.includes("integer"); + }) + .map(([name]) => name); + } + + it("declares every flat numeric delivery metric in available-metric", () => { + for (const name of flatNumericDeliveryMetrics()) { + assert.ok(availableMetrics.has(name), `${name} missing from available-metric.json`); + } + }); + + it("makes every flat numeric delivery metric sortable except lift estimates", () => { + for (const name of flatNumericDeliveryMetrics()) { + if (SORT_EXCLUDED_LIFT_METRICS.includes(name)) { + assert.ok( + !sortMetrics.has(name), + `${name} is documented as sort-excluded but present in sort-metric.json` + ); + continue; + } + assert.ok(sortMetrics.has(name), `${name} missing from sort-metric.json`); + } + }); + + it("keeps sort-metric a subset of available-metric", () => { + for (const name of sortMetrics) { + assert.ok(availableMetrics.has(name), `${name} sortable but not declarable`); + } + }); + + it("resolves every leaf identity to an existing nested numeric value", () => { + for (const [leaf, [container, field]] of Object.entries(LEAF_IDENTITIES)) { + assert.ok(availableMetrics.has(leaf), `${leaf} missing from available-metric.json`); + assert.ok(sortMetrics.has(leaf), `${leaf} missing from sort-metric.json`); + const nested = deliveryMetrics.properties[container]; + assert.ok(nested, `${container} missing from delivery-metrics.json`); + const target = nested.properties[field]; + assert.ok(target, `${container}.${field} missing from delivery-metrics.json`); + const types = Array.isArray(target.type) ? target.type : [target.type]; + assert.ok( + types.includes("number") || types.includes("integer"), + `${container}.${field} is not numeric` + ); + // No duplicate flat field: the leaf identity must not also exist as a + // top-level delivery-metrics property (the nested value is canonical). + assert.equal( + deliveryMetrics.properties[leaf], + undefined, + `${leaf} must not exist as a flat delivery-metrics field` + ); + } + }); + + it("only conditions delivery-metric-aggregate on representable metric_ids", () => { + const aggregate = readSchema("/schemas/core/delivery-metric-aggregate.json"); + const standardBranch = aggregate.oneOf.find( + (branch) => branch.properties.scope.const === "standard" + ); + for (const conditional of standardBranch.allOf) { + const metricId = conditional.if.properties.metric_id.const; + assert.ok( + availableMetrics.has(metricId), + `delivery-metric-aggregate conditions on unrepresentable metric_id ${metricId}` + ); + } + }); + + it("validates the shipped committed_metrics example against its own schema", async () => { + const validate = await compile(readSchema("/schemas/core/committed-metric.json")); + const pkg = readSchema("/schemas/core/package.json"); + const example = pkg.properties.committed_metrics.examples[0]; + for (const entry of example) { + assert.equal( + validate(entry), + true, + `${JSON.stringify(entry)} -> ${JSON.stringify(validate.errors)}` + ); + } + }); + + it("validates the shipped metric_aggregates example against its own schema", async () => { + const validate = await compile( + readSchema("/schemas/core/delivery-metric-aggregate.json") + ); + const response = readSchema( + "/schemas/media-buy/get-media-buy-delivery-response.json" + ); + const example = + response.properties.aggregated_totals.properties.metric_aggregates.examples[0]; + for (const entry of example) { + assert.equal( + validate(entry), + true, + `${JSON.stringify(entry)} -> ${JSON.stringify(validate.errors)}` + ); + } + }); +}); From e5c65973bddc6c74fe35e397b62703e53ba77dee Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 17 Aug 2026 01:26:06 +0200 Subject: [PATCH 2/6] feat(reporting): vendor-scope metric qualifier symmetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vendor-branch qualifier on delivery-metric-aggregate was a closed object with no properties, so only {} could validate. Give it the same closed 5-key set as the standard branch, and add the optional qualifier to the vendor branches of committed-metric and missing-metric — matching what canonical-reporting-commitment already allows — so vendor metrics measured under different attribution windows or methodologies are distinguishable on the contract and reconciliation surfaces. A parity contract test now enforces an identical closed key set across every hand-maintained qualifier copy. Co-Authored-By: Claude Fable 5 --- .../metric-qualifier-vendor-symmetry.md | 5 + .../task-reference/create_media_buy.mdx | 2 + package.json | 2 +- .../schemas/source/core/committed-metric.json | 27 ++ .../core/delivery-metric-aggregate.json | 24 +- .../schemas/source/core/missing-metric.json | 22 ++ tests/metric-qualifier-parity.test.cjs | 238 ++++++++++++++++++ 7 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 .changeset/metric-qualifier-vendor-symmetry.md create mode 100644 tests/metric-qualifier-parity.test.cjs diff --git a/.changeset/metric-qualifier-vendor-symmetry.md b/.changeset/metric-qualifier-vendor-symmetry.md new file mode 100644 index 0000000000..dd86b8eaa6 --- /dev/null +++ b/.changeset/metric-qualifier-vendor-symmetry.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Fix the vendor-scope qualifier on `delivery-metric-aggregate` (previously a closed object with no properties, so only `{}` could validate) and add the optional 5-key qualifier to the vendor branches of `committed-metric` and `missing-metric`, matching what `canonical-reporting-commitment` already allows — vendor metrics measured under different attribution windows or methodologies are now distinguishable on the contract and reconciliation surfaces. A qualifier parity contract test now enforces an identical closed key set across every hand-maintained copy. diff --git a/docs/media-buy/task-reference/create_media_buy.mdx b/docs/media-buy/task-reference/create_media_buy.mdx index 488ea38363..e71623c1d4 100644 --- a/docs/media-buy/task-reference/create_media_buy.mdx +++ b/docs/media-buy/task-reference/create_media_buy.mdx @@ -330,6 +330,8 @@ When `confirmed_at` is `null`, sellers MUST omit `packages[].committed_metrics`. - **`attribution_window`** — when `metric_id` is an outcome metric and the seller commits to a specific lookback window, the entry SHOULD carry `qualifier.attribution_window` as a structured duration (`{ interval: 14, unit: "days" }`). Two outcome rows over different windows are reported as separate rows so buyers don't accidentally aggregate across periods. Without the qualifier, the contract is ambiguous and reconciliation falls back to whatever the delivery report happens to carry. The qualifier vocabulary is closed (`additionalProperties: false`); new keys ship explicitly in subsequent minors. + + Vendor-scope entries MAY carry the same qualifier keys when the same vendor metric is committed under more than one methodology or window — e.g., `attribution_window` on a vendor outcome metric distinguishes a 14-day vendor attribution commitment from a 30-day one for the same `(vendor, metric_id)`. `missing_metrics` mirrors the qualifier for vendor entries exactly as it does for standard entries. - **Reconciliation:** `missing_metrics` on [`get_media_buy_delivery`](/docs/media-buy/task-reference/get_media_buy_delivery) filters `committed_metrics` to entries where `committed_at < reporting_period.end`, then flags any that aren't populated in the report. A metric committed mid-flight is only audited from its commitment timestamp forward. Qualifiers are matched verbatim — a committed `{viewable_rate, mrc}` is not satisfied by a delivered `viewable_rate` carrying `viewability.standard: groupm`. - **Optional in v1.** Sellers without per-package snapshot infrastructure can adopt incrementally. Absence is conformant but carries a known audit gap: without the snapshot, `missing_metrics` reconciles against the product's live `available_metrics` at report time, which may not reflect what was committed at create time. Sellers that omit `committed_metrics` accept this risk; buyers SHOULD treat absence as "no audit-grade contract" rather than "clean delivery." Expected to become required at the next major. diff --git a/package.json b/package.json index ce5dc7da71..20a7ad3a5d 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/metric-identity-coherence.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/metric-identity-coherence.test.cjs tests/metric-qualifier-parity.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", diff --git a/static/schemas/source/core/committed-metric.json b/static/schemas/source/core/committed-metric.json index 4b3b591d70..160d96f680 100644 --- a/static/schemas/source/core/committed-metric.json +++ b/static/schemas/source/core/committed-metric.json @@ -70,6 +70,33 @@ "$ref": "/schemas/core/vendor-metric-id.json", "description": "Identifier for the metric within the vendor's vocabulary." }, + "qualifier": { + "type": "object", + "description": "Optional qualifier disambiguating commitments to the same vendor metric measured under different methodologies or windows. Same closed key set as standard-scope entries; new keys ship explicitly.", + "properties": { + "viewability_standard": { + "$ref": "/schemas/enums/viewability-standard.json", + "description": "Viewability standard for this vendor metric, as a closed enum." + }, + "completion_source": { + "$ref": "/schemas/enums/completion-source.json", + "description": "Completion attestation source for this vendor metric." + }, + "attribution_methodology": { + "$ref": "/schemas/enums/attribution-methodology.json", + "description": "Attribution methodology for this vendor outcome metric." + }, + "attribution_window": { + "$ref": "/schemas/core/duration.json", + "description": "Attribution window for this vendor outcome metric, as a structured duration." + }, + "lift_dimension": { + "$ref": "/schemas/enums/lift-dimension.json", + "description": "Brand-lift dimension for this vendor metric." + } + }, + "additionalProperties": false + }, "committed_at": { "type": "string", "format": "date-time", diff --git a/static/schemas/source/core/delivery-metric-aggregate.json b/static/schemas/source/core/delivery-metric-aggregate.json index 803ad63f98..961b138a9a 100644 --- a/static/schemas/source/core/delivery-metric-aggregate.json +++ b/static/schemas/source/core/delivery-metric-aggregate.json @@ -177,7 +177,29 @@ }, "qualifier": { "type": "object", - "description": "Optional qualifier keys for vendor metrics that need disambiguation (rare today — most vendor methodologies are intrinsic to the metric definition).", + "description": "Optional qualifier keys disambiguating this vendor-metric row from sibling rows under the same (vendor, metric_id) — e.g., attribution_window on a vendor outcome metric. Same closed key set as the standard branch; new keys ship explicitly.", + "properties": { + "viewability_standard": { + "$ref": "/schemas/enums/viewability-standard.json", + "description": "Viewability standard under which this row was measured. MRC and GroupM define materially different thresholds; never sum across standards." + }, + "completion_source": { + "$ref": "/schemas/enums/completion-source.json", + "description": "Source of `completion_rate` attestation for this row — `seller_attested` from player/ad server, `vendor_attested` from a third-party measurement vendor. The two paths can yield different rates; never sum across sources." + }, + "attribution_methodology": { + "$ref": "/schemas/enums/attribution-methodology.json", + "description": "Attribution methodology under which this outcome row was computed (`deterministic_purchase`, `probabilistic`, `panel_based`, `modeled`). Outcome metrics measured under different methodologies represent materially different numbers; never sum across methodologies." + }, + "attribution_window": { + "$ref": "/schemas/core/duration.json", + "description": "Attribution window for this outcome row. Object-valued duration (`{interval, unit}`), not a shorthand string. Outcome metrics measured over different windows represent the same metric over different time periods; the partition keeps them as separate rows so buyers don't accidentally aggregate." + }, + "lift_dimension": { + "$ref": "/schemas/enums/lift-dimension.json", + "description": "Brand-lift dimension this row represents (awareness, consideration, favorability, purchase intent, or ad recall). Required when `metric_id` is `brand_lift`. Each dimension is a separate surveyed outcome with its own sample size and confidence interval; never sum across dimensions." + } + }, "additionalProperties": false }, "value": { diff --git a/static/schemas/source/core/missing-metric.json b/static/schemas/source/core/missing-metric.json index 1129a1d86d..598052ad69 100644 --- a/static/schemas/source/core/missing-metric.json +++ b/static/schemas/source/core/missing-metric.json @@ -57,6 +57,28 @@ }, "metric_id": { "$ref": "/schemas/core/vendor-metric-id.json" + }, + "qualifier": { + "type": "object", + "description": "Mirrors the qualifier on the corresponding vendor-scope `committed_metrics` entry. MUST match that entry so reconciliation joins on (vendor, metric_id, qualifier).", + "properties": { + "viewability_standard": { + "$ref": "/schemas/enums/viewability-standard.json" + }, + "completion_source": { + "$ref": "/schemas/enums/completion-source.json" + }, + "attribution_methodology": { + "$ref": "/schemas/enums/attribution-methodology.json" + }, + "attribution_window": { + "$ref": "/schemas/core/duration.json" + }, + "lift_dimension": { + "$ref": "/schemas/enums/lift-dimension.json" + } + }, + "additionalProperties": false } }, "required": [ diff --git a/tests/metric-qualifier-parity.test.cjs b/tests/metric-qualifier-parity.test.cjs new file mode 100644 index 0000000000..6143800d51 --- /dev/null +++ b/tests/metric-qualifier-parity.test.cjs @@ -0,0 +1,238 @@ +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); +} + +// Finds the oneOf branch whose `scope` discriminator is pinned to `scopeConst` +// (e.g. "standard" or "vendor") — the shared shape for every hand-maintained +// copy of the (scope, metric_id, qualifier) row. +function findScopeBranch(oneOf, scopeConst) { + const branch = oneOf.find( + (entry) => entry.properties && entry.properties.scope && entry.properties.scope.const === scopeConst + ); + assert.ok(branch, `no oneOf branch found for scope=${scopeConst}`); + return branch; +} + +function keySet(qualifierSchema) { + assert.ok(qualifierSchema, "qualifier schema missing"); + return Object.keys(qualifierSchema.properties).sort(); +} + +describe("metric qualifier parity across reporting surfaces", () => { + const EXPECTED_KEYS = [ + "attribution_methodology", + "attribution_window", + "completion_source", + "lift_dimension", + "viewability_standard", + ]; + + it("has an identical standard-branch qualifier key set across every hand-maintained copy", () => { + const committedMetric = readSchema("/schemas/core/committed-metric.json"); + const missingMetric = readSchema("/schemas/core/missing-metric.json"); + const deliveryMetricAggregate = readSchema( + "/schemas/core/delivery-metric-aggregate.json" + ); + const performanceFeedbackMetric = readSchema( + "/schemas/core/performance-feedback-metric.json" + ); + const performanceFeedback = readSchema("/schemas/core/performance-feedback.json"); + const packageRequest = readSchema("/schemas/media-buy/package-request.json"); + const canonicalMetricQualifier = readSchema( + "/schemas/core/canonical-metric-qualifier.json" + ); + + const copies = { + "committed-metric.json (standard)": keySet( + findScopeBranch(committedMetric.oneOf, "standard").properties.qualifier + ), + "missing-metric.json (standard)": keySet( + findScopeBranch(missingMetric.oneOf, "standard").properties.qualifier + ), + "delivery-metric-aggregate.json (standard)": keySet( + findScopeBranch(deliveryMetricAggregate.oneOf, "standard").properties + .qualifier + ), + "performance-feedback-metric.json (standard)": keySet( + findScopeBranch(performanceFeedbackMetric.oneOf, "standard").properties + .qualifier + ), + // Inline copy nested under properties.metric.oneOf. + "performance-feedback.json (metric, standard)": keySet( + findScopeBranch(performanceFeedback.properties.metric.oneOf, "standard") + .properties.qualifier + ), + // package-request.json's committed_metrics array items carry their own + // inline (scope, metric_id, qualifier) oneOf under properties.committed_metrics.items. + "package-request.json (committed_metrics, standard)": keySet( + findScopeBranch( + packageRequest.properties.committed_metrics.items.oneOf, + "standard" + ).properties.qualifier + ), + "canonical-metric-qualifier.json": keySet(canonicalMetricQualifier), + }; + + for (const [label, keys] of Object.entries(copies)) { + assert.deepEqual(keys, EXPECTED_KEYS, `${label} qualifier key set drifted`); + } + }); + + it("gives vendor-branch qualifiers the same closed key set as the standard branch", () => { + for (const uri of [ + "/schemas/core/committed-metric.json", + "/schemas/core/missing-metric.json", + "/schemas/core/delivery-metric-aggregate.json", + ]) { + const schema = readSchema(uri); + const vendorQualifier = findScopeBranch(schema.oneOf, "vendor").properties + .qualifier; + assert.ok(vendorQualifier, `${uri} vendor branch missing qualifier`); + assert.deepEqual( + keySet(vendorQualifier), + EXPECTED_KEYS, + `${uri} vendor qualifier key set does not match standard` + ); + assert.equal( + vendorQualifier.additionalProperties, + false, + `${uri} vendor qualifier must be closed` + ); + } + }); + + it("closes every qualifier copy with additionalProperties: false", () => { + const committedMetric = readSchema("/schemas/core/committed-metric.json"); + const missingMetric = readSchema("/schemas/core/missing-metric.json"); + const deliveryMetricAggregate = readSchema( + "/schemas/core/delivery-metric-aggregate.json" + ); + const performanceFeedbackMetric = readSchema( + "/schemas/core/performance-feedback-metric.json" + ); + const performanceFeedback = readSchema("/schemas/core/performance-feedback.json"); + const packageRequest = readSchema("/schemas/media-buy/package-request.json"); + const canonicalMetricQualifier = readSchema( + "/schemas/core/canonical-metric-qualifier.json" + ); + + const closedQualifiers = { + "committed-metric.json (standard)": findScopeBranch( + committedMetric.oneOf, + "standard" + ).properties.qualifier, + "committed-metric.json (vendor)": findScopeBranch( + committedMetric.oneOf, + "vendor" + ).properties.qualifier, + "missing-metric.json (standard)": findScopeBranch( + missingMetric.oneOf, + "standard" + ).properties.qualifier, + "missing-metric.json (vendor)": findScopeBranch(missingMetric.oneOf, "vendor") + .properties.qualifier, + "delivery-metric-aggregate.json (standard)": findScopeBranch( + deliveryMetricAggregate.oneOf, + "standard" + ).properties.qualifier, + "delivery-metric-aggregate.json (vendor)": findScopeBranch( + deliveryMetricAggregate.oneOf, + "vendor" + ).properties.qualifier, + "performance-feedback-metric.json (standard)": findScopeBranch( + performanceFeedbackMetric.oneOf, + "standard" + ).properties.qualifier, + "performance-feedback.json (metric, standard)": findScopeBranch( + performanceFeedback.properties.metric.oneOf, + "standard" + ).properties.qualifier, + "package-request.json (committed_metrics, standard)": findScopeBranch( + packageRequest.properties.committed_metrics.items.oneOf, + "standard" + ).properties.qualifier, + "canonical-metric-qualifier.json": canonicalMetricQualifier, + }; + + for (const [label, qualifierSchema] of Object.entries(closedQualifiers)) { + assert.equal( + qualifierSchema.additionalProperties, + false, + `${label} qualifier must be closed` + ); + } + }); + + describe("delivery-metric-aggregate vendor qualifier is usable", () => { + let validateAggregate; + + before(async () => { + validateAggregate = await compile( + readSchema("/schemas/core/delivery-metric-aggregate.json") + ); + }); + + it("accepts a vendor row qualified with a structured attribution window", () => { + const row = { + scope: "vendor", + vendor: { domain: "attentionvendor.example" }, + metric_id: "attention_units", + qualifier: { attribution_window: { interval: 14, unit: "days" } }, + value: 4.2, + }; + assert.equal( + validateAggregate(row), + true, + JSON.stringify(validateAggregate.errors) + ); + }); + + it("accepts an empty vendor qualifier", () => { + const row = { + scope: "vendor", + vendor: { domain: "attentionvendor.example" }, + metric_id: "attention_units", + qualifier: {}, + value: 4.2, + }; + assert.equal( + validateAggregate(row), + true, + JSON.stringify(validateAggregate.errors) + ); + }); + + it("rejects an unknown vendor qualifier key", () => { + const row = { + scope: "vendor", + vendor: { domain: "attentionvendor.example" }, + metric_id: "attention_units", + qualifier: { bogus: 1 }, + value: 4.2, + }; + assert.equal(validateAggregate(row), false); + }); + }); +}); From d42eaa62f950880e184924692162c94d5f4a7d04 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 17 Aug 2026 01:38:48 +0200 Subject: [PATCH 3/6] docs: convert HTML comments to MDX comments MDX cannot parse HTML comments; the Mintlify parser fails on docs/building/by-layer/L1/security.mdx and blocks any docs push. Use the {/* */} form the contributing guidance already prescribes. Co-Authored-By: Claude Fable 5 --- docs/building/by-layer/L1/security.mdx | 4 ++-- docs/building/operating/transport-errors.mdx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/building/by-layer/L1/security.mdx b/docs/building/by-layer/L1/security.mdx index b99315b0a9..cf290c9150 100644 --- a/docs/building/by-layer/L1/security.mdx +++ b/docs/building/by-layer/L1/security.mdx @@ -141,8 +141,8 @@ Implementations MUST keep three layers distinct: 2. **Credential** — the issuer-signed claim found through the reference or supplied as an embedded proof. 3. **Evaluation** — the receiving seller's or governance agent's result after applying its trust policy. - +{/* Using latest because these schemas are not yet released in any version. + Update to the correct version alias after the next release. */} The shared schemas are [`attestation-reference.json`](https://adcontextprotocol.org/schemas/v3/core/attestation-reference.json), [`attestation-issuer.json`](https://adcontextprotocol.org/schemas/v3/core/attestation-issuer.json), [`attestation-subject.json`](https://adcontextprotocol.org/schemas/v3/core/attestation-subject.json), and [`attestation-evaluation.json`](https://adcontextprotocol.org/schemas/v3/core/attestation-evaluation.json). Every presentation identifies an issuer, an open URI-namespaced `claim_type`, a typed subject, and at least one delivery path: diff --git a/docs/building/operating/transport-errors.mdx b/docs/building/operating/transport-errors.mdx index 611a3af2df..20d9c11b0c 100644 --- a/docs/building/operating/transport-errors.mdx +++ b/docs/building/operating/transport-errors.mdx @@ -563,8 +563,8 @@ JSON Schemas for all recommended `details` shapes are published alongside the er - [`/schemas/v3/error-details/rate-limited.json`](https://adcontextprotocol.org/schemas/v3/error-details/rate-limited.json) - [`/schemas/v3/error-details/budget-too-low.json`](https://adcontextprotocol.org/schemas/v3/error-details/budget-too-low.json) - [`/schemas/v3/error-details/audience-too-small.json`](https://adcontextprotocol.org/schemas/v3/error-details/audience-too-small.json) - +{/* Using latest because account-moved.json is not yet released in any version. + Update to the correct version alias after the next release. */} - [`/schemas/latest/error-details/account-moved.json`](https://adcontextprotocol.org/schemas/latest/error-details/account-moved.json) - [`/schemas/v3/error-details/account-setup-required.json`](https://adcontextprotocol.org/schemas/v3/error-details/account-setup-required.json) - [`/schemas/v3/error-details/creative-rejected.json`](https://adcontextprotocol.org/schemas/v3/error-details/creative-rejected.json) From cfe6dde0c1b62bbf7e804408e3d8d74dbaafd7e7 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 17 Aug 2026 08:30:53 +0200 Subject: [PATCH 4/6] fix(reporting): container-subsumption rule for metric set operations Leaf identities split the metric vocabulary into container and leaf tokens, which made token-equality set operations ambiguous: a product declaring viewability could be silently excluded by a required_metrics filter for viewable_rate, and a format/product intersection of mixed declarations could yield the empty set. State the rule once in available-metric (container subsumes its leaves; leaf selects its carrier; a leaf implies neither siblings nor the carrier's other fields) and cite it from required_metrics, reported_metrics, and requested_metrics. Also: sellers MUST populate viewability.standard when the commitment carries a viewability_standard qualifier, and the leaf-identity docs paragraph no longer splits the metric bullet list. Co-Authored-By: Claude Fable 5 --- docs/media-buy/media-buys/optimization-reporting.mdx | 4 ++-- static/schemas/source/core/delivery-metrics.json | 2 +- static/schemas/source/core/format.json | 2 +- static/schemas/source/core/product-filters.json | 2 +- static/schemas/source/core/reporting-webhook.json | 2 +- static/schemas/source/enums/available-metric.json | 2 +- tests/metric-identity-coherence.test.cjs | 6 +++++- 7 files changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/media-buy/media-buys/optimization-reporting.mdx b/docs/media-buy/media-buys/optimization-reporting.mdx index 0020f50bb5..85e0a7ec5e 100644 --- a/docs/media-buy/media-buys/optimization-reporting.mdx +++ b/docs/media-buy/media-buys/optimization-reporting.mdx @@ -313,8 +313,6 @@ Buyers receive the intersection of both. `impressions` and `spend` are always re - **`profile_visits`**: Visits to the brand's in-platform page - **`viewability`**: Viewability data (measurable_impressions, viewable_impressions, viewable_rate, viewed_seconds, standard, vendor). Separates MRC and GroupM standards. `viewed_seconds` is the average in-view duration per measurable impression — reporting-side counterpart to the `viewed_seconds` optimization goal, governed by the same `standard` threshold as `viewable_rate`. The optional `vendor` field carries a `BrandRef` so the row is self-describing — buyer agents reading delivery in isolation can attribute the numbers to a measurement vendor without joining back to `package.committed_metrics` or `package.performance_standards`. - **`quartile_data`**: Video quartile completion data (q1-q4); `null` when not applicable (e.g. a non-video buy) - -The numeric values inside `viewability` and `quartile_data` are individually addressable through leaf metric identities — `viewable_rate`, `viewable_impressions`, `measurable_impressions`, `viewed_seconds`, and `quartile_25`/`quartile_50`/`quartile_75`/`quartile_100` (which resolve to `quartile_data.q1_views`–`q4_views`). Leaf identities appear in `available_metrics` declarations, `committed_metrics` contracts, `metric_aggregates` rows, and breakdown `sort_by`; the nested object remains the only carrier of the values in delivery reports. Note that `quartile_100` counts 100%-of-duration completions (VAST `complete`), which is distinct from `completed_views` when the buy defines a shorter billable view threshold. - **`dooh_metrics`**: DOOH-specific metrics (loop plays, screens, venue breakdown) - **`cost_per_click`**: Cost per click (`spend / clicks`) - **`cost_per_completed_view`**: Cost per completed view (`spend / completed_views`); CPCV pricing scalar for video/audio inventory @@ -324,6 +322,8 @@ The numeric values inside `viewability` and `quartile_data` are individually add - **`new_to_brand_units`**: Unit-volume parallel to `new_to_brand_rate` — count of units sold to first-time brand buyers - **`plays`**: Raw play count for DOOH/broadcast inventory (mirrors `forecastable-metric.plays`); distinct from `dooh_metrics.loop_plays` (per-screen rotation) and `impressions` (multiplied audience figure) +The numeric values inside `viewability` and `quartile_data` are individually addressable through leaf metric identities — `viewable_rate`, `viewable_impressions`, `measurable_impressions`, `viewed_seconds`, and `quartile_25`/`quartile_50`/`quartile_75`/`quartile_100` (which resolve to `quartile_data.q1_views`–`q4_views`). Leaf identities appear in `available_metrics` declarations, `committed_metrics` contracts, `metric_aggregates` rows, and breakdown `sort_by`; the nested object remains the only carrier of the values in delivery reports. In every metric set operation (declaration, filtering, intersection, selection) a container token subsumes its leaf identities — declaring `viewability` satisfies `viewable_rate`. Note that `quartile_100` counts 100%-of-duration completions (VAST `complete`), which is distinct from `completed_views` when the buy defines a shorter billable view threshold. + Buyers can optionally request a subset via `requested_metrics` to reduce payload size and focus on relevant KPIs. For `completion_rate` and `quartile_data`, sellers MAY return `null` to signal the metric does not apply (e.g. on a non-video buy), and clients MUST accept `null` as a valid value for these two fields. Every other metric signals "not applicable" by omission — sellers omit it rather than sending `null`. diff --git a/static/schemas/source/core/delivery-metrics.json b/static/schemas/source/core/delivery-metrics.json index 65d778045b..469ec16c69 100644 --- a/static/schemas/source/core/delivery-metrics.json +++ b/static/schemas/source/core/delivery-metrics.json @@ -288,7 +288,7 @@ }, "viewability": { "type": "object", - "description": "Viewability metrics. Viewable rate should be calculated as viewable_impressions / measurable_impressions (not total impressions), since some environments cannot measure viewability. Includes `viewed_seconds` — average in-view duration — since duration is governed by the same viewability threshold (`standard`) and shares the same `measurable_impressions` denominator. Sellers SHOULD include `standard` whenever measured viewability values are reported because MRC and GroupM rows are not interchangeable. The numeric leaves are addressable via the leaf metric identities `viewable_rate`, `viewable_impressions`, `measurable_impressions`, and `viewed_seconds` for declaration, commitments, aggregates, and breakdown sorting; this object remains the canonical carrier of the values. When a buy reports under more than one standard, contract a specific standard via the `viewability_standard` qualifier on `committed_metrics`.", + "description": "Viewability metrics. Viewable rate should be calculated as viewable_impressions / measurable_impressions (not total impressions), since some environments cannot measure viewability. Includes `viewed_seconds` — average in-view duration — since duration is governed by the same viewability threshold (`standard`) and shares the same `measurable_impressions` denominator. Sellers SHOULD include `standard` whenever measured viewability values are reported because MRC and GroupM rows are not interchangeable. The numeric leaves are addressable via the leaf metric identities `viewable_rate`, `viewable_impressions`, `measurable_impressions`, and `viewed_seconds` for declaration, commitments, aggregates, and breakdown sorting; this object remains the canonical carrier of the values. When a buy reports under more than one standard, contract a specific standard via the `viewability_standard` qualifier on `committed_metrics`; when the package's `committed_metrics` carry a `viewability_standard` qualifier, sellers MUST populate `standard` on reported viewability objects so reconciliation can match the qualifier.", "properties": { "vendor": { "$ref": "/schemas/core/brand-ref.json", diff --git a/static/schemas/source/core/format.json b/static/schemas/source/core/format.json index dc96b39121..0ad2665f8f 100644 --- a/static/schemas/source/core/format.json +++ b/static/schemas/source/core/format.json @@ -687,7 +687,7 @@ }, "reported_metrics": { "type": "array", - "description": "Metrics this format can produce in delivery reporting. Buyers receive the intersection of format reported_metrics and product available_metrics. If omitted, the format defers entirely to product-level metric declarations.", + "description": "Metrics this format can produce in delivery reporting. Buyers receive the intersection of format reported_metrics and product available_metrics. The intersection is evaluated under the container-subsumption rule in `enums/available-metric.json` — a container token (e.g. `viewability`) matches its leaf identities (e.g. `viewable_rate`), so mixed container/leaf declarations do not produce an empty intersection. If omitted, the format defers entirely to product-level metric declarations.", "items": { "$ref": "/schemas/enums/available-metric.json" }, diff --git a/static/schemas/source/core/product-filters.json b/static/schemas/source/core/product-filters.json index 645ec206b9..b9b8a9aa6b 100644 --- a/static/schemas/source/core/product-filters.json +++ b/static/schemas/source/core/product-filters.json @@ -500,7 +500,7 @@ }, "required_metrics": { "type": "array", - "description": "Filter to products whose `reporting_capabilities.available_metrics` is a superset of these metrics — i.e., products that commit to reporting all listed metrics in delivery responses. Use this for capability-level discovery (e.g., 'I need products that report `completed_views` for a CTV CPCV buy'); guarantee-level requirements with thresholds belong in `required_performance_standards` and `measurement_terms`. Sellers MUST silently exclude products that cannot meet this list (filter-not-fail; do not return an error). The product's declared `available_metrics` becomes the binding reporting contract carried into the resulting media buy — the same metric vocabulary is used to compute `missing_metrics` on `get_media_buy_delivery`.", + "description": "Filter to products whose `reporting_capabilities.available_metrics` is a superset of these metrics — i.e., products that commit to reporting all listed metrics in delivery responses. Use this for capability-level discovery (e.g., 'I need products that report `completed_views` for a CTV CPCV buy'); guarantee-level requirements with thresholds belong in `required_performance_standards` and `measurement_terms`. Sellers MUST silently exclude products that cannot meet this list (filter-not-fail; do not return an error). Superset evaluation follows the container-subsumption rule in `enums/available-metric.json` — a product declaring the `viewability` container satisfies a requirement for the `viewable_rate` leaf. The product's declared `available_metrics` becomes the binding reporting contract carried into the resulting media buy — the same metric vocabulary is used to compute `missing_metrics` on `get_media_buy_delivery`.", "items": { "$ref": "/schemas/enums/available-metric.json" }, diff --git a/static/schemas/source/core/reporting-webhook.json b/static/schemas/source/core/reporting-webhook.json index c50244d3cd..dfa7e0998e 100644 --- a/static/schemas/source/core/reporting-webhook.json +++ b/static/schemas/source/core/reporting-webhook.json @@ -52,7 +52,7 @@ }, "requested_metrics": { "type": "array", - "description": "Optional list of metrics to include in webhook notifications. If omitted, all available metrics are included. Must be subset of product's available_metrics.", + "description": "Optional list of metrics to include in webhook notifications. If omitted, all available metrics are included. Must be subset of product's available_metrics. Subset evaluation and leaf resolution follow the container-subsumption rule in `enums/available-metric.json`: requesting a leaf identity selects its canonical carrier object in the payload.", "items": { "$ref": "/schemas/enums/available-metric.json" }, diff --git a/static/schemas/source/enums/available-metric.json b/static/schemas/source/enums/available-metric.json index f33a02932c..24e8c1706e 100644 --- a/static/schemas/source/enums/available-metric.json +++ b/static/schemas/source/enums/available-metric.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/enums/available-metric.json", "title": "Available Metric", - "description": "Standard delivery and performance metrics available for reporting. Most values name flat numeric fields of `core/delivery-metrics.json`. Leaf identities address a single numeric value nested inside an object-shaped metric so it can be declared, committed, aggregated, and sorted individually: `quartile_25`/`quartile_50`/`quartile_75`/`quartile_100` resolve to `quartile_data.q1_views`–`q4_views`, and `viewable_rate`/`viewable_impressions`/`measurable_impressions`/`viewed_seconds` resolve to the same-named fields of `viewability`. The nested object remains the canonical carrier of the reported value — leaf identities never introduce duplicate flat response fields. The object-shaped tokens (`viewability`, `quartile_data`, `dooh_metrics`) declare the whole nested surface at once.", + "description": "Standard delivery and performance metrics available for reporting. Most values name flat numeric fields of `core/delivery-metrics.json`. Leaf identities address a single numeric value nested inside an object-shaped metric so it can be declared, committed, aggregated, and sorted individually: `quartile_25`/`quartile_50`/`quartile_75`/`quartile_100` resolve to `quartile_data.q1_views`–`q4_views`, and `viewable_rate`/`viewable_impressions`/`measurable_impressions`/`viewed_seconds` resolve to the same-named fields of `viewability`. The nested object remains the canonical carrier of the reported value — leaf identities never introduce duplicate flat response fields. The object-shaped tokens (`viewability`, `quartile_data`, `dooh_metrics`) declare the whole nested surface at once. **Container subsumption**: in every set operation over this enum — capability declaration, `required_metrics` filtering, format `reported_metrics` intersection, and `requested_metrics` selection — a container token subsumes its leaf identities: declaring `viewability` satisfies a requirement or request for `viewable_rate`, and requesting a leaf identity selects its canonical carrier object in the response (never a flat duplicate). A leaf declaration does not imply sibling leaves or the carrier's non-numeric fields.", "type": "string", "enum": [ "impressions", diff --git a/tests/metric-identity-coherence.test.cjs b/tests/metric-identity-coherence.test.cjs index 3893e541d4..611c25cd29 100644 --- a/tests/metric-identity-coherence.test.cjs +++ b/tests/metric-identity-coherence.test.cjs @@ -120,8 +120,12 @@ describe("metric identity coherence", () => { it("only conditions delivery-metric-aggregate on representable metric_ids", () => { const aggregate = readSchema("/schemas/core/delivery-metric-aggregate.json"); const standardBranch = aggregate.oneOf.find( - (branch) => branch.properties.scope.const === "standard" + (branch) => + branch.properties && + branch.properties.scope && + branch.properties.scope.const === "standard" ); + assert.ok(standardBranch, "no standard-scope branch in delivery-metric-aggregate"); for (const conditional of standardBranch.allOf) { const metricId = conditional.if.properties.metric_id.const; assert.ok( From 08902c56233a80c5983e4eb337e2dc084523fca3 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 17 Aug 2026 09:26:34 +0200 Subject: [PATCH 5/6] fix(reporting): qualifier on the vendor delivery carrier and full parity Panel review found the qualifier symmetry stopped one schema short of the value carrier: vendor_metric_values rows had no qualifier and a one-row-per-(vendor, metric_id) uniqueness MUST, so a vendor metric committed under two attribution windows was expressible in the contract but structurally unrepresentable in the delivery report. vendor-metric- value gains the optional closed qualifier and uniqueness re-keys to (vendor, metric_id, qualifier). The remaining vendor branches (package-request committed_metrics, performance-feedback surfaces) gain the same qualifier so canonical payloads are expressible everywhere, and the parity test now covers every vendor copy. Also: container tokens are barred as value-bearing aggregate metric_ids, and the vendor-branch qualifier descriptions no longer borrow standard-branch requirements that reference metrics outside the vendor vocabulary. Co-Authored-By: Claude Fable 5 --- .../core/delivery-metric-aggregate.json | 6 +- .../schemas/source/core/delivery-metrics.json | 2 +- .../core/performance-feedback-metric.json | 22 ++++++++ .../source/core/performance-feedback.json | 22 ++++++++ .../source/core/vendor-metric-value.json | 22 ++++++++ .../source/media-buy/package-request.json | 22 ++++++++ tests/metric-qualifier-parity.test.cjs | 56 +++++++++++++++++-- 7 files changed, 143 insertions(+), 9 deletions(-) diff --git a/static/schemas/source/core/delivery-metric-aggregate.json b/static/schemas/source/core/delivery-metric-aggregate.json index 961b138a9a..f2759973c8 100644 --- a/static/schemas/source/core/delivery-metric-aggregate.json +++ b/static/schemas/source/core/delivery-metric-aggregate.json @@ -17,7 +17,7 @@ }, "metric_id": { "$ref": "/schemas/enums/available-metric.json", - "description": "Identifier for the standard metric." + "description": "Identifier for the standard metric. Container tokens (e.g., `viewability`, `quartile_data`, `dooh_metrics`) MUST NOT appear here — an aggregate row carries a single numeric `value`, and leaf identities exist for exactly that purpose (e.g., `viewable_rate`, `quartile_75`)." }, "qualifier": { "type": "object", @@ -185,7 +185,7 @@ }, "completion_source": { "$ref": "/schemas/enums/completion-source.json", - "description": "Source of `completion_rate` attestation for this row — `seller_attested` from player/ad server, `vendor_attested` from a third-party measurement vendor. The two paths can yield different rates; never sum across sources." + "description": "Attestation source for a vendor completion-style metric — seller_attested from player/ad server, vendor_attested from an independent measurement path. Applicability is defined by the vendor metric; never sum across sources." }, "attribution_methodology": { "$ref": "/schemas/enums/attribution-methodology.json", @@ -197,7 +197,7 @@ }, "lift_dimension": { "$ref": "/schemas/enums/lift-dimension.json", - "description": "Brand-lift dimension this row represents (awareness, consideration, favorability, purchase intent, or ad recall). Required when `metric_id` is `brand_lift`. Each dimension is a separate surveyed outcome with its own sample size and confidence interval; never sum across dimensions." + "description": "Lift dimension this row represents (awareness, consideration, favorability, purchase intent, or ad recall) for vendor lift-style metrics. Applicability is defined by the vendor metric; each dimension is a separate surveyed outcome and rows under different dimensions must not be summed." } }, "additionalProperties": false diff --git a/static/schemas/source/core/delivery-metrics.json b/static/schemas/source/core/delivery-metrics.json index 469ec16c69..b73989fece 100644 --- a/static/schemas/source/core/delivery-metrics.json +++ b/static/schemas/source/core/delivery-metrics.json @@ -412,7 +412,7 @@ }, "vendor_metric_values": { "type": "array", - "description": "Reported values for vendor-defined metrics that the product's `reporting_capabilities.vendor_metrics` declared. Each entry carries the vendor (BrandRef), the metric identifier within the vendor's vocabulary, the value, optional unit, and `measurable_impressions` as the coverage denominator — vendor measurement is rarely 100% of delivered impressions, since vendors only score impressions where their SDK fires or their panel matches. When a declared vendor metric is omitted from this array, buyers infer no measurement happened (no integration). One row per `(vendor.domain, vendor.brand_id, metric_id)` per reporting period — sellers MUST de-duplicate before emission and MUST NOT emit the same vendor metric twice; buyers MAY treat duplicate rows as a seller-side conformance bug. The structured `vendor_metric_values` array is the recommended path for vendor metrics; `additionalProperties: true` on this parent object is preserved so existing free-form vendor emissions remain conformant during migration.", + "description": "Reported values for vendor-defined metrics that the product's `reporting_capabilities.vendor_metrics` declared. Each entry carries the vendor (BrandRef), the metric identifier within the vendor's vocabulary, the value, optional unit, and `measurable_impressions` as the coverage denominator — vendor measurement is rarely 100% of delivered impressions, since vendors only score impressions where their SDK fires or their panel matches. When a declared vendor metric is omitted from this array, buyers infer no measurement happened (no integration). One row per `(vendor.domain, vendor.brand_id, metric_id, qualifier)` per reporting period — the same vendor metric MAY appear in multiple rows only when each carries a distinct qualifier (e.g., 7-day and 30-day attribution windows); sellers MUST de-duplicate before emission and MUST NOT emit two rows with the same tuple; buyers MAY treat duplicate rows as a seller-side conformance bug. The structured `vendor_metric_values` array is the recommended path for vendor metrics; `additionalProperties: true` on this parent object is preserved so existing free-form vendor emissions remain conformant during migration.", "items": { "$ref": "/schemas/core/vendor-metric-value.json" } diff --git a/static/schemas/source/core/performance-feedback-metric.json b/static/schemas/source/core/performance-feedback-metric.json index b9b8f5c4d9..2f8f2fdf15 100644 --- a/static/schemas/source/core/performance-feedback-metric.json +++ b/static/schemas/source/core/performance-feedback-metric.json @@ -55,6 +55,28 @@ }, "metric_id": { "$ref": "/schemas/core/vendor-metric-id.json" + }, + "qualifier": { + "type": "object", + "description": "Optional disambiguator mirroring the vendor-scope qualifier on `committed_metrics` — same closed key set as standard-scope entries.", + "properties": { + "viewability_standard": { + "$ref": "/schemas/enums/viewability-standard.json" + }, + "completion_source": { + "$ref": "/schemas/enums/completion-source.json" + }, + "attribution_methodology": { + "$ref": "/schemas/enums/attribution-methodology.json" + }, + "attribution_window": { + "$ref": "/schemas/core/duration.json" + }, + "lift_dimension": { + "$ref": "/schemas/enums/lift-dimension.json" + } + }, + "additionalProperties": false } }, "required": ["scope", "vendor", "metric_id"], diff --git a/static/schemas/source/core/performance-feedback.json b/static/schemas/source/core/performance-feedback.json index dfe0911036..a87143599a 100644 --- a/static/schemas/source/core/performance-feedback.json +++ b/static/schemas/source/core/performance-feedback.json @@ -110,6 +110,28 @@ "metric_id": { "$ref": "/schemas/core/vendor-metric-id.json", "description": "Identifier for the metric within the vendor's vocabulary." + }, + "qualifier": { + "type": "object", + "description": "Optional disambiguator mirroring the vendor-scope qualifier on `committed_metrics` — same closed key set as standard-scope entries.", + "properties": { + "viewability_standard": { + "$ref": "/schemas/enums/viewability-standard.json" + }, + "completion_source": { + "$ref": "/schemas/enums/completion-source.json" + }, + "attribution_methodology": { + "$ref": "/schemas/enums/attribution-methodology.json" + }, + "attribution_window": { + "$ref": "/schemas/core/duration.json" + }, + "lift_dimension": { + "$ref": "/schemas/enums/lift-dimension.json" + } + }, + "additionalProperties": false } }, "required": ["scope", "vendor", "metric_id"], diff --git a/static/schemas/source/core/vendor-metric-value.json b/static/schemas/source/core/vendor-metric-value.json index 51a8f22c42..97e1c4dedc 100644 --- a/static/schemas/source/core/vendor-metric-value.json +++ b/static/schemas/source/core/vendor-metric-value.json @@ -27,6 +27,28 @@ "description": "Number of impressions in this reporting period that the vendor was able to measure. Coverage denominator — buyers compute coverage rate as `measurable_impressions / impressions`. When absent, coverage is unspecified — buyers MUST NOT compute a coverage rate or assume full coverage. When the vendor measured zero impressions but is integrated, set to 0 explicitly. When the entry is omitted from `vendor_metric_values` entirely, the buyer infers no measurement happened (no integration). This pattern parallels `viewability.measurable_impressions` (`delivery-metrics.json#/properties/viewability`), which has handled vendor coverage in the IAS/DV/MRC ecosystem for over a decade — same convention: absence is unknown, not full.", "minimum": 0 }, + "qualifier": { + "type": "object", + "description": "Optional qualifier disambiguating this row from sibling rows for the same (vendor, metric_id) — e.g., the same vendor outcome metric reported under 7-day and 30-day attribution windows. Same closed key set as `committed-metric`. When the matching `committed_metrics` entry carries a qualifier, this row MUST carry the identical qualifier so reconciliation joins on `(vendor, metric_id, qualifier)`.", + "properties": { + "viewability_standard": { + "$ref": "/schemas/enums/viewability-standard.json" + }, + "completion_source": { + "$ref": "/schemas/enums/completion-source.json" + }, + "attribution_methodology": { + "$ref": "/schemas/enums/attribution-methodology.json" + }, + "attribution_window": { + "$ref": "/schemas/core/duration.json" + }, + "lift_dimension": { + "$ref": "/schemas/enums/lift-dimension.json" + } + }, + "additionalProperties": false + }, "breakdown": { "type": "object", "description": "Optional structured payload for vendor metrics that don't fit a single scalar — panel demographic breakouts, co-view audience composition, incremental reach + frequency + lift decompositions. Free-form; the keys and value semantics are defined by the vendor (see the vendor's `brand.json` measurement-agent docs). Buyers MUST treat this object as opaque without consulting the vendor's documentation. Vendors place any fields beyond the standard envelope (e.g., confidence intervals, panel sizes) inside this object rather than at the top level.", diff --git a/static/schemas/source/media-buy/package-request.json b/static/schemas/source/media-buy/package-request.json index f7794eb7b5..b907ab7dad 100644 --- a/static/schemas/source/media-buy/package-request.json +++ b/static/schemas/source/media-buy/package-request.json @@ -229,6 +229,28 @@ "metric_id": { "$ref": "/schemas/core/vendor-metric-id.json", "description": "Identifier for the metric within the vendor's vocabulary. MUST be present in the product's `reporting_capabilities.vendor_metrics` for the same vendor." + }, + "qualifier": { + "type": "object", + "description": "Optional disambiguator for vendor metrics committed under more than one methodology or window — same closed key set as standard-scope entries.", + "properties": { + "viewability_standard": { + "$ref": "/schemas/enums/viewability-standard.json" + }, + "completion_source": { + "$ref": "/schemas/enums/completion-source.json" + }, + "attribution_methodology": { + "$ref": "/schemas/enums/attribution-methodology.json" + }, + "attribution_window": { + "$ref": "/schemas/core/duration.json" + }, + "lift_dimension": { + "$ref": "/schemas/enums/lift-dimension.json" + } + }, + "additionalProperties": false } }, "required": ["scope", "vendor", "metric_id"], diff --git a/tests/metric-qualifier-parity.test.cjs b/tests/metric-qualifier-parity.test.cjs index 6143800d51..6b56ab9641 100644 --- a/tests/metric-qualifier-parity.test.cjs +++ b/tests/metric-qualifier-parity.test.cjs @@ -101,28 +101,74 @@ describe("metric qualifier parity across reporting surfaces", () => { }); it("gives vendor-branch qualifiers the same closed key set as the standard branch", () => { + const vendorQualifiers = []; for (const uri of [ "/schemas/core/committed-metric.json", "/schemas/core/missing-metric.json", "/schemas/core/delivery-metric-aggregate.json", + "/schemas/core/performance-feedback-metric.json", ]) { const schema = readSchema(uri); - const vendorQualifier = findScopeBranch(schema.oneOf, "vendor").properties - .qualifier; - assert.ok(vendorQualifier, `${uri} vendor branch missing qualifier`); + vendorQualifiers.push([ + uri, + findScopeBranch(schema.oneOf, "vendor").properties.qualifier, + ]); + } + vendorQualifiers.push([ + "performance-feedback.json (metric, vendor)", + findScopeBranch( + readSchema("/schemas/core/performance-feedback.json").properties.metric + .oneOf, + "vendor" + ).properties.qualifier, + ]); + vendorQualifiers.push([ + "package-request.json (committed_metrics, vendor)", + findScopeBranch( + readSchema("/schemas/media-buy/package-request.json").properties + .committed_metrics.items.oneOf, + "vendor" + ).properties.qualifier, + ]); + // The delivery carrier: vendor-metric-value is a flat object, not a + // scope-discriminated row, but its qualifier joins against the vendor + // commitment on (vendor, metric_id, qualifier) and must stay in parity. + vendorQualifiers.push([ + "vendor-metric-value.json", + readSchema("/schemas/core/vendor-metric-value.json").properties.qualifier, + ]); + + for (const [label, vendorQualifier] of vendorQualifiers) { + assert.ok(vendorQualifier, `${label} missing qualifier`); assert.deepEqual( keySet(vendorQualifier), EXPECTED_KEYS, - `${uri} vendor qualifier key set does not match standard` + `${label} vendor qualifier key set does not match standard` ); assert.equal( vendorQualifier.additionalProperties, false, - `${uri} vendor qualifier must be closed` + `${label} vendor qualifier must be closed` ); } }); + it("accepts a qualified vendor_metric_values delivery row", async () => { + const validate = await compile(readSchema("/schemas/core/vendor-metric-value.json")); + const row = { + vendor: { domain: "attentionvendor.example" }, + metric_id: "attention_units", + value: 4.2, + qualifier: { attribution_window: { interval: 14, unit: "days" } }, + }; + assert.equal(validate(row), true, JSON.stringify(validate.errors)); + assert.equal( + validate({ ...row, qualifier: { bogus: 1 } }), + false, + "unknown qualifier keys must be rejected" + ); + }); + it("closes every qualifier copy with additionalProperties: false", () => { const committedMetric = readSchema("/schemas/core/committed-metric.json"); const missingMetric = readSchema("/schemas/core/missing-metric.json"); From f2ae3d4686d2a851f0eb85f021e46fff3fd831cb Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 17 Aug 2026 12:07:41 +0200 Subject: [PATCH 6/6] fix(reporting): align storyboard uniqueness rule with qualified vendor rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verification review: the vendor_metric_accountability storyboard still stated the pre-qualifier uniqueness MUST — one row per (vendor, metric_id) — which the schema re-keyed to include the qualifier. Align the narrative and expand the changeset to name the wire-visible parts of the fix (delivery-carrier qualifier, uniqueness re-key, container token bar). Co-Authored-By: Claude Fable 5 --- .changeset/metric-qualifier-vendor-symmetry.md | 2 +- .../scenarios/vendor_metric_accountability.yaml | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.changeset/metric-qualifier-vendor-symmetry.md b/.changeset/metric-qualifier-vendor-symmetry.md index dd86b8eaa6..a3b9f635bd 100644 --- a/.changeset/metric-qualifier-vendor-symmetry.md +++ b/.changeset/metric-qualifier-vendor-symmetry.md @@ -2,4 +2,4 @@ "adcontextprotocol": minor --- -Fix the vendor-scope qualifier on `delivery-metric-aggregate` (previously a closed object with no properties, so only `{}` could validate) and add the optional 5-key qualifier to the vendor branches of `committed-metric` and `missing-metric`, matching what `canonical-reporting-commitment` already allows — vendor metrics measured under different attribution windows or methodologies are now distinguishable on the contract and reconciliation surfaces. A qualifier parity contract test now enforces an identical closed key set across every hand-maintained copy. +Fix the vendor-scope qualifier on `delivery-metric-aggregate` (previously a closed object with no properties, so only `{}` could validate) and add the optional 5-key qualifier to the vendor branches of `committed-metric`, `missing-metric`, `package-request` committed_metrics, the performance-feedback surfaces, and — critically — the `vendor-metric-value` delivery carrier, whose row uniqueness re-keys from `(vendor, metric_id)` to `(vendor, metric_id, qualifier)` so a vendor metric committed under two attribution windows is representable in the delivery report. Container tokens (`viewability`, `quartile_data`, `dooh_metrics`) are barred as value-bearing aggregate `metric_id`s — leaf identities exist for that. Matches what `canonical-reporting-commitment` already allows; a qualifier parity contract test now enforces an identical closed key set across every hand-maintained copy. diff --git a/static/compliance/source/protocols/media-buy/scenarios/vendor_metric_accountability.yaml b/static/compliance/source/protocols/media-buy/scenarios/vendor_metric_accountability.yaml index 645622f8dd..f2255fa040 100644 --- a/static/compliance/source/protocols/media-buy/scenarios/vendor_metric_accountability.yaml +++ b/static/compliance/source/protocols/media-buy/scenarios/vendor_metric_accountability.yaml @@ -273,10 +273,12 @@ phases: get_media_buy_delivery and reconcile each package independently. The semantic uniqueness key for each vendor metric row is - `(vendor.domain, vendor.brand_id, metric_id)`. Since brand_id is optional - in BrandRef, the operative key for single-brand vendors is - `(vendor.domain, metric_id)`. Sellers MUST NOT emit the same - `(vendor, metric_id)` pair twice in a single delivery report. + `(vendor.domain, vendor.brand_id, metric_id, qualifier)`. Since brand_id + is optional in BrandRef, the operative key for single-brand vendors is + `(vendor.domain, metric_id, qualifier)`. The same vendor metric MAY + appear in multiple rows only when each carries a distinct qualifier + (e.g., 7-day and 30-day attribution windows); sellers MUST NOT emit two + rows with the same tuple in a single delivery report. steps: - id: simulate_delivery_with_vendor_metrics title: "Inject simulated delivery with vendor_metric_values"