Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-demographic-delivery-reporting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"adcontextprotocol": minor
---

Add opt-in demographic delivery breakdowns to `get_media_buy_delivery`, reusing the existing demographic-system vocabulary and declaring support in product reporting capabilities.
3 changes: 3 additions & 0 deletions docs/media-buy/media-buys/optimization-reporting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1276,10 +1276,13 @@ Delivery data can be broken down across multiple dimensions within each package.
| Device type | `by_device_type` | `device_type`, `impressions`, `spend` | — | `supports_device_type_breakdown` |
| Device platform | `by_device_platform` | `device_platform`, `impressions`, `spend` | — | `supports_device_platform_breakdown` |
| Audience | `by_audience` | `audience_id`, `audience_source`, `impressions`, `spend` | `audience_name` | `supports_audience_breakdown` |
| Demographic | `by_demographic` | `demographic`, `demographic_system`, `impressions`, `spend` | other delivery metrics | `supports_demographic_breakdown` |
| Placement | `by_placement` | `placement_id`, `impressions`, `spend` | `publisher_domain`, `placement_name` | `supports_placement_breakdown` |

Each breakdown entry inherits all fields from `delivery-metrics` (clicks, conversions, and other optional metrics) plus its dimension-specific fields. Every entry requires the fields shown in the required fields column. Check `reporting_capabilities` on the product to discover which dimensions are available; product-level capabilities are authoritative because different products from the same seller may support different breakdowns. `supports_geo_breakdown` is an object that declares available levels and systems; the other capability declarations in this table are boolean flags. Within `supports_geo_breakdown`, `country` and `region` are booleans, while `metro` is keyed by `metro-system` values and native `postal_area` is keyed by ISO 3166-1 alpha-2 country with arrays of country-local `postal-system` values. Geo rows use `system` for `geo_level: "metro"` and `"postal_area"`; native postal rows also include `country`. Deprecated country-fused postal systems remain accepted for compatibility.

Demographic rows pair a free-form `demographic` code with the `demographic_system` that defines its notation, such as `P25-54` with `nielsen`. This reuses the same vocabulary as CPP pricing and GRP forecasts.

Placement identity is publisher-scoped. Placement rows MAY carry `publisher_domain`, the publisher namespace from the product's `placements[]` entry, so buyers can treat `{publisher_domain, placement_id}` as the stable placement identity for multi-publisher products when it is present. Sellers SHOULD emit `publisher_domain` whenever the product placement carries it (always true for `kind: "publisher_ref"`); sellers MAY omit it only for `kind: "seller_inline"` placements in a legacy single-publisher context where the seller agent's own domain is the namespace. When `publisher_domain` is omitted, buyers MAY interpret `placement_id` relative to the seller agent's own publisher domain only in that legacy single-publisher context; otherwise buyers should not infer a cross-publisher placement key. `publisher_domain` is single-valued because each placement belongs to exactly one publisher namespace.

Breakdowns are opt-in — no dimension data is returned unless explicitly requested. Sellers that don't support a requested dimension silently omit it. Each breakdown array has a sibling `by_*_truncated` boolean indicating whether additional rows exist beyond the requested `limit`.
Expand Down
7 changes: 5 additions & 2 deletions docs/media-buy/task-reference/get_media_buy_delivery.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Retrieve comprehensive delivery metrics and performance data for media buy repor
| `status_filter` | string \| string[] | No | Status filter: `"pending_creatives"`, `"pending_start"`, `"active"`, `"paused"`, `"completed"`. Defaults to `["active"]` when omitted. |
| `start_date` | string | No | Report start date (YYYY-MM-DD), inclusive. Omit for campaign lifetime data. Only accepted when product supports `date_range`. |
| `end_date` | string | No | Report end date (YYYY-MM-DD), **exclusive**. Omit for campaign lifetime data. Only accepted when product supports `date_range`. |
| `reporting_dimensions` | object | No | Request dimensional breakdowns within `by_package`. Include a key as an empty object (e.g., `"device_type": {}`) to activate with defaults. Keys: `geo`, `device_type`, `device_platform`, `audience`, `placement`. Each accepts optional `limit` (defaults to 25 for geo, audience, placement) and `sort_by` (sort-metric enum, default: `spend`). Geo requires `geo_level` (one per request); include `system` for metro/postal levels when requesting a specific system. Unsupported dimensions are silently omitted; malformed requests return a validation error. |
| `reporting_dimensions` | object | No | Request dimensional breakdowns within `by_package`. Include a key as an empty object (e.g., `"device_type": {}`) to activate with defaults. Keys: `geo`, `device_type`, `device_platform`, `audience`, `demographic`, `placement`. Each accepts optional `limit` (defaults to 25 for geo, audience, demographic, and placement) and `sort_by` (sort-metric enum, default: `spend`). Geo requires `geo_level` (one per request); include `system` for metro/postal levels when requesting a specific system. Unsupported dimensions are silently omitted; malformed requests return a validation error. |
| `time_granularity` | string | No | Per-window slice granularity for pull recovery, matching `reporting_webhook.reporting_frequency` vocabulary (`hourly`, `daily`, `weekly`, `monthly`, `quarterly`, `post_campaign`). When set, the response includes `windows[]` slices shape-aligned with webhook fires at the same granularity. Capability-scoped — value MUST be in the product's `reporting_capabilities.windowed_pull_granularities`. See [Windowed pull recovery](#windowed-pull-recovery). |
| `include_window_breakdown` | boolean | No | When `true` (and `time_granularity` is set), include the `windows[]` array on each media buy. Defaults to `false`. Ignored when `time_granularity` is omitted. |

Expand Down Expand Up @@ -891,7 +891,7 @@ When you include `reporting_dimensions` in the request, the response includes di
}
```

Each dimension accepts optional `limit` (max rows; defaults to 25 for geo, audience, and placement) and `sort_by` (any value from the `sort-metric` enum, e.g., `spend`, `impressions`, `clicks`, `roas` — defaults to `spend` descending; falls back to `spend` if the seller does not report the requested metric). Geo requires `geo_level` (`country`, `region`, `metro`, `postal_area`). Include `system` for metro/postal levels when requesting a specific system; native postal requests also include `country` (for example, `{ "geo_level": "postal_area", "country": "US", "system": "zip" }`). Each request uses a single geo_level — for multiple granularities (e.g., country and region), make separate requests. Unsupported dimensions are silently omitted from the response, but malformed requests (e.g., geo without `geo_level`) return a validation error. Breakdowns are per-dimension only — cross-dimensional intersections (e.g., device_type × geo) are not supported.
Each dimension accepts optional `limit` (max rows; defaults to 25 for geo, audience, demographic, and placement) and `sort_by` (any value from the `sort-metric` enum, e.g., `spend`, `impressions`, `clicks`, `roas` — defaults to `spend` descending; falls back to `spend` if the seller does not report the requested metric). Geo requires `geo_level` (`country`, `region`, `metro`, `postal_area`). Include `system` for metro/postal levels when requesting a specific system; native postal requests also include `country` (for example, `{ "geo_level": "postal_area", "country": "US", "system": "zip" }`). Each request uses a single geo_level — for multiple granularities (e.g., country and region), make separate requests. Unsupported dimensions are silently omitted from the response, but malformed requests (e.g., geo without `geo_level`) return a validation error. Breakdowns are per-dimension only — cross-dimensional intersections (e.g., device_type × geo) are not supported.

### Available dimensions

Expand All @@ -901,10 +901,13 @@ Each dimension accepts optional `limit` (max rows; defaults to 25 for geo, audie
| Device type | `by_device_type` | `device_type`, `impressions`, `spend` | — | `supports_device_type_breakdown` |
| Device platform | `by_device_platform` | `device_platform`, `impressions`, `spend` | — | `supports_device_platform_breakdown` |
| Audience | `by_audience` | `audience_id`, `audience_source`, `impressions`, `spend` | `audience_name` | `supports_audience_breakdown` |
| Demographic | `by_demographic` | `demographic`, `demographic_system`, `impressions`, `spend` | other delivery metrics | `supports_demographic_breakdown` |
| Placement | `by_placement` | `placement_id`, `impressions`, `spend` | `publisher_domain`, `placement_name` | `supports_placement_breakdown` |

Check `reporting_capabilities` on the product to discover which dimensions are available. Product-level capabilities are authoritative since different products from the same seller may support different breakdowns.

`demographic` uses the notation of its accompanying `demographic_system`; for example, `{ "demographic": "P25-54", "demographic_system": "nielsen" }`. The code is intentionally free-form because each supported system defines its own notation.

`supports_geo_breakdown` is an object that declares available levels and systems; the other capability declarations in this table are boolean flags. Within `supports_geo_breakdown`, `country` and `region` are booleans, while `metro` is keyed by `metro-system` values and native `postal_area` is keyed by ISO 3166-1 alpha-2 country with arrays of country-local `postal-system` values. Geo rows use `system` for `geo_level: "metro"` and `"postal_area"`; native postal rows also include `country`. Deprecated country-fused postal systems remain accepted for compatibility.

Placement identity is publisher-scoped. Placement rows MAY carry `publisher_domain` — the publisher namespace from the product's `placements[]` entry — so buyers can treat `{publisher_domain, placement_id}` as the stable placement identity for multi-publisher products when it is present. Sellers SHOULD emit `publisher_domain` whenever the product placement carries it (always true for `kind: "publisher_ref"`); sellers MAY omit it only for `kind: "seller_inline"` placements in a legacy single-publisher context where the seller agent's own domain is the namespace. When `publisher_domain` is omitted, buyers MAY interpret `placement_id` relative to the seller agent's own publisher domain only in that legacy single-publisher context; otherwise buyers should not infer a cross-publisher placement key. `publisher_domain` is single-valued because each placement belongs to exactly one publisher namespace.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"supports_device_type_breakdown": { "type": "boolean" },
"supports_device_platform_breakdown": { "type": "boolean" },
"supports_audience_breakdown": { "type": "boolean" },
"supports_demographic_breakdown": { "type": "boolean" },
"supports_placement_breakdown": { "type": "boolean" },
"date_range_support": { "type": "string", "enum": ["date_range", "lifetime_only"] },
"windowed_pull_granularities": { "type": "array", "items": { "$ref": "/schemas/enums/reporting-frequency.json" }, "uniqueItems": true },
Expand Down
4 changes: 4 additions & 0 deletions static/schemas/source/core/reporting-capabilities.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@
"type": "boolean",
"description": "Whether this product supports audience segment breakdowns in delivery reporting (by_audience within by_package)"
},
"supports_demographic_breakdown": {
"type": "boolean",
"description": "Whether this product supports measurement-system demographic breakdowns in delivery reporting (by_demographic within by_package)"
},
"supports_placement_breakdown": {
"type": "boolean",
"description": "Whether this product supports placement breakdowns in delivery reporting (by_placement within by_package)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,23 @@
},
"additionalProperties": true
},
"demographic": {
"type": "object",
"description": "Request delivery broken down by measurement-system demographic codes (for example, P25-54 in Nielsen notation). Check reporting_capabilities.supports_demographic_breakdown before requesting this dimension.",
"properties": {
"limit": {
"type": "integer",
"minimum": 1,
"default": 25,
"description": "Maximum number of demographic entries to return. Defaults to 25."
},
"sort_by": {
"$ref": "/schemas/enums/sort-metric.json",
"description": "Metric to sort breakdown rows by (descending). Falls back to 'spend' if the seller does not report the requested metric.",
"default": "spend"
}
}
},
"placement": {
"type": "object",
"description": "Request placement breakdown.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,42 @@
"type": "boolean",
"description": "Whether by_audience was truncated. Sellers MUST return this flag whenever by_audience is present (false means the list is complete)."
},
"by_demographic": {
"type": "array",
"description": "Delivery by measurement-system demographic within this package. Available when the buyer requests demographic breakdown via reporting_dimensions and the product declares supports_demographic_breakdown. Demographic is a free-form code in the declared demographic_system's notation.",
"items": {
"allOf": [
{
"$ref": "/schemas/core/delivery-metrics.json"
},
{
"type": "object",
"properties": {
"demographic": {
"type": "string",
"minLength": 1,
"pattern": "\\S",
"description": "Demographic code in the declared system's notation (for example, P25-54 for Nielsen)."
},
"demographic_system": {
"$ref": "/schemas/enums/demographic-system.json",
"description": "Measurement system that defines the demographic code."
}
},
"required": [
"demographic",
"demographic_system",
"impressions",
"spend"
]
}
]
}
},
"by_demographic_truncated": {
"type": "boolean",
"description": "Whether by_demographic was truncated due to the requested limit or a seller-imposed maximum. Sellers MUST return this flag whenever by_demographic is present (false means the list is complete)."
},
"by_placement": {
"type": "array",
"description": "Delivery by placement within this package. Available when the buyer requests placement breakdown via reporting_dimensions and the seller supports it. Placement IDs reference the product's placements array.",
Expand Down
101 changes: 101 additions & 0 deletions tests/demographic-delivery-reporting-contract.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const assert = require('node:assert/strict');
const Ajv = require('ajv');
const addFormats = require('ajv-formats');

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(uri) {
const ajv = new Ajv({
allErrors: true,
strict: false,
discriminator: true,
loadSchema: async ref => readSchema(ref),
});
addFormats(ajv);
return ajv.compileAsync(readSchema(uri));
}

test('demographic reporting request accepts limits and sort metrics', async () => {
const validate = await compile('/schemas/media-buy/get-media-buy-delivery-request.json');

assert.equal(validate({ reporting_dimensions: { demographic: {} } }), true);
assert.equal(
validate({ reporting_dimensions: { demographic: { limit: 10, sort_by: 'impressions' } } }),
true,
JSON.stringify(validate.errors),
);
assert.equal(validate({ reporting_dimensions: { demographic: { limit: 0 } } }), false);
});

test('demographic delivery rows reuse the shared demographic system vocabulary', async () => {
const response = readSchema('/schemas/media-buy/get-media-buy-delivery-response.json');
const byPackage = response.properties.media_buy_deliveries.items.properties.by_package.items;
const extension = byPackage.allOf.find(part => part.properties?.by_demographic);
assert.ok(extension, 'by_package exposes by_demographic');

const validate = await compile('/schemas/media-buy/get-media-buy-delivery-response.json');
const base = {
status: 'completed',
reporting_period: {
start: '2026-08-01T00:00:00Z',
end: '2026-08-15T23:59:59Z',
},
currency: 'USD',
media_buy_deliveries: [{
media_buy_id: 'mb_demo',
buyer_ref: 'buyer_demo',
status: 'active',
currency: 'USD',
total_spend: 14400,
totals: {
spend: 14400,
impressions: 1200000,
},
by_package: [{
package_id: 'pkg_demo',
spend: 14400,
impressions: 1200000,
pricing_model: 'cpm',
rate: 12,
currency: 'USD',
by_demographic: [{
demographic: 'P25-54',
demographic_system: 'nielsen',
impressions: 1200000,
spend: 14400,
}],
by_demographic_truncated: false,
}],
}],
};

assert.equal(validate(base), true, JSON.stringify(validate.errors));

const unknownSystem = structuredClone(base);
unknownSystem.media_buy_deliveries[0].by_package[0].by_demographic[0].demographic_system = 'unknown';
assert.equal(validate(unknownSystem), false, 'unknown demographic systems are rejected');

const missingCode = structuredClone(base);
delete missingCode.media_buy_deliveries[0].by_package[0].by_demographic[0].demographic;
assert.equal(validate(missingCode), false, 'demographic code is required');

const blankCode = structuredClone(base);
blankCode.media_buy_deliveries[0].by_package[0].by_demographic[0].demographic = ' ';
assert.equal(validate(blankCode), false, 'blank demographic codes are rejected');
});

test('reporting capabilities expose demographic breakdown support on both surfaces', () => {
const legacy = readSchema('/schemas/core/reporting-capabilities.json');
const canonical = readSchema('/schemas/core/canonical-reporting-capabilities.json');

assert.equal(legacy.properties.supports_demographic_breakdown.type, 'boolean');
assert.equal(canonical.properties.supports_demographic_breakdown.type, 'boolean');
});
2 changes: 1 addition & 1 deletion tests/mcp-schema-analysis.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ test("experiment report keeps all alternatives smaller than standalone model con
assert.equal(report.status, "non-normative");
assert.equal(report.prompt_cleanup_adapter.required, true);
assert.equal(report.selection.tools.length, 16);
assert.equal(variants.standalone.context_bytes, 285_447);
assert.equal(variants.standalone.context_bytes, 285_583);
assert.ok(
variants.prompt_cleanup.context_bytes <
variants.standalone.context_bytes * 0.82
Expand Down
Loading