From 9e792bb2348c395dc19640b491e2ee5a4a52189d Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 8 Aug 2026 10:06:33 +0200 Subject: [PATCH] spec(tmp): preserve provider-attributed targeting KVs --- ...eserve-tmp-context-provider-attribution.md | 5 + .github/workflows/build-check.yml | 3 + docs/trusted-match/ai-mediation.mdx | 1 + docs/trusted-match/buyer-guide.mdx | 3 +- docs/trusted-match/context-and-identity.mdx | 9 +- docs/trusted-match/index.mdx | 1 + docs/trusted-match/router-architecture.mdx | 8 +- docs/trusted-match/specification.mdx | 36 ++- docs/trusted-match/surfaces/ai-assistants.mdx | 3 + docs/trusted-match/surfaces/ctv.mdx | 1 + docs/trusted-match/surfaces/mobile.mdx | 2 + docs/trusted-match/surfaces/retail-media.mdx | 1 + docs/trusted-match/surfaces/web.mdx | 23 +- package.json | 3 +- .../trusted-match-context-merge/README.md | 7 + .../trusted-match-context-merge/vectors.json | 230 ++++++++++++++++++ static/schemas/source/index.json | 6 +- .../trusted-match/context-match-response.json | 116 ++++++--- .../provider-context-match-response.json | 106 ++++++++ tests/example-validation-simple.test.cjs | 70 +++++- tests/trusted-match-context-merge.test.cjs | 144 +++++++++++ 21 files changed, 717 insertions(+), 61 deletions(-) create mode 100644 .changeset/preserve-tmp-context-provider-attribution.md create mode 100644 static/compliance/source/test-vectors/trusted-match-context-merge/README.md create mode 100644 static/compliance/source/test-vectors/trusted-match-context-merge/vectors.json create mode 100644 static/schemas/source/trusted-match/provider-context-match-response.json create mode 100644 tests/trusted-match-context-merge.test.cjs diff --git a/.changeset/preserve-tmp-context-provider-attribution.md b/.changeset/preserve-tmp-context-provider-attribution.md new file mode 100644 index 0000000000..34d2524e00 --- /dev/null +++ b/.changeset/preserve-tmp-context-provider-attribution.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": patch +--- + +Preserve Trusted Match Context targeting key-values in router-authored, provider-attributed buckets. diff --git a/.github/workflows/build-check.yml b/.github/workflows/build-check.yml index d96f7026a7..893c59026b 100644 --- a/.github/workflows/build-check.yml +++ b/.github/workflows/build-check.yml @@ -88,6 +88,9 @@ jobs: - name: Validate schemas run: npm run test:schemas && npm run test:json-schema && npm run test:adagents-catalog-only && npm run test:extension-schemas && npm run test:composed + - name: Trusted Match Context merge conformance vectors + run: npm run test:tmp-context-merge + - name: Validate MCP schema projection run: npm run test:mcp-schema-projection diff --git a/docs/trusted-match/ai-mediation.mdx b/docs/trusted-match/ai-mediation.mdx index af96a41ec5..32058e352c 100644 --- a/docs/trusted-match/ai-mediation.mdx +++ b/docs/trusted-match/ai-mediation.mdx @@ -97,6 +97,7 @@ Priya chose full recommendation for StreamHaus's assistant: ```json { + "status": "completed", "type": "context_match_response", "request_id": "ctx-trail-shoes-01", "offers": [ diff --git a/docs/trusted-match/buyer-guide.mdx b/docs/trusted-match/buyer-guide.mdx index cf1444dcc7..27d6c003b5 100644 --- a/docs/trusted-match/buyer-guide.mdx +++ b/docs/trusted-match/buyer-guide.mdx @@ -64,8 +64,9 @@ The router sends you page context. You evaluate your active packages against tha 3. Return offers for packages that match, each with a creative manifest ```json -// Your response +// Your provider→router response (provider-context-match-response.json) { + "status": "completed", "type": "context_match_response", "request_id": "ctx-8f3a2b", "offers": [ diff --git a/docs/trusted-match/context-and-identity.mdx b/docs/trusted-match/context-and-identity.mdx index 4320b84fb1..aef8b5f23e 100644 --- a/docs/trusted-match/context-and-identity.mdx +++ b/docs/trusted-match/context-and-identity.mdx @@ -56,6 +56,7 @@ The buyer agent evaluates each package against the context. Package B is homepag ```json { + "status": "completed", "type": "context_match_response", "request_id": "ctx-7f3a", "offers": [ @@ -100,7 +101,7 @@ The buyer agent evaluates each package against the context. Package B is homepag } ``` -The buyer returns an offer per matched package. Each offer carries a `package_id` and optionally a `brand`, `price`, `summary`, `creative_manifest`, and `macros`. The `summary` gives the publisher enough to judge relevance. When the creative manifest is present inline, the publisher has everything needed to render. For large creatives (e.g., VAST video), the manifest references external assets via URLs rather than embedding them. The response also includes enrichment signals — audience segments and targeting key-values — that the publisher can pass to their ad server. +The buyer returns an offer per matched package. Each offer carries a `package_id` and optionally a `brand`, `price`, `summary`, `creative_manifest`, and `macros`. The `summary` gives the publisher enough to judge relevance. When the creative manifest is present inline, the publisher has everything needed to render. For large creatives (e.g., VAST video), the manifest references external assets via URLs rather than embedding them. This provider-hop response validates against `provider-context-match-response.json`. Targeting pairs use `signals.targeting_kvs`; the router preserves them unchanged under `signals_by_provider[provider_id].targeting_kvs` in the `context-match-response.json` shape returned to the publisher. ### What Context Match never carries @@ -186,7 +187,7 @@ For each offer from Context Match: → Skip this offer for this user. Result: Accept Package A. -Set targeting KVs: category_affinity=beverages, seasonal_relevance=high. +Resolve the provider-attributed targeting tuples through the publisher's local mapping, then set the mapped ad-server keys. Drop any tuple without a mapping. ``` The publisher already knows how to render Package A — it maps to a sponsored carousel slot. The inline creative manifest carries the catalog items and assets needed. The publisher handled the rest. @@ -195,7 +196,7 @@ The publisher already knows how to render Package A — it maps to a sponsored c TMP's response model is the offer. An offer carries a `package_id` (required) and optional fields: `brand`, `price`, `summary`, `creative_manifest`, and `macros` (a key-value map for dynamic values). -**Simple case (GAM/Prebid)**: The offer carries `package_id`. The publisher flows `package_id` via `targeting_kvs` signals to GAM for line item matching. The `macros` map carries dynamic values (e.g., sponsor labels, promo text) that GAM can insert into the creative at render time. +**Simple case (GAM/Prebid)**: The offer carries `package_id`. The provider may return a provider-local package targeting pair in `signals.targeting_kvs`; the router attributes it in `signals_by_provider`, and the publisher maps `(provider_id, key)` to its GAM key for line item matching. The `macros` map carries dynamic values (e.g., sponsor labels, promo text) that GAM can insert into the creative at render time. **Rich case (AI assistants, dynamic retail)**: The offer includes a `summary` ("50% off cold brew — recipe integration") so the publisher can judge relevance, and an inline `creative_manifest` with everything needed to render. For large creatives (e.g., VAST video), the manifest references external assets via URLs rather than embedding the full payload. @@ -212,7 +213,7 @@ Per-user exposure tracking flows through the TMPX macro — an encrypted token f Context Match responses can include enrichment signals alongside package activation: - **Segments**: Audience or contextual segments (e.g., "coffee_enthusiast", "high_purchase_intent") that flow into the publisher's ad server as targeting signals. -- **Targeting key-values**: Arbitrary key-value pairs (e.g., `category_affinity=beverages`) that the publisher can use for line item targeting, reporting breakdowns, or real-time decisioning. +- **Targeting key-values**: Provider-local key-value pairs (e.g., `category_affinity=beverages`). The router attributes them under the provider's registered `provider_id`; the publisher maps `(provider_id, key)` to its local destination before using the value for line item targeting, reporting breakdowns, or real-time decisioning. Unmapped tuples are dropped. Enrichment signals are additive — they are not tied to specific packages. A buyer agent might return enrichment signals even when it activates no packages, providing value as a data provider rather than a demand source. diff --git a/docs/trusted-match/index.mdx b/docs/trusted-match/index.mdx index ce82c3a6ce..065eb67c9a 100644 --- a/docs/trusted-match/index.mdx +++ b/docs/trusted-match/index.mdx @@ -110,6 +110,7 @@ Response from Sam's buyer agent: ```json { + "status": "completed", "type": "context_match_response", "request_id": "ctx-8f3a2b", "offers": [ diff --git a/docs/trusted-match/router-architecture.mdx b/docs/trusted-match/router-architecture.mdx index 1dd376d775..f987484690 100644 --- a/docs/trusted-match/router-architecture.mdx +++ b/docs/trusted-match/router-architecture.mdx @@ -136,12 +136,14 @@ Identity tokens come from existing providers (ID5, LiveRamp, UID2, etc.) that ar When the publisher sends a Context Match request: 1. The router identifies all providers configured for the request's `property_rid` with `context_match` capability. -2. It sends the request to all matching providers in parallel over HTTP/2. +2. It sends the request to all matching providers in parallel over HTTP/2 and validates their responses against `provider-context-match-response.json`. That provider-hop schema permits `signals.targeting_kvs` but forbids `signals_by_provider`. 3. It waits for responses up to the latency budget (default: 50ms). 4. It merges responses: - **Offers** are collected from all providers. If two providers return offers for the same `package_id` (uncommon — packages are typically provider-specific), the router keeps the offer from the higher-priority provider (lower `priority` value). Equal priorities are broken by first response received. Duplicate `package_id` across providers is a configuration error; the router SHOULD log a warning that identifies both providers and the selected winner. - - **Enrichment signals** are concatenated. Segments from all providers are combined into a single list. Targeting key-values from different providers are namespaced to prevent collisions. -5. It returns the merged response to the publisher. + - **Enrichment signals** are merged by kind. Segments from all providers are combined into `signals.segments`. Targeting key-values retain provider attribution: for every provider that returns a non-empty `signals.targeting_kvs` list, the router copies the list unchanged into `signals_by_provider[provider_id].targeting_kvs`, using the publisher-assigned `provider_id` from that provider's registration. The router MUST derive this key itself and MUST ignore or reject any provider-supplied `signals_by_provider` map. A provider with no targeting pairs is omitted. +5. It validates the merged response against `context-match-response.json` and returns it to the publisher. That router-hop schema permits `signals_by_provider` and forbids flattened `signals.targeting_kvs` even when no attributed bucket is present. + +The publisher resolves each `(provider_id, key)` tuple in `signals_by_provider` to the ad-server destination configured for that provider on the local surface. This mapping is publisher-owned deployment configuration and does not travel between provider and router. Tuples without a local mapping MUST be dropped; the publisher MUST NOT fall back to treating an unrecognized provider-local key as a global ad-server key. Distinct providers may therefore use the same local key without collision or cross-provider name capture. ### Identity Match fan-out diff --git a/docs/trusted-match/specification.mdx b/docs/trusted-match/specification.mdx index 31aca99219..3571c9cf77 100644 --- a/docs/trusted-match/specification.mdx +++ b/docs/trusted-match/specification.mdx @@ -126,7 +126,9 @@ Geographic context for the impression opportunity. Publisher controls granularit ### ContextMatchResponse -Returned by the buyer agent. Contains offers for matched packages and optional response-level targeting signals. +Contains offers for matched packages and optional response-level signals. Two schemas carry this message across the two hops of a Context Match fan-out so the provider boundary cannot claim router-authored attribution and the publisher boundary cannot receive flattened provider targeting keys. + +**Provider → router** (`provider-context-match-response.json`): | Field | Type | Required | Description | |---|---|---|---| @@ -134,7 +136,21 @@ Returned by the buyer agent. Contains offers for matched packages and optional r | `request_id` | string | Yes | Echo of the request's `request_id`. | | `offers` | List\ | Yes | Offers from the buyer, one per activated package. Empty list means no packages matched. | | `cache_ttl` | integer | No | Provider override (in seconds) for the router's default Context Match response cache TTL. When present, routers MUST use this value instead of their default. `0` disables caching (e.g., when targeting configuration has just changed); schema-enforced maximum is 86400 seconds. See [Caching](#caching). | -| `signals` | Signals | No | Response-level targeting signals for ad server pass-through. Not per-offer — applies to the response as a whole. In the GAM case, these carry the key-value pairs that trigger line items. | +| `signals` | Signals | No | Provider-origin response-level signals. Targeting pairs use the provider's local vocabulary in `signals.targeting_kvs`; the provider MUST NOT author `signals_by_provider`. | + +**Router → publisher** (`context-match-response.json`): + +| Field | Type | Required | Description | +|---|---|---|---| +| `type` | string | Yes | `"context_match_response"`. Message type discriminator for deserialization. | +| `request_id` | string | Yes | Echo of the request's `request_id`. | +| `offers` | List\ | Yes | Offers collected across the provider fan-out. Empty list means no packages matched. | +| `signals` | Signals | No | Merged non-keyed response-level signals such as `segments`. The router-facing schema forbids flattened `signals.targeting_kvs`, even when `signals_by_provider` is absent. | +| `signals_by_provider` | Map\ | No | Router-authored targeting pairs grouped under the publisher-assigned `provider_id` from provider registration. For every provider response containing a non-empty `signals.targeting_kvs` list, the router copies the complete list unchanged into `signals_by_provider[provider_id].targeting_kvs`. The router derives the key from registration and MUST ignore or reject provider-supplied buckets. Providers with no targeting pairs are omitted. Map keys match the registered `provider_id` charset (`^[A-Za-z0-9_]+$`, 1–64 chars). | + +The hop-specific fields are disjoint. `provider-context-match-response.json` permits `signals.targeting_kvs` and forbids `signals_by_provider`; `context-match-response.json` permits `signals_by_provider` and forbids `signals.targeting_kvs` whether or not the attributed map is present. Both forbid `context` and `ext` so extension data cannot cross the context privacy boundary. + +`signals_by_provider` is the attribution boundary for merged targeting signals. The router MUST NOT invent a namespace by rewriting provider keys, accept a provider's claimed bucket name, or flatten attributed pairs back into `signals.targeting_kvs`. The publisher resolves each `(provider_id, key)` tuple through publisher-owned deployment configuration to a local ad-server destination. A tuple without a local mapping MUST be dropped; its provider-local `key` MUST NOT be treated as a global or publisher-owned targeting key. The mapping format is deployment-specific in 3.2 and is not carried on either protocol hop. #### Offer @@ -160,19 +176,27 @@ A buyer's response for a single package. #### Signals -Response-level targeting signals for ad server pass-through. +Response-level context signals. Providers may emit both fields; routers combine `segments` but move targeting pairs to `signals_by_provider` rather than passing provider-local keys through as flattened publisher targeting. | Field | Type | Required | Description | |---|---|---|---| | `segments` | List\ | No | Audience or contextual segment IDs. | -| `targeting_kvs` | List\ | No | Key-value pairs for ad server targeting. | +| `targeting_kvs` | List\ | No | Provider-hop only. Provider-local key-value pairs for ad-server targeting. A router preserves the complete list unchanged in the corresponding `signals_by_provider` bucket; this field is forbidden on router-to-publisher responses. | + +#### ProviderSignals + +Router-authored bucket preserving one provider's targeting pairs. + +| Field | Type | Required | Description | +|---|---|---|---| +| `targeting_kvs` | List\ | Yes | Non-empty exact copy of the provider's `signals.targeting_kvs` list. Providers with an absent or empty list have no bucket. | #### KeyValuePair | Field | Type | Required | Description | |---|---|---|---| -| `key` | string | Yes | Targeting key. | -| `value` | string | Yes | Targeting value. | +| `key` | string | Yes | Provider-local targeting key. Publishers resolve it together with `provider_id`; it is not globally unique or itself a publisher ad-server destination. | +| `value` | string | Yes | Targeting value, preserved unchanged by the router. | ### IdentityMatchRequest diff --git a/docs/trusted-match/surfaces/ai-assistants.mdx b/docs/trusted-match/surfaces/ai-assistants.mdx index 5026f76290..76881fc232 100644 --- a/docs/trusted-match/surfaces/ai-assistants.mdx +++ b/docs/trusted-match/surfaces/ai-assistants.mdx @@ -56,6 +56,7 @@ The buyer agent responds with an offer: ```json { + "status": "completed", "type": "context_match_response", "request_id": "ctx-a1b2c3d4", "offers": [ @@ -84,6 +85,8 @@ The buyer agent responds with an offer: } ``` +This buyer response uses the provider-hop `provider-context-match-response.json` shape, where `signals.targeting_kvs` contains provider-local keys. The router moves those pairs unchanged into its router-authored `signals_by_provider` buckets; it never forwards this flattened list to the publisher. + The buyer's offer includes `package_id` (required) along with optional fields: `brand`, `price`, `summary`, `creative_manifest`, and `macros`. For an AI assistant, the creative manifest is small enough to send inline in the real-time path. The manifest carries the text the platform can weave into the conversation and the catalog items to reference. The `summary` helps the platform judge relevance before deciding whether to incorporate the sponsored content. ## Identity Match diff --git a/docs/trusted-match/surfaces/ctv.mdx b/docs/trusted-match/surfaces/ctv.mdx index 9a7825ec65..457369c699 100644 --- a/docs/trusted-match/surfaces/ctv.mdx +++ b/docs/trusted-match/surfaces/ctv.mdx @@ -48,6 +48,7 @@ Each buyer agent evaluates the content context and responds with offers for pack ```json { + "status": "completed", "type": "context_match_response", "request_id": "ctx-9f3a-e7b2", "offers": [ diff --git a/docs/trusted-match/surfaces/mobile.mdx b/docs/trusted-match/surfaces/mobile.mdx index a409063926..c78241d7d2 100644 --- a/docs/trusted-match/surfaces/mobile.mdx +++ b/docs/trusted-match/surfaces/mobile.mdx @@ -66,6 +66,7 @@ No package list is sent per request. The provider evaluates all eligible package ```json { + "status": "completed", "type": "context_match_response", "request_id": "ctx-mob-7f3a91", "offers": [ @@ -107,6 +108,7 @@ A recipe app shows sponsored content cards in its recipe feed: ```json { + "status": "completed", "type": "context_match_response", "request_id": "ctx-mob-b2c419", "offers": [ diff --git a/docs/trusted-match/surfaces/retail-media.mdx b/docs/trusted-match/surfaces/retail-media.mdx index d40ed0bf5d..55fd6e3b24 100644 --- a/docs/trusted-match/surfaces/retail-media.mdx +++ b/docs/trusted-match/surfaces/retail-media.mdx @@ -35,6 +35,7 @@ The buyer responds with an offer: ```json { + "status": "completed", "type": "context_match_response", "request_id": "ctx-retail-8f3a", "offers": [ diff --git a/docs/trusted-match/surfaces/web.mdx b/docs/trusted-match/surfaces/web.mdx index 534bd225f3..3353ee553a 100644 --- a/docs/trusted-match/surfaces/web.mdx +++ b/docs/trusted-match/surfaces/web.mdx @@ -46,6 +46,7 @@ The router fans out to each buyer agent and merges the responses. Each buyer ret ```json { + "status": "completed", "type": "context_match_response", "request_id": "ctx-7f2a-oakwood-91b3", "offers": [ @@ -53,13 +54,17 @@ The router fans out to each buyer agent and merges the responses. Each buyer ret { "package_id": "pkg-native-0078" } ], "signals": { - "segments": ["sustainability", "home_cooking"], - "targeting_kvs": [ - { "key": "adcp_seg", "value": "sustainability" }, - { "key": "adcp_seg", "value": "home_cooking" }, - { "key": "adcp_pkg", "value": "pkg-display-0041" }, - { "key": "adcp_pkg", "value": "pkg-native-0078" } - ] + "segments": ["sustainability", "home_cooking"] + }, + "signals_by_provider": { + "outdoor_media": { + "targeting_kvs": [ + { "key": "segment", "value": "sustainability" }, + { "key": "segment", "value": "home_cooking" }, + { "key": "package", "value": "pkg-display-0041" }, + { "key": "package", "value": "pkg-native-0078" } + ] + } } } ``` @@ -68,7 +73,7 @@ Key points: - `offers` contains one entry per activated package. The provider's synced set for this placement included three packages — these two matched the kitchen/sustainability context, the third (`pkg-display-0103`) did not and is absent. - For web/GAM activation, offers are simple — just `package_id`. Richer fields (`brand`, `price`, `summary`, `creative_manifest`, `macros`) are available for integrations that need them but are not required. -- `signals.targeting_kvs` are the key-value pairs that the Prebid module sets on the GAM ad request. GAM line items are configured to match on these keys. +- `signals_by_provider` preserves which provider emitted each targeting pair. The Prebid module resolves each `(provider_id, key)` through publisher-owned configuration before setting the resulting GAM key. Unmapped tuples are dropped. ## Identity Match @@ -148,7 +153,7 @@ Only two packages survive: `pkg-display-0041` and `pkg-native-0078`. ### Step 2: Set GAM Targeting -The Prebid module takes the context match `signals.targeting_kvs` and sets them on the GAM ad request as key-value pairs: +The Prebid module resolves the context match `signals_by_provider` tuples through the publisher's local mapping. For example, mapping `(outdoor_media, segment)` to `adcp_seg` and `(outdoor_media, package)` to `adcp_pkg` produces: ``` adcp_seg = sustainability, home_cooking diff --git a/package.json b/package.json index 40740e115e..da2aca37f4 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "typecheck": "tsc --project server/tsconfig.json --noEmit", "test:schemas": "node tests/schema-validation.test.cjs && node --test tests/schema-deprecation-metadata.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", + "test:examples": "node tests/example-validation-simple.test.cjs && npm run test:tmp-context-merge", + "test:tmp-context-merge": "node --test --test-force-exit --test-timeout=30000 tests/trusted-match-context-merge.test.cjs", "test:extensions": "node tests/extension-fields.test.cjs", "test:extension-schemas": "node tests/extension-schemas.test.cjs", "test:snippets": "node tests/snippet-validation.test.cjs", diff --git a/static/compliance/source/test-vectors/trusted-match-context-merge/README.md b/static/compliance/source/test-vectors/trusted-match-context-merge/README.md new file mode 100644 index 0000000000..6de781efdf --- /dev/null +++ b/static/compliance/source/test-vectors/trusted-match-context-merge/README.md @@ -0,0 +1,7 @@ +# Trusted Match Context targeting merge vectors + +These vectors exercise the AdCP 3.2 router attribution contract for Context Match targeting key-values. They are served at `/compliance/{version}/test-vectors/trusted-match-context-merge/vectors.json`. + +A conforming router derives each bucket key from its publisher-controlled provider registration, copies the provider's `signals.targeting_kvs` list unchanged into that bucket, and never emits the same targeting pairs as a flattened `signals.targeting_kvs` list. Provider-supplied `signals_by_provider` data has no authority and is ignored in the reference merge; an implementation may instead reject that provider response. + +The publisher-side example maps `(provider_id, key)` tuples to local ad-server keys. It demonstrates that two providers may safely reuse `shared_key` and that an unmapped tuple is dropped rather than treated as a global targeting key. The mapping shape in this fixture is illustrative publisher deployment configuration, not a standardized 3.2 wire schema. diff --git a/static/compliance/source/test-vectors/trusted-match-context-merge/vectors.json b/static/compliance/source/test-vectors/trusted-match-context-merge/vectors.json new file mode 100644 index 0000000000..c9a14b2435 --- /dev/null +++ b/static/compliance/source/test-vectors/trusted-match-context-merge/vectors.json @@ -0,0 +1,230 @@ +{ + "profile": "adcp/trusted-match/context-targeting-merge/v1", + "registrations": [ + { "provider_id": "provider_a" }, + { "provider_id": "provider_b" }, + { "provider_id": "provider_spoof" }, + { "provider_id": "provider_absent" }, + { "provider_id": "provider_empty" }, + { "provider_id": "__proto__" }, + { "provider_id": "constructor" } + ], + "provider_responses": [ + { + "registration_provider_id": "provider_a", + "response": { + "status": "completed", + "type": "context_match_response", + "request_id": "ctx-provider-attribution-1", + "offers": [{ "package_id": "pkg-a" }], + "signals": { + "segments": ["outdoor"], + "targeting_kvs": [ + { "key": "shared_key", "value": "alpha" }, + { "key": "unmapped_key", "value": "drop-me" } + ] + } + } + }, + { + "registration_provider_id": "provider_b", + "response": { + "status": "completed", + "type": "context_match_response", + "request_id": "ctx-provider-attribution-1", + "offers": [{ "package_id": "pkg-b" }], + "signals": { + "segments": ["travel"], + "targeting_kvs": [ + { "key": "shared_key", "value": "bravo" } + ] + } + } + }, + { + "registration_provider_id": "provider_spoof", + "response": { + "status": "completed", + "type": "context_match_response", + "request_id": "ctx-provider-attribution-1", + "offers": [], + "signals_by_provider": { + "provider_a": { + "targeting_kvs": [ + { "key": "shared_key", "value": "spoofed" } + ] + } + } + } + }, + { + "registration_provider_id": "provider_absent", + "response": { + "status": "completed", + "type": "context_match_response", + "request_id": "ctx-provider-attribution-1", + "offers": [] + } + }, + { + "registration_provider_id": "provider_empty", + "response": { + "status": "completed", + "type": "context_match_response", + "request_id": "ctx-provider-attribution-1", + "offers": [], + "signals": { + "targeting_kvs": [] + } + } + }, + { + "registration_provider_id": "__proto__", + "response": { + "status": "completed", + "type": "context_match_response", + "request_id": "ctx-provider-attribution-1", + "offers": [], + "signals": { + "targeting_kvs": [ + { "key": "edge_key", "value": "proto-value" } + ] + } + } + }, + { + "registration_provider_id": "constructor", + "response": { + "status": "completed", + "type": "context_match_response", + "request_id": "ctx-provider-attribution-1", + "offers": [], + "signals": { + "targeting_kvs": [ + { "key": "name", "value": "constructor-value" } + ] + } + } + } + ], + "expected_router_response": { + "status": "completed", + "type": "context_match_response", + "request_id": "ctx-provider-attribution-1", + "offers": [ + { "package_id": "pkg-a" }, + { "package_id": "pkg-b" } + ], + "signals": { + "segments": ["outdoor", "travel"] + }, + "signals_by_provider": { + "provider_a": { + "targeting_kvs": [ + { "key": "shared_key", "value": "alpha" }, + { "key": "unmapped_key", "value": "drop-me" } + ] + }, + "provider_b": { + "targeting_kvs": [ + { "key": "shared_key", "value": "bravo" } + ] + }, + "__proto__": { + "targeting_kvs": [ + { "key": "edge_key", "value": "proto-value" } + ] + }, + "constructor": { + "targeting_kvs": [ + { "key": "name", "value": "constructor-value" } + ] + } + } + }, + "publisher_mapping": { + "provider_a": { + "shared_key": "gam_provider_a" + }, + "provider_b": { + "shared_key": "gam_provider_b" + }, + "__proto__": { + "edge_key": "gam_proto" + } + }, + "expected_publisher_targeting": [ + { "key": "gam_provider_a", "value": "alpha" }, + { "key": "gam_provider_b", "value": "bravo" }, + { "key": "gam_proto", "value": "proto-value" } + ], + "invalid_router_responses": [ + { + "id": "flattened-targeting-only", + "response": { + "status": "completed", + "type": "context_match_response", + "request_id": "ctx-invalid-0", + "offers": [], + "signals": { + "targeting_kvs": [{ "key": "shared_key", "value": "alpha" }] + } + } + }, + { + "id": "flattened-and-attributed-targeting", + "response": { + "status": "completed", + "type": "context_match_response", + "request_id": "ctx-invalid-1", + "offers": [], + "signals": { + "targeting_kvs": [{ "key": "shared_key", "value": "alpha" }] + }, + "signals_by_provider": { + "provider_a": { + "targeting_kvs": [{ "key": "shared_key", "value": "alpha" }] + } + } + } + }, + { + "id": "invalid-provider-id-key", + "response": { + "status": "completed", + "type": "context_match_response", + "request_id": "ctx-invalid-2", + "offers": [], + "signals_by_provider": { + "bad-provider-id": { + "targeting_kvs": [{ "key": "shared_key", "value": "alpha" }] + } + } + } + }, + { + "id": "empty-provider-bucket", + "response": { + "status": "completed", + "type": "context_match_response", + "request_id": "ctx-invalid-3", + "offers": [], + "signals_by_provider": { + "provider_a": { + "targeting_kvs": [] + } + } + } + }, + { + "id": "empty-provider-map", + "response": { + "status": "completed", + "type": "context_match_response", + "request_id": "ctx-invalid-4", + "offers": [], + "signals_by_provider": {} + } + } + ] +} diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index 290a63cf4f..4ce5143794 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -1975,6 +1975,10 @@ "$ref": "/schemas/trusted-match/provider-registration.json", "description": "TMP provider registration — endpoint, capabilities, and operational parameters for router configuration" }, + "provider-context-match-response": { + "$ref": "/schemas/trusted-match/provider-context-match-response.json", + "description": "Provider-to-router Context Match response shape — carries provider-local targeting key-values and forbids router-authored attribution buckets" + }, "provider-identity-match-response": { "$ref": "/schemas/trusted-match/provider-identity-match-response.json", "description": "Provider-to-router Identity Match response shape — carries ordered TMPX `{slot_id, value}` chunks with no publisher-local names" @@ -1996,7 +2000,7 @@ }, "response": { "$ref": "/schemas/trusted-match/context-match-response.json", - "description": "Offers for matched packages with optional brand, price, summary, creative manifest, and targeting signals" + "description": "Router-to-publisher offers for matched packages with provider-attributed targeting signals" } }, "identity-match": { diff --git a/static/schemas/source/trusted-match/context-match-response.json b/static/schemas/source/trusted-match/context-match-response.json index 00a1977a91..77c74609ce 100644 --- a/static/schemas/source/trusted-match/context-match-response.json +++ b/static/schemas/source/trusted-match/context-match-response.json @@ -1,8 +1,8 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/trusted-match/context-match-response.json", - "title": "Context Match Response", - "description": "Response from router or provider with offers for matched packages. An empty offers array means no packages matched. For simple GAM integration, package_id flows as a macro via signals. For rich integrations, the offer includes brand, price, summary, and optionally an inline creative manifest. Extension fields (ext, context) are intentionally omitted — extension data in the context path could inadvertently carry or correlate user identity signals.", + "title": "Context Match Response (Router → Publisher)", + "description": "Router-to-publisher shape of the Context Match response. The router collects offers and non-keyed signals from provider responses, and preserves provider-local targeting pairs under signals_by_provider using the publisher-assigned provider_id from registration. Flattened signals.targeting_kvs is a provider-hop field and MUST NOT appear on this shape. Publisher-local targeting destination names never travel on either protocol hop; the publisher resolves each (provider_id, key) tuple through deployment configuration and drops tuples without a mapping. Envelope extension fields (ext, context) are intentionally omitted and forbidden because extension data in the context path could inadvertently carry or correlate user identity signals.", "type": "object", "allOf": [ { @@ -13,6 +13,30 @@ } ], "x-status": "experimental", + "definitions": { + "targeting_kvs": { + "type": "array", + "description": "Provider-local key-value pairs for ad-server targeting. The router preserves each pair unchanged and attributes it by provider_id; the publisher maps the tuple (provider_id, key) to a local ad-server destination.", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Provider-local targeting key. Publishers resolve this together with provider_id; it is not a globally unique or publisher-owned ad-server key." + }, + "value": { + "type": "string", + "description": "Targeting value preserved unchanged by the router." + } + }, + "required": [ + "key", + "value" + ], + "additionalProperties": false + } + } + }, "properties": { "type": { "type": "string", @@ -21,56 +45,56 @@ }, "request_id": { "type": "string", - "description": "Echoed request identifier from the context match request" + "description": "Echoed request identifier from the context match request." }, "offers": { "type": "array", - "description": "Offers from the buyer, one per activated package. An empty array means no packages matched. For simple activation, each offer has just package_id. For richer responses, offers include brand, price, summary, and creative manifest.", + "description": "Offers collected across the provider fan-out, one per activated package. An empty array means no packages matched. For simple activation, each offer has just package_id. For richer responses, offers include brand, price, summary, and creative manifest.", "items": { "$ref": "/schemas/trusted-match/offer.json" } }, - "cache_ttl": { - "type": "integer", - "description": "Optional override for the default 5-minute cache TTL, in seconds. When present, the router MUST use this value instead of its default. Set to 0 to disable caching (e.g., when targeting configuration has just changed).", - "minimum": 0, - "maximum": 86400 - }, "signals": { "type": "object", - "description": "Response-level targeting signals for ad server pass-through. In the GAM case, these carry the key-value pairs that trigger line items. Not per-offer — applies to the response as a whole.", + "description": "Merged non-keyed response-level signals. Provider-local targeting pairs do not pass through this object; the router emits them only in signals_by_provider.", "properties": { "segments": { "type": "array", - "description": "Contextual segment identifiers", + "description": "Contextual segment identifiers combined across provider responses.", "items": { "type": "string" } - }, - "targeting_kvs": { - "type": "array", - "description": "Key-value pairs for ad server targeting", - "items": { - "type": "object", - "properties": { - "key": { - "type": "string", - "description": "Targeting key" - }, - "value": { - "type": "string", - "description": "Targeting value" + } + }, + "additionalProperties": true + }, + "signals_by_provider": { + "type": "object", + "description": "Router-authored map of provider targeting pairs, keyed by the publisher-assigned provider_id from provider registration. For every provider response containing a non-empty signals.targeting_kvs list, the router copies the complete list unchanged into that provider's bucket. The router derives the map key from its registration and MUST ignore or reject provider-supplied signals_by_provider data. A provider with no targeting pairs is omitted. Publishers resolve each (provider_id, key) tuple to a local ad-server destination and drop tuples that have no local mapping.", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9_]+$", + "minLength": 1, + "maxLength": 64 + }, + "additionalProperties": { + "type": "object", + "properties": { + "targeting_kvs": { + "allOf": [ + { + "$ref": "#/definitions/targeting_kvs" } - }, - "required": [ - "key", - "value" ], - "additionalProperties": false + "minItems": 1 } - } + }, + "required": [ + "targeting_kvs" + ], + "additionalProperties": false }, - "additionalProperties": true + "minProperties": 1 } }, "required": [ @@ -78,5 +102,31 @@ "request_id", "offers" ], + "not": { + "anyOf": [ + { + "properties": { + "signals": { + "required": [ + "targeting_kvs" + ] + } + }, + "required": [ + "signals" + ] + }, + { + "required": [ + "context" + ] + }, + { + "required": [ + "ext" + ] + } + ] + }, "additionalProperties": true } diff --git a/static/schemas/source/trusted-match/provider-context-match-response.json b/static/schemas/source/trusted-match/provider-context-match-response.json new file mode 100644 index 0000000000..ebdc36e2fd --- /dev/null +++ b/static/schemas/source/trusted-match/provider-context-match-response.json @@ -0,0 +1,106 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/trusted-match/provider-context-match-response.json", + "title": "Context Match Response (Provider → Router)", + "description": "Provider-to-router shape of the Context Match response. A context provider returns offers, optional cache guidance, and response-level signals using its own targeting vocabulary in signals.targeting_kvs. The router attributes those targeting pairs using the publisher-assigned provider_id from registration; a provider MUST NOT author signals_by_provider or choose its attribution bucket. Publisher-local targeting destination names never travel on this hop. Envelope extension fields (ext, context) are intentionally omitted and forbidden because extension data in the context path could inadvertently carry or correlate user identity signals.", + "type": "object", + "allOf": [ + { + "$ref": "/schemas/core/version-envelope.json" + }, + { + "$ref": "/schemas/core/protocol-envelope.json" + } + ], + "x-status": "experimental", + "definitions": { + "targeting_kvs": { + "type": "array", + "description": "Provider-local key-value pairs for ad-server targeting. The router preserves each pair unchanged under the emitting provider's registered provider_id.", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Provider-local targeting key. It is not a globally unique or publisher-owned ad-server destination." + }, + "value": { + "type": "string", + "description": "Targeting value that the router preserves unchanged." + } + }, + "required": [ + "key", + "value" + ], + "additionalProperties": false + } + } + }, + "properties": { + "type": { + "type": "string", + "const": "context_match_response", + "description": "Message type discriminator for deserialization. Same const as the router→publisher variant so decoders can key off type before dispatching on hop shape." + }, + "request_id": { + "type": "string", + "description": "Echoed request identifier from the context match request." + }, + "offers": { + "type": "array", + "description": "Offers from this provider, one per activated package. An empty array means no packages matched. For simple activation, each offer has just package_id. For richer responses, offers include brand, price, summary, and creative manifest.", + "items": { + "$ref": "/schemas/trusted-match/offer.json" + } + }, + "cache_ttl": { + "type": "integer", + "description": "Optional override for the router's default 5-minute response cache TTL, in seconds. When present, the router MUST use this value instead of its default. Set to 0 to disable caching.", + "minimum": 0, + "maximum": 86400 + }, + "signals": { + "type": "object", + "description": "Provider-origin response-level signals. Targeting pairs use the provider's local key vocabulary; the router preserves and attributes them rather than passing them through as flattened publisher targeting.", + "properties": { + "segments": { + "type": "array", + "description": "Contextual segment identifiers.", + "items": { + "type": "string" + } + }, + "targeting_kvs": { + "$ref": "#/definitions/targeting_kvs" + } + }, + "additionalProperties": true + } + }, + "required": [ + "type", + "request_id", + "offers" + ], + "not": { + "anyOf": [ + { + "required": [ + "signals_by_provider" + ] + }, + { + "required": [ + "context" + ] + }, + { + "required": [ + "ext" + ] + } + ] + }, + "additionalProperties": true +} diff --git a/tests/example-validation-simple.test.cjs b/tests/example-validation-simple.test.cjs index d09ed67932..188b81f09d 100644 --- a/tests/example-validation-simple.test.cjs +++ b/tests/example-validation-simple.test.cjs @@ -1006,8 +1006,8 @@ async function runTests() { } ] }, - '/schemas/trusted-match/context-match-response.json', - 'TMP Context Match response — web (overview walkthrough)' + '/schemas/trusted-match/provider-context-match-response.json', + 'TMP provider→router Context Match response — web (overview walkthrough)' ); // Identity Match request — web (from index.mdx) @@ -1087,8 +1087,72 @@ async function runTests() { } ] }, + '/schemas/trusted-match/provider-context-match-response.json', + 'TMP provider→router Context Match response — AI assistant with creative manifest (ai-mediation walkthrough)' + ); + + // Context Match response hop split: providers emit flattened local keys; + // routers emit only registration-derived, attributed buckets. + await validateExample( + { + "status": "completed", + "type": "context_match_response", + "request_id": "ctx-provider-hop-1", + "offers": [], + "signals": { + "targeting_kvs": [{ "key": "category", "value": "outdoor" }] + } + }, + '/schemas/trusted-match/provider-context-match-response.json', + 'TMP provider→router Context Match response accepts provider-local targeting pairs' + ); + + await expectInvalid( + { + "status": "completed", + "type": "context_match_response", + "request_id": "ctx-provider-hop-2", + "offers": [], + "signals_by_provider": { + "provider_a": { + "targeting_kvs": [{ "key": "category", "value": "spoofed" }] + } + } + }, + '/schemas/trusted-match/provider-context-match-response.json', + 'TMP provider→router Context Match response rejects router-authored attribution buckets', + [/not|must NOT|anyOf/i] + ); + + await validateExample( + { + "status": "completed", + "type": "context_match_response", + "request_id": "ctx-router-hop-1", + "offers": [], + "signals_by_provider": { + "provider_a": { + "targeting_kvs": [{ "key": "category", "value": "outdoor" }] + } + } + }, '/schemas/trusted-match/context-match-response.json', - 'TMP Context Match response — AI assistant with creative manifest (ai-mediation walkthrough)' + 'TMP router→publisher Context Match response accepts attributed targeting buckets' + ); + + await expectInvalid( + { + "status": "completed", + "type": "context_match_response", + "request_id": "ctx-router-hop-2", + "offers": [], + "signals": { + "targeting_kvs": [{ "key": "category", "value": "outdoor" }] + } + }, + '/schemas/trusted-match/context-match-response.json', + 'TMP router→publisher Context Match response rejects flattened targeting without attributed buckets', + [/not|must NOT|anyOf/i] ); // Identity Match request with consent (from context-and-identity.mdx) diff --git a/tests/trusted-match-context-merge.test.cjs b/tests/trusted-match-context-merge.test.cjs new file mode 100644 index 0000000000..87ad4977f4 --- /dev/null +++ b/tests/trusted-match-context-merge.test.cjs @@ -0,0 +1,144 @@ +const { readFileSync } = require('node:fs'); +const path = require('node:path'); +const { describe, it, before } = require('node:test'); +const assert = require('node:assert/strict'); +const Ajv = require('ajv'); +const addFormats = require('ajv-formats'); + +const ROOT = path.resolve(__dirname, '..'); +const SCHEMA_ROOT = path.join(ROOT, 'static/schemas/source'); +const fixture = JSON.parse(readFileSync( + path.join(ROOT, 'static/compliance/source/test-vectors/trusted-match-context-merge/vectors.json'), + 'utf8', +)); + +function readSchema(uri) { + assert.match(uri, /^\/schemas\//); + return JSON.parse(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)); +} + +function referenceMerge(providerResponses) { + const output = { + status: 'completed', + type: 'context_match_response', + request_id: providerResponses[0].response.request_id, + offers: [], + }; + const segments = []; + const signalsByProvider = new Map(); + + for (const { registration_provider_id: providerId, response } of providerResponses) { + output.offers.push(...response.offers); + segments.push(...(response.signals?.segments || [])); + + const targetingKvs = response.signals?.targeting_kvs; + if (targetingKvs?.length) { + signalsByProvider.set(providerId, { targeting_kvs: structuredClone(targetingKvs) }); + } + // A provider-supplied signals_by_provider map is deliberately not read. + } + + if (segments.length) output.signals = { segments }; + if (signalsByProvider.size) output.signals_by_provider = Object.fromEntries(signalsByProvider); + return output; +} + +function applyPublisherMapping(response, mapping) { + const targeting = []; + for (const [providerId, bucket] of Object.entries(response.signals_by_provider || {})) { + if (!Object.hasOwn(mapping, providerId)) continue; + const providerMapping = mapping[providerId]; + if (providerMapping === null || typeof providerMapping !== 'object') continue; + for (const pair of bucket.targeting_kvs) { + if (!Object.hasOwn(providerMapping, pair.key)) continue; + const localKey = providerMapping[pair.key]; + if (localKey) targeting.push({ key: localKey, value: pair.value }); + } + } + return targeting; +} + +describe('Trusted Match Context targeting merge vectors', () => { + let validateProviderResponse; + let validateRouterResponse; + + before(async () => { + [validateProviderResponse, validateRouterResponse] = await Promise.all([ + compile('/schemas/trusted-match/provider-context-match-response.json'), + compile('/schemas/trusted-match/context-match-response.json'), + ]); + }); + + it('publishes schema-valid provider-hop responses', () => { + const legitimateResponses = fixture.provider_responses.filter( + ({ response }) => !Object.hasOwn(response, 'signals_by_provider'), + ); + assert.ok(legitimateResponses.length > 0); + for (const { registration_provider_id: providerId, response } of legitimateResponses) { + assert.equal( + validateProviderResponse(response), + true, + `${providerId}: ${JSON.stringify(validateProviderResponse.errors)}`, + ); + } + }); + + it('rejects provider-authored attribution buckets on the provider hop', () => { + const spoof = fixture.provider_responses.find( + ({ response }) => Object.hasOwn(response, 'signals_by_provider'), + ); + assert.ok(spoof, 'fixture must include a provider-supplied attribution bucket'); + assert.equal(validateProviderResponse(spoof.response), false); + }); + + it('derives provider buckets from registration and preserves targeting pairs unchanged', () => { + const actual = referenceMerge(fixture.provider_responses); + assert.deepEqual(actual, fixture.expected_router_response); + assert.equal(validateRouterResponse(actual), true, JSON.stringify(validateRouterResponse.errors)); + assert.equal(actual.signals?.targeting_kvs, undefined); + assert.equal(actual.signals_by_provider.provider_a.targeting_kvs[0].value, 'alpha'); + }); + + it('omits providers with absent or empty targeting pairs and safely retains legal object-like IDs', () => { + const actual = referenceMerge(fixture.provider_responses); + assert.equal(actual.signals_by_provider.provider_absent, undefined); + assert.equal(actual.signals_by_provider.provider_empty, undefined); + assert.equal(Object.hasOwn(actual.signals_by_provider, '__proto__'), true); + assert.deepEqual(actual.signals_by_provider.__proto__.targeting_kvs, [ + { key: 'edge_key', value: 'proto-value' }, + ]); + }); + + it('does not trust a provider-supplied attribution bucket', () => { + const actual = referenceMerge(fixture.provider_responses); + assert.equal(actual.signals_by_provider.provider_spoof, undefined); + assert.notEqual(actual.signals_by_provider.provider_a.targeting_kvs[0].value, 'spoofed'); + }); + + it('maps by provider and drops unmapped tuples at the publisher boundary', () => { + const actual = applyPublisherMapping(fixture.expected_router_response, fixture.publisher_mapping); + assert.deepEqual(actual, fixture.expected_publisher_targeting); + assert.equal( + actual.some(({ value }) => value === 'constructor-value'), + false, + 'inherited constructor/name properties must not create publisher targeting', + ); + }); + + it('rejects invalid merged response shapes', () => { + for (const vector of fixture.invalid_router_responses) { + assert.equal(validateRouterResponse(vector.response), false, `${vector.id} unexpectedly validated`); + } + }); +});