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
36 changes: 36 additions & 0 deletions .changeset/identitymatch-fcap-architecture-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
"adcontextprotocol": patch
---

IdentityMatch & frequency capping architecture, with the wire-spec change and the data-flow boundary contract landing as authoritative protocol docs. Counting and policy live in the buyer's impression tracker; the IdentityMatch service consumes only cap-fire events at the boundary.

**Wire spec changes** (`identity-match-response.json`):
- Adds `serve_window_sec` (integer, 1–300, default 60) — per-package single-shot fcap window. After serving the user one impression on each eligible package within this window, the publisher MUST re-query Identity Match before serving from those packages again. Not a router response cache TTL.
- Removes `ttl_sec`. Originally documented as a router cache TTL but operationally functioned as a per-package serve throttle. TMP is pre-launch (experimental, pre-3.0.0 GA) and not subject to deprecation cycles, so the field is removed outright.

**Doc updates:**
- `docs/trusted-match/specification.mdx` — adds `serve_window_sec` field, removes `ttl_sec`, adds normative conformance invariants for IdentityMatch eligibility (audience intersection; cap-state presence check; active state; audience freshness). Updates the caching section for the new contract.
- `docs/trusted-match/identity-match-implementation.mdx` (new page) — frequency-cap data flow (boundary contract): the cap-fire event the impression tracker writes into the IdentityMatch cap-state store, and how the IdentityMatch service consumes it at query time. The protocol does not constrain how the impression tracker counts impressions, evaluates windows, or decides when a cap fires — those concerns live entirely in the buyer's impression-tracking pipeline.
- `docs/trusted-match/buyer-guide.mdx` — updates frequency-cap management to reflect the impression-tracker / IdentityMatch split, and the serve-window contract section.
- `docs/trusted-match/migration-from-axe.mdx` — adds OpenRTB 2.6 `User.eids[]` cross-walk for buyers bridging from OpenRTB-shaped pipelines.

**Three-layer model:**
- Wire spec (normative) — what crosses an agent boundary.
- Conformance invariants (normative) — backend-agnostic eligibility logic, including a presence check against cap-state.
- Boundary contract (normative for the cap-state store API) — what events flow from the impression tracker into the IdentityMatch cap-state store. Storage backend is implementer choice; the reference store ships in `adcp-go/targeting/fcap` (Valkey 9 hashes with HSETEX).

**Cap-state store surface:** `RecordCap(userIdentity, fields, expireAt)` and `IsCapped(userIdentity, field)`, where `field` is `{seller_agent_url, package_id}`. v1 keys cap-state at `(user_identity, seller_agent_url, package_id)`; broader-dimension caps (advertiser, campaign, creative, line item) are a future extension to the boundary contract.

**Architecture history** preserved at `specs/identitymatch-fcap-architecture.md` — captures design decisions, deferred security/privacy follow-ups, the rollout plan, and consolidated Slack/PR-review threads. Earlier iterations of the design (counter-based exposure tracking, log-based tracking with `impression_id` dedup, `fcap_keys` label model) were unwound — counting, dedup, and policy evaluation depend on buyer-internal concerns the protocol shouldn't constrain.

All TMP surfaces remain `x-status: experimental`. Per the experimental-status contract, fields on this surface are not subject to deprecation cycles until 3.0.0 GA.

**Tracked deferred follow-ups** (not in this PR):
- TMPX harvest → competitor-suppression attack
- Eligibility-as-audience-membership oracle (honeypot package_ids)
- Consent revocation between IdentityMatch and impression
- Side-channel via eligibility deltas
- `hashed_email` in TMPX leak surface
- DoS amplification via large `package_ids[]`
- Cap-state extensions for advertiser/campaign/creative dimensions
- Identity-graph plug-point in the impression tracker
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Upcoming

### Notices — experimental surfaces

- **TMP `identity-match-response.ttl_sec` is removed; replaced by `serve_window_sec`.** The `ttl_sec` field was documented as a router response cache TTL but operationally functioned as a per-package single-shot fcap, conflating two distinct concerns and silently breaking either when tuned. Replacement field `serve_window_sec` (integer, 1–300, default 60) carries the corrected semantic — *after serving the user one impression on each eligible package within this window, the publisher MUST re-query Identity Match before serving from those packages again.* This is **not** a router response cache. Multi-impression frequency capping is a separate concern handled by the buyer's impression tracker, which writes cap-fire events to the IdentityMatch cap-state store at the boundary regardless of this window. TMP is pre-launch (experimental, pre-3.0.0 GA) and not subject to deprecation cycles, so `ttl_sec` is removed outright rather than going through a deprecation window. Tracked in `specs/identitymatch-fcap-architecture.md` and [Frequency-Cap Data Flow](docs/trusted-match/identity-match-implementation.mdx).

## 3.0.6

### Patch Changes
Expand Down
44 changes: 25 additions & 19 deletions docs/trusted-match/buyer-guide.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ A buyer agent exposes two HTTP/2 endpoints under a single base URL — `POST /co
| Message type | Receives | Returns |
|---|---|---|
| `context_match_request` | Page/content signals, placement, geo | Offers with creative manifests |
| `identity_match_request` | Seller agent URL, identity tokens, optional package ID list | Eligible package IDs + TTL |
| `identity_match_request` | Seller agent URL, identity tokens, optional package ID list | Eligible package IDs + `serve_window_sec` |

Each endpoint handles one message type. Both must respond in under 50ms. The router enforces this budget and will skip slow providers.

Expand Down Expand Up @@ -121,11 +121,11 @@ The router sends you the seller's `seller_agent_url` and one or more identity to
"type": "identity_match_response",
"request_id": "id-9c4e",
"eligible_package_ids": ["acme-outdoor-q2", "acme-loyalty-retarget"],
"ttl_sec": 60
"serve_window_sec": 60
}
```

Return only the package IDs that pass your eligibility checks. Packages not in the list are treated as ineligible. The `ttl_sec` tells the router how long to cache this response — during that window, the router returns cached eligibility without re-querying you. The publisher uses cached eligibility to allocate across whatever placements exist. Set the TTL based on how quickly your eligibility state changes (frequency caps, audience updates, etc.).
Return only the package IDs that pass your eligibility checks. Packages not in the list are treated as ineligible. The `serve_window_sec` is a **per-package single-shot fcap**: after the publisher serves the user one impression on each eligible package within this window, the publisher MUST re-query Identity Match before serving from those packages again. Default 60s, max 300s. This is not a router response cache TTL — see [The serve-window contract](#the-serve-window-contract).

**What you never receive** in Identity Match: page URLs, content topics, keywords, article text, or any content signal. You cannot determine what the user is looking at.

Expand All @@ -144,21 +144,25 @@ You have no role in this step. The publisher controls activation.

## Frequency Cap Management

Cross-publisher frequency capping is the primary use case for Identity Match. Your agent maintains frequency state per user token:
Cross-publisher frequency capping is the primary use case for Identity Match. Cap policy and counting live in your **impression tracker**; the Identity Match service consumes only cap-fire signals at query time. The split:

- **Count impressions** by user token + package ID
- **Track recency** — when was the last impression for this token?
- **Apply caps** from the media buy: `max_impressions` per `window`, minimum `recency` between exposures
- **Exclude the package** from `eligible_package_ids` when a cap is hit
- **Set `ttl_sec`** to reflect how long this eligibility is valid — a shorter TTL means the router re-checks sooner, which is useful when a cap is close to being reached
- **Impression tracker** receives pixel fires, decodes the TMPX token, and applies whatever fcap policies you maintain — counting impressions across whatever dimensions you cap on (package, campaign, advertiser, creative, line item) for each resolved user identity, with whatever windowing and dedup logic your policy engine uses.
- **On the impression that exhausts a cap**, the impression tracker writes a cap-fire entry — `(user_identity, package) capped until <expireAt>` — into the Identity Match cap-state store.
- **Identity Match service** at query time excludes any package with a cap-fire entry against any of the request's identities from `eligible_package_ids`.

The protocol does not constrain how you count impressions, where policies live, or how you dedup across identities. It only defines the boundary: cap-fire events flow into the cap-state store; the IdentityMatch service checks presence at query time. See [Frequency-Cap Data Flow](/docs/trusted-match/identity-match-implementation) for the boundary contract and the reference cap-state store.

When an fcap rule changes — a window shortens or lengthens, a `max_count` rises or falls, a policy is paused or removed, a package is reassigned — you MUST re-evaluate the affected `(user_identity, package)` cap-state entries against the new policy and push the appropriate updates: **delete** entries for users no longer over-cap, **extend** (overwrite with a new `expire_at`) entries that are still over-cap but whose window changed. The cap-state store doesn't store counts and can't re-evaluate on its own; the buyer's policy owner is the source of truth. See [Policy updates and cap-state re-evaluation](/docs/trusted-match/identity-match-implementation#policy-updates-and-cap-state-re-evaluation) for the event shapes.

Because Identity Match runs across all publishers using TMP, a user who saw your ad on Publisher A will correctly show as over-frequency on Publisher B — even though you can't see which publisher sent the request.

### How Buyers Learn About Exposures

The `tmpx` field on the Identity Match response carries a TMPX token — an HPKE-encrypted blob containing the user's resolved identity tokens. The publisher substitutes `{TMPX}` into creative tracking URLs. When the ad serves, your impression pixel receives the encrypted token. Your cluster master decrypts it, logs the exposure against the user, and replicates updated frequency state to read replicas. This gives you real-time per-user exposure signals without the publisher seeing user identity.
The `tmpx` field on the Identity Match response carries a TMPX token — an HPKE-encrypted blob containing the user's resolved identity tokens. The publisher substitutes `{TMPX}` into creative tracking URLs. When the ad serves, your impression pixel receives the encrypted token. Your impression tracker decrypts it, applies your fcap policy logic against the resolved identities, and (when a cap fires) writes a cap-fire entry to the Identity Match cap-state store. Most production deployments separate decode (synchronous, at intake) from policy evaluation and cap-state writes (asynchronous, behind a queue) for buffering.

This gives you real-time per-user exposure signals without the publisher seeing user identity.

See [TMPX Exposure Tokens](/docs/trusted-match/specification#tmpx-exposure-tokens) for the encryption format and binary token structure.
See [TMPX Exposure Tokens](/docs/trusted-match/specification#tmpx-exposure-tokens) for the encryption format and binary token structure, and [Frequency-Cap Data Flow](/docs/trusted-match/identity-match-implementation) for the cap-state store boundary contract.

## Provider Registration

Expand Down Expand Up @@ -201,16 +205,18 @@ Common scenarios:
- **Internal failure**: Return an error response. The router skips your provider and proceeds with other providers.
- **Timeout**: If you can't respond within the latency budget, the router skips you. No error response needed — the router handles this.

## The TTL Caching Contract
## The serve-window contract

The `serve_window_sec` field on Identity Match responses is a **per-package single-shot fcap** between the buyer and the publisher:

- For each package in `eligible_package_ids`, the publisher MAY serve the user **at most one impression** on that package within `serve_window_sec` seconds.
- After the publisher has served one impression on each eligible package, the publisher MUST re-query Identity Match before serving any of those packages to the same user again.
- Multi-impression frequency capping (5/day, 100/month, etc.) is separate. It lives in your buyer-side state and is updated out-of-band via TMPX impression callbacks regardless of `serve_window_sec`. The serve window is the protocol-level throttle; multi-impression caps are buyer-internal policy.

The `ttl_sec` field on Identity Match responses is a caching contract between the buyer and the router:
The router MAY apply an internal deduplication cache keyed by `{identities_hash, provider_id, package_ids_hash, consent_hash}` (see spec for canonical bytes), but the publisher's binding contract is the serve-window throttle, not the router's cache window.

- The router caches the response for `ttl_sec` seconds, keyed by `{identities_hash, provider_id, package_ids_hash, consent_hash}` (see spec for canonical bytes). `identities_hash` is computed over the per-provider filtered subset you received — your cache partition is scoped to the identity types you resolve.
- During that window, the router returns cached eligibility without re-querying the buyer
- The publisher uses cached eligibility to allocate across whatever placements exist — a single pre-roll, a CTV ad pod, or a web page with multiple ad units
- The buyer doesn't need to know how many placements exist or how the publisher allocates
**Choosing a serve_window_sec value**: Default 60 seconds. Range 1–300. Anything longer than 300 makes per-package fcap too coarse for typical campaigns. Anything shorter than your IdentityMatch round-trip just adds load. 60 is a good default; tune downward if eligibility state shifts faster (close to a cap, audience just changed) or upward (max 300) if your IdentityMatch service is at load and the campaigns are tolerant of coarser fcap.

**Choosing a TTL**: Set the TTL based on how quickly your eligibility state changes. If frequency caps reset hourly, a 300-second TTL is reasonable. If a user is close to a cap limit, return a shorter TTL (e.g., 30 seconds) so the router re-checks sooner.

## Performance Requirements

Expand All @@ -234,7 +240,7 @@ Buyers receive real-time per-user exposure signals via the `{TMPX}` macro. The I
| | OpenRTB | TMP |
|---|---|---|
| **You receive** | Full bid request (user + content + device) | Either content OR identity, never both |
| **You return** | Bid price | Offer (creative manifest) or eligible package IDs + TTL |
| **You return** | Bid price | Offer (creative manifest) or eligible package IDs + serve window |
| **Auction** | Exchange runs auction | No auction — publisher joins locally |
| **Frequency** | Per-DSP only | Cross-publisher via Identity Match |
| **Integration** | Per-exchange SSP adapter | Two endpoints (context + identity), any surface |
Loading
Loading