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
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 both the wire-spec change and the implementation guidance landing as authoritative protocol docs.

**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.
- Deprecates `ttl_sec`. Originally documented as a router cache TTL but operationally functioned as a per-package serve throttle. Senders during the deprecation window populate both fields; receivers prefer `serve_window_sec`. Removed in a 3.0.x release ≥ 6 weeks after the 2026-04-26 notice (earliest 2026-06-07).

**Doc updates** (authoritative implementation guidance):
- `docs/trusted-match/specification.mdx` — adds `serve_window_sec` field, marks `ttl_sec` deprecated, adds normative conformance invariants for IdentityMatch eligibility (audience intersection, fcap merge across identities, active state, audience freshness). Updates the caching section for the new contract.
- `docs/trusted-match/identity-match-implementation.mdx` (new page) — implementation guide covering the `fcap_keys` label model with tenant prefix and charset, reference valkey-backed data model (audience SET, exposure HASH, package HASH, fcap_policy HASH), merge rules with MAX recommended, SDK primitives (`decodeTmpx`, `writeExposure`, `upsertAudience`, `upsertPackage`, `upsertFcapPolicy`, `inspectExposure`), pluggable store interfaces (FrequencyStore / AudienceStore / PackageStore / FcapPolicyStore), production topology pattern (pub/sub buffering between tracking endpoint and store writer), and Redis-command walkthroughs for the five conformance scenarios.
- `docs/trusted-match/buyer-guide.mdx` — updates frequency-cap management and the serve-window contract sections; cross-links to the implementation page.
- `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.
- Reference data model (non-normative) — Scope3's valkey-backed implementation choice. Buyers may use Aerospike, DynamoDB, or anything else; the SDK exposes pluggable store interfaces. The protocol describes WHAT the service must compute, not HOW it stores the data.

**SDK primitives** ship across `@adcp/client` (TS), `adcp-go`, and `adcp` (Python). Same primitive surface in all three languages. Impression handling is two composable functions (`decodeTmpx` + `writeExposure`), not one bundled call — production tracking endpoints decode at intake and write downstream behind a pub/sub buffer; bundling would force synchronous topology.

**Architecture history** preserved at `specs/identitymatch-fcap-architecture.md` (slimmed from 485 to 136 lines) — captures the design decisions, the deferred security/privacy follow-ups, the rollout plan, and consolidated Slack/PR-review threads. Implementation details now live in `docs/`.

All TMP surfaces remain `x-status: experimental`. Wire change is purely additive (`serve_window_sec`); the `ttl_sec` removal lands in a later 3.0.x.

**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[]`
- Where do fcap policies live on the wire (currently SDK-only)
- Identity-graph plug-point interface for SDK
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Upcoming

### Deprecation Notices — experimental surfaces

- **TMP `identity-match-response.ttl_sec` is deprecated; replaced by `serve_window_sec`.** Notice published 2026-04-26. 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 buyer-side exposure records and policies, updated out-of-band via TMPX impression callbacks regardless of this window. During the deprecation period, senders SHOULD populate both `ttl_sec` and `serve_window_sec` with the same value; receivers SHOULD prefer `serve_window_sec` when both are present. Per the [experimental-status contract](docs/reference/experimental-status.mdx), the `ttl_sec` field MAY be removed no earlier than **2026-06-07** (6 weeks after this notice) in a 3.0.x release. `serve_window_sec` lands additively in 3.0.1 alongside this notice. Tracked in `specs/identitymatch-fcap-architecture.md`.

## 3.0.0

See [release notes](docs/reference/release-notes.mdx) for migration guidance, or [prerelease upgrade notes](docs/reference/migration/prerelease-upgrades.mdx) for rc.3 adopters.
Expand Down
43 changes: 24 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` | Opaque user token, all active package IDs | Eligible package IDs + TTL |
| `identity_match_request` | Opaque user token, all active package IDs | 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 @@ -120,11 +120,11 @@ The router sends you one or more opaque identity tokens and a list of ALL your a
"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 @@ -143,21 +143,24 @@ 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. Your agent maintains frequency state per user identity:

- **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
- **Count impressions** per fcap key (campaign, advertiser, creative, line item, or whatever dimensions you cap on) per resolved user identity
- **Apply policies** with a window and max count
- **Merge across identities** for users with multiple resolved tokens (RampID + ID5 + MAID for the same person) — see [identity handling](/docs/trusted-match/identity-match-implementation#identity-handling-and-cross-identity-dedup)
- **Exclude packages** from `eligible_package_ids` when any cap on the package trips

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.

For the implementation details — the fcap_keys label model, the reference valkey data model, audience and exposure record shapes, the SDK primitives, and conformance scenarios — see [Identity Match implementation](/docs/trusted-match/identity-match-implementation).

### 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 handler decrypts it (via the SDK's `decodeTmpx` primitive) and writes the exposure increment to your store (via `writeExposure`). Most production deployments separate decode (synchronous, at intake) from write (asynchronous, behind a queue) for buffering — see the implementation page for the topology pattern.

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 [Identity Match implementation](/docs/trusted-match/identity-match-implementation#sdk-primitives) for the SDK functions.

## Provider Registration

Expand Down Expand Up @@ -200,16 +203,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 @@ -233,7 +238,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