Skip to content
Merged
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/settle-tmp-router-merge-semantics.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 8 additions & 2 deletions docs/trusted-match/router-architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/trusted-match/specification.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ The standard registration path is **static configuration** — the publisher dec
| `uid_types` | List\<string\> | 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\<UUID\> | 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\<string\> | 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`. |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down
28 changes: 28 additions & 0 deletions tests/tmp-router-merge-semantics.test.cjs
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading