From 810042d802aee6646e6e985cd80fd38cf182da11 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 6 Aug 2026 18:13:45 +0200 Subject: [PATCH] fix(tmp): settle router merge semantics for 3.2 --- .../settle-tmp-router-merge-semantics.md | 5 ++++ docs/trusted-match/router-architecture.mdx | 10 +++++-- docs/trusted-match/specification.mdx | 2 +- .../trusted-match/provider-registration.json | 2 +- tests/tmp-router-merge-semantics.test.cjs | 28 +++++++++++++++++++ 5 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 .changeset/settle-tmp-router-merge-semantics.md create mode 100644 tests/tmp-router-merge-semantics.test.cjs diff --git a/.changeset/settle-tmp-router-merge-semantics.md b/.changeset/settle-tmp-router-merge-semantics.md new file mode 100644 index 0000000000..928659eee6 --- /dev/null +++ b/.changeset/settle-tmp-router-merge-semantics.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Settle TMP router merge behavior for 3.2: Context Match duplicate offers use provider priority with arrival-order tie-breaking, while Identity Match uses an explicit responder-scoped union that preserves silent-ignore privacy semantics. diff --git a/docs/trusted-match/router-architecture.mdx b/docs/trusted-match/router-architecture.mdx index 23b5f65bef..1dd376d775 100644 --- a/docs/trusted-match/router-architecture.mdx +++ b/docs/trusted-match/router-architecture.mdx @@ -139,7 +139,7 @@ When the publisher sends a Context Match request: 2. It sends the request to all matching providers in parallel over HTTP/2. 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 first response received. Duplicate `package_id` across providers is a configuration error; the router SHOULD log a warning. + - **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. @@ -156,7 +156,13 @@ The router filters Identity Match providers by country and identity type: 7. Because the per-provider payload differs from the inbound request, the router **re-signs** each per-provider forward over the canonical signed fields of the filtered set — `identities_hash` (which covers any per-identity `attestation`) and, when present, `sealed_credentials_hash` over the `sealed_credentials[]` entries routed to that provider by `audience_kid` — as defined in [Identity Match signed fields](/docs/trusted-match/specification#identity-match-signed-fields). Providers verify signatures against the router's public key. 8. It fans out to all matching providers in parallel, merges eligibility results, and returns a unified response. -Duplicate `package_id` across providers is a configuration error — packages come from media buys and are provider-specific. If it occurs, the router applies conservative merging: the package is only eligible if it appears in `eligible_package_ids` from both providers. The router uses the minimum `serve_window_sec` across providers and SHOULD log a warning. +Identity eligibility is merged over **successful responders only**. The router returns the union of every responder's `eligible_package_ids` and uses the minimum `serve_window_sec` returned by those responders. Provider omission is not a negative vote: under the silent-ignore rule, the router cannot distinguish “unknown package” from “known but ineligible,” so it MUST NOT intersect omissions or require a quorum. + +- With zero successful responders, the all-providers-timeout rule below returns an empty response. +- With one successful responder, the merged eligibility and serve window are that responder's result. +- With two or more successful responders, non-responders remain excluded and the router unions the responder results. + +A `package_id` observed in two or more responders' positive `eligible_package_ids` lists is a configuration error because packages are provider-specific. The router SHOULD log a warning naming those responders, but the package remains eligible because every observable claim about it is positive. Provider `priority` does not convert a silent omission into an ineligibility signal. This responder-scoped union is a deliberate 3.2 behavior ratification; it preserves the silent-ignore privacy property. **TMPX collection.** Each identity provider that resolves enough identity material to mint a TMPX token returns its ordered `{slot_id, value}` chunks in `tmpx_chunks[]` (see `provider-identity-match-response.json`), where each `slot_id` is drawn from the provider's registered `tmpx_slots` on `provider-registration.json`. The router MUST collect each provider's chunks into the outbound response's `tmpx_providers` map, keyed by the emitting provider's `provider_id`, with the same ordering the provider emitted (`tmpx_providers[provider_id].chunks[]`). Per-provider impression accounting depends on that keying — the router MUST NOT collapse multiple providers' chunks into a single `tmpx` string, and MUST NOT reorder, add, or drop entries within a provider's list. Before forwarding, the router MUST validate the registered slot contract for each provider's `tmpx_chunks`: if the provider has no `tmpx_slots` registration, or if the chunk `slot_id` sequence is not an exact non-empty ordered prefix of the registered `tmpx_slots` list (i.e. contains duplicate, reordered, sparse, or unregistered slot IDs), the router MUST drop that provider's chunks atomically — omitting the provider from `tmpx_providers` entirely — rather than forwarding a non-conformant set to publisher mappings. Publisher-local destination names (ad-server macros, GAM key-values, VAST substitutions, DOOH play-log fields) MUST NOT travel between provider and router; naming is resolved on the publisher side via `tmpx_macro_mapping` (see `publisher-tmpx-config.json`), so the router never accepts a destination name from an untrusted provider. A provider that does not emit any TMPX (e.g. no eligible packages) MUST be omitted from the map rather than represented by an empty `chunks[]`. The two hop schemas are disjoint by design and each enforces its own hop split plus the identity-privacy boundary with `not: {anyOf: […]}` clauses. The provider-facing schema forbids the router-hop fields (`tmpx_providers`, `tmpx`), any legacy carriers (`tmpx_values`, `tmpx_macros`), and envelope-extension fields (`context`, `ext`). The publisher-facing schema forbids the provider-hop field (`tmpx_chunks`) at the root, the same legacy carriers, and the same envelope-extension fields — so `context`/`ext` cannot slip through on either hop. Routers MUST drop any inbound `tmpx_chunks` at the outbound-envelope boundary rather than passing it through the envelope's extension surface. For back-compat with consumers that read the deprecated singular `tmpx` field, routers MAY also populate `tmpx` with one provider's first-slot value; when both fields are present, `tmpx_providers` is authoritative. diff --git a/docs/trusted-match/specification.mdx b/docs/trusted-match/specification.mdx index 9cf78c3431..31aca99219 100644 --- a/docs/trusted-match/specification.mdx +++ b/docs/trusted-match/specification.mdx @@ -354,7 +354,7 @@ The standard registration path is **static configuration** — the publisher dec | `uid_types` | List\ | Conditional | Identity types this provider can resolve (from `uid-type` enum). The router selects providers whose `uid_types` overlaps with any `uid_type` in the request's `identities` array, and filters the forwarded `identities` to the intersection — providers MUST NOT receive tokens for types they did not declare. MUST be present and non-empty when `identity_match` is true. | | `properties` | List\ | No | Property RIDs this provider serves. When absent, the provider serves all properties. | | `timeout_ms` | integer | No | Per-provider timeout in milliseconds. Must be ≤ the router's overall `latency_budget_ms`. Default: 50. | -| `priority` | integer | No | Provider ordering for merge conflict resolution. Lower values = higher priority. Default: 0. | +| `priority` | integer | No | Provider ordering for Context Match offer conflicts. Lower values = higher priority; equal priorities are broken by first response received. Default: 0. Identity eligibility remains a responder-scoped union because silent omission is not a negative vote. | | `tmpx_slots` | List\ | No | Stable provider-local slot identifiers for the ordered TMPX chunks this provider mints — e.g. `["primary","secondary"]`. Slot IDs are opaque provider-namespaced tokens, NOT ad-server macro names; publishers map `(provider_id, slot_id)` → local destination via [`tmpx_macro_mapping`](#publisher-tmpx-macro-mapping), so the destination namespace stays publisher-owned. Distinct providers MAY reuse the same slot_id without collision (publisher lookup keys on `(provider_id, slot_id)`). Ordering carries the ordered-prefix invariant: a provider that emits fewer chunks than it registered MUST emit an ordered prefix — chunks are never shifted, sparse, or reordered. Pattern: `^[a-zA-Z][a-zA-Z0-9_]*$`, 1–64 chars, `uniqueItems`. Cap of 2 slots in v1; the cap MAY rise without a shape change. A provider that emits TMPX (populates `tmpx_chunks` on its identity-match response) MUST register this list. | | `status` | enum | No | Provider lifecycle status: `active`, `inactive`, or `draining`. Default: `active`. | diff --git a/static/schemas/source/trusted-match/provider-registration.json b/static/schemas/source/trusted-match/provider-registration.json index a3bcce3369..55cfd9f5b3 100644 --- a/static/schemas/source/trusted-match/provider-registration.json +++ b/static/schemas/source/trusted-match/provider-registration.json @@ -61,7 +61,7 @@ }, "priority": { "type": "integer", - "description": "Provider ordering for merge conflict resolution. Lower values have higher priority. When two providers return offers for the same package_id (a configuration error), the router keeps the offer from the higher-priority provider. Also used for adaptive timeout allocation — higher-priority providers receive a larger share of the latency budget.", + "description": "Provider ordering for Context Match offer conflict resolution. Lower values have higher priority. When two providers return offers for the same package_id (a configuration error), the router keeps the offer from the higher-priority provider; equal priorities are broken by first response received. Identity Match eligibility remains a responder-scoped union because silent omission is not a negative vote. Also used for adaptive timeout allocation — higher-priority providers receive a larger share of the latency budget.", "minimum": 0, "default": 0 }, diff --git a/tests/tmp-router-merge-semantics.test.cjs b/tests/tmp-router-merge-semantics.test.cjs new file mode 100644 index 0000000000..562974ca09 --- /dev/null +++ b/tests/tmp-router-merge-semantics.test.cjs @@ -0,0 +1,28 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { describe, it } = require('node:test'); + +const root = path.join(__dirname, '..'); +const architecture = fs.readFileSync(path.join(root, 'docs', 'trusted-match', 'router-architecture.mdx'), 'utf8'); +const registration = JSON.parse(fs.readFileSync( + path.join(root, 'static', 'schemas', 'source', 'trusted-match', 'provider-registration.json'), + 'utf8', +)); + +describe('TMP router 3.2 merge semantics', () => { + it('uses provider priority for Context Match offer conflicts', () => { + assert.match(architecture, /higher-priority provider \(lower `priority` value\)/); + assert.match(architecture, /Equal priorities are broken by first response received/); + assert.match(registration.properties.priority.description, /equal priorities are broken by first response received/i); + }); + + it('ratifies responder-scoped union for Identity Match', () => { + assert.match(architecture, /successful responders only/); + assert.match(architecture, /union of every responder's `eligible_package_ids`/); + assert.match(architecture, /MUST NOT intersect omissions or require a quorum/); + assert.match(architecture, /zero successful responders/); + assert.match(architecture, /one successful responder/); + assert.match(registration.properties.priority.description, /Identity Match eligibility remains a responder-scoped union/); + }); +});