diff --git a/.changeset/metric-qualifier-vendor-symmetry.md b/.changeset/metric-qualifier-vendor-symmetry.md new file mode 100644 index 0000000000..a3b9f635bd --- /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`, `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/docs/media-buy/task-reference/create_media_buy.mdx b/docs/media-buy/task-reference/create_media_buy.mdx index b685b2160f..04f425a476 100644 --- a/docs/media-buy/task-reference/create_media_buy.mdx +++ b/docs/media-buy/task-reference/create_media_buy.mdx @@ -372,6 +372,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 8103bf2879..62c280c970 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "deploy:cdn-artifacts-cutover:dry-run": "wrangler deploy --config workers/artifact-cdn/wrangler.cutover.toml --dry-run", "verify:cdn-artifacts-cutover": "node scripts/verify-cdn-artifacts-cutover.mjs", "typecheck": "tsc --project server/tsconfig.json --noEmit", - "test:schemas": "node tests/schema-validation.test.cjs && node --test tests/outcome-target.test.cjs tests/trusted-match-offer-creative-data.test.cjs tests/accessibility-violation-details.test.cjs tests/portfolio-routing-scope.test.cjs tests/catalog-item-availability-updates.test.cjs tests/compact-product-lifecycle-storyboards.test.cjs tests/timezone-resolution-storyboards.test.cjs tests/schema-deprecation-metadata.test.cjs tests/products-only-brief-compatibility.test.cjs tests/async-identity-convergence.test.cjs tests/creative-rotation.test.cjs tests/metric-identity-coherence.test.cjs tests/sort-contract-delivery-reporting.test.cjs tests/time-based-views-contract.test.cjs tests/lint-schema-enum-drift.test.cjs tests/synthetic-depiction.test.cjs tests/creative-rendering-authority.test.cjs && npm run test:premium-display-formats && npm run test:geo-region-targeting", + "test:schemas": "node tests/schema-validation.test.cjs && node --test tests/outcome-target.test.cjs tests/trusted-match-offer-creative-data.test.cjs tests/accessibility-violation-details.test.cjs tests/portfolio-routing-scope.test.cjs tests/catalog-item-availability-updates.test.cjs tests/compact-product-lifecycle-storyboards.test.cjs tests/timezone-resolution-storyboards.test.cjs tests/schema-deprecation-metadata.test.cjs tests/products-only-brief-compatibility.test.cjs tests/async-identity-convergence.test.cjs tests/creative-rotation.test.cjs tests/metric-identity-coherence.test.cjs tests/sort-contract-delivery-reporting.test.cjs tests/time-based-views-contract.test.cjs tests/metric-qualifier-parity.test.cjs tests/lint-schema-enum-drift.test.cjs tests/synthetic-depiction.test.cjs tests/creative-rendering-authority.test.cjs && npm run test:premium-display-formats && npm run test:geo-region-targeting", "test:performance-feedback": "node --test --test-force-exit --test-timeout=30000 tests/performance-feedback-contract.test.cjs", "test:dist-schema-version-ids": "node --test --test-force-exit --test-timeout=30000 tests/dist-schema-version-ids.test.cjs", "test:examples": "node tests/example-validation-simple.test.cjs && npm run test:tmp-context-merge", diff --git a/static/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" 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..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", @@ -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": "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", + "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": "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 }, "value": { diff --git a/static/schemas/source/core/delivery-metrics.json b/static/schemas/source/core/delivery-metrics.json index eb8d273cc3..3df857e3d4 100644 --- a/static/schemas/source/core/delivery-metrics.json +++ b/static/schemas/source/core/delivery-metrics.json @@ -441,7 +441,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/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/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 f413662727..90f78cb8a2 100644 --- a/static/schemas/source/media-buy/package-request.json +++ b/static/schemas/source/media-buy/package-request.json @@ -247,6 +247,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 new file mode 100644 index 0000000000..6b56ab9641 --- /dev/null +++ b/tests/metric-qualifier-parity.test.cjs @@ -0,0 +1,284 @@ +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", () => { + 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); + 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, + `${label} vendor qualifier key set does not match standard` + ); + assert.equal( + vendorQualifier.additionalProperties, + false, + `${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"); + 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); + }); + }); +});