Skip to content

New Module: adcontextprotocol/tmp — OpenRTB → TMP adapter + router - #4865

Draft
ohalushchak-exadel wants to merge 10 commits into
prebid:masterfrom
scope3data:adcontextprotocol-tmp-module
Draft

New Module: adcontextprotocol/tmp — OpenRTB → TMP adapter + router#4865
ohalushchak-exadel wants to merge 10 commits into
prebid:masterfrom
scope3data:adcontextprotocol-tmp-module

Conversation

@ohalushchak-exadel

@ohalushchak-exadel ohalushchak-exadel commented Jul 16, 2026

Copy link
Copy Markdown

Summary

Adds a new Prebid Server module modules/adcontextprotocol/tmp/ that acts as an OpenRTB → Trusted Match Protocol (TMP) adapter and TMP router.

For each auction the module:

  • Resolves site.domain / app.bundle to a stable property_rid via the AdCP property catalog's POST /api/registry/resolve endpoint (adcp ResolveRequest/ResolveResponse), with an in-memory expirable LRU cache (single-flight to collapse concurrent misses).
  • Builds a TMP context_match_request from site/app/imp/geo fields and, when identity tokens are present on user.eids, a companion identity_match_request. The two payloads are structurally separated at the wire level and in this module's code paths, per the TMP privacy contract.
  • Fans out to one or more configured TMP providers in parallel, signing every outbound request with Ed25519 (X-AdCP-Signature, X-AdCP-Key-Id) per the spec — signature bound to the provider endpoint URL and daily epoch.
  • Joins each provider's context offers with its identity eligibility set locally and surfaces the joined signals on the bid response ext under a configurable key (default adcp), with optional mirroring to prebid.targeting for GAM-style pass-through.

Multi-provider config: each provider may set identity_url, context_url, or both — at least one URL per provider is required.

Response surface

Four spec surfaces flow onto ext.<targeting_key>.segments and (when enabled) ext.prebid.targeting:

  • Package IDs eligible under identity, comma-joined under package_targeting_key (default adcp_package_id).
  • Response-level context signals (ContextMatchResponse.signals).
  • Per-offer creative macros (Offer.macros) for offers that survived the identity eligibility gate.
  • Identity TMPX chunks resolved through the publisher-owned tmpx_macro_mapping config (see below).

TMPX macro handling

The module is the router in the publisher-owned TMPX macro model — providers emit provider-local {slot_id, value} pairs against their registered tmpx_slots, and the publisher's deployment configuration owns the ad-server destination namespace.

  • providers[].tmpx_slots mirrors each provider's registered tmpx_slots list (provider-registration.json). The module enforces the adcp#5971 ordered-prefix invariant on every incoming response — any provider whose emitted tmpx_chunks[].slot_id sequence deviates (empty, reordered, sparse, unregistered, over-cap, duplicate) has its chunks dropped atomically. Mirrors the reference implementation in adcp-go router/slot_contract.go.
  • tmpx_macro_mapping is the publisher-authored provider_id → slot_id → ad-server macro name map. Chunks with an unmapped (provider, slot_id) fail the whole provider closed on that impression (per publisher-tmpx-config.json). Providers absent from the mapping emit no TMPX targeting on this surface.
  • Provider-hop responses that carry router-hop or envelope-extension fields (tmpx_providers, tmpx, tmpx_values, tmpx_macros, context, ext) are rejected — provider-identity-match-response.json encodes that as a not: {anyOf: [...]} MUST.

Config-time validation enforces every layer: provider_id and slot_id charsets, v1 slot cap (2), mapping entries cross-checked against registered tmpx_slots, and a startup warning when a registered slot has no mapping entry (so the operator sees the gap before the fail-closed rule catches it at serve time).

Property catalog

The property resolver POSTs to adcp's /api/registry/resolve with {identifiers, provenance, mode} matching the ResolveRequest schema, and reads resolved[0].property_rid back. Null property_rid (excluded ad-infra or publisher-mask identifiers, unresolved lookups) caches as a negative hit so downstream fan-out short-circuits without hitting the TMP agents.

  • property_registry.moderesolve (default) contributes to the catalog and requires auth_bearer; lookup is a pure unauthenticated read.
  • property_registry.provenance_type — one of the adcp FactProvenance.type enum values (member_assertion by default). crawl is reserved for server-side pipelines and rejected.
  • Bundle vs domain identifier type is inferred from the input shape (reverse-DNS prefix → bundle; everything else → domain).

Design notes

  • Wire types, URL canonicalization, and signing primitives are imported from github.com/adcontextprotocol/adcp-go. The Go module pin is on the specific sub-modules the code uses (.../tmproto v0.1.0, .../urlcanon v0.1.0 transitive) rather than the root pseudo-version, matching the module split adcp-go 3.0 published.
  • Privacy invariants: context requests never carry identity tokens; identity requests never carry page context. Geo on the context path is coarsened to country / region / metro. Identity token count is capped at 3 to match the TMP HPKE budget. Context and identity calls per provider fire in randomized order with optional decorrelation jitter.
  • Signing key material is passed as PEM in module config; deployments substitute from environment via yaml expansion (\${ADCP_TMP_SIGNING_KEY_PEM}), matching the pattern used by other modules.
  • Auction is never failed by this module — registry / provider errors are logged and the auction continues without TMP signals. Identity-attempted-but-errored fails closed (no offers pass, no TMPX emitted).

References

Test plan

  • Unit tests: config validation (URL invariant, charsets, v1 slot cap, unknown-provider mapping, coverage warnings, unregistered-slot mapping, registry mode/provenance defaults, resolve-requires-bearer), OpenRTB→TMP mapping, property registry (POST body, mode/provenance forwarding, null-RID negative caching, cache, negative cache on 404, LRU eviction, bearer auth, upstream error), fan-out router (context+identity join, context-only fallback, identity-attempted fail-closed, signing headers, decorrelation ordering / jitter, panic containment)
  • TMPX conformance: ordered-prefix slot-contract table matching adcp-go router/slot_contract.go; reorder / sparse / over-cap / empty-value / unmapped-slot merge-layer regressions; forbidden-router-hop-field rejection on the provider response
  • go build ./...
  • go test ./modules/... -race -count=1
  • ./scripts/format.sh -f false
  • Integration test against a live TMP provider (out of scope for this PR — happens in the deploying operator's environment)

ohalushchak-exadel and others added 6 commits August 6, 2026 12:06
Adds a Prebid Server module that acts as an OpenRTB → Trusted Match
Protocol (TMP) adapter and TMP router. For each auction the module:

- Resolves site.domain / app.bundle to a property_rid via the AdCP
  property registry (adcp-go), with an in-memory expirable LRU cache.
- Builds a TMP context_match_request from site/app/imp/geo fields, and,
  when identity tokens are available on user.eids, a companion
  identity_match_request. The two payloads are structurally separated
  per the TMP privacy contract.
- Fans out to one or more configured TMP providers in parallel, signing
  every outbound request with Ed25519 per the spec (X-AdCP-Signature,
  X-AdCP-Key-Id) and binding the signature to the provider endpoint URL
  and daily epoch.
- Merges responses locally (offers intersected with eligibility) and
  surfaces the joined signals on the bid response ext under a
  configurable key, with optional mirroring to prebid.targeting.

Multi-provider configuration is supported: each provider can serve
identity_url, context_url, or both. At least one URL per provider is
required. Existing modules/scope3/rtd is left untouched.

Wire types, canonicalization, and signing primitives are imported from
github.com/adcontextprotocol/adcp-go; the OpenRTB→TMP mapping and the
property registry client are implemented here.
…tness

- Recover panics in per-provider fan-out goroutines and per-endpoint
  inner goroutines. A crashing provider client no longer takes the
  process down; the error is appended to the provider result and the
  merge stays consistent.
- Drop the client-level http.Client.Timeout so per-provider TimeoutMs
  can exceed the module default. Per-call deadlines come from context.
- Short-circuit the fan-out when the OpenRTB request lacks a placement
  id — ContextMatch requires it and firing without one wastes a signed
  call every well-behaved provider would 400.
- Return an error from newRequestID rather than falling back to a
  literal string. Reusing a fallback would silently violate the TMP
  privacy invariant that context and identity request_ids never
  correlate.
- Bound response reads on the provider and registry clients so a
  misbehaving upstream cannot exhaust memory.
- Give the property registry singleflight leader a fresh context so
  followers are not tied to whichever caller happened to arrive first.
- Drain non-2xx registry response bodies to preserve keep-alive reuse.
- Own a cancelable context in the entrypoint hook so the fan-out
  goroutine is guaranteed to stop when the auction ends.
- Mirror segments onto seatbid[].bid[].ext.prebid.targeting when
  add_to_targeting is set — that is where GAM actually reads keys.
- Stringify context response signals with fmt.Sprint so non-string
  values (numbers, bools) survive rather than being silently dropped.
- Fix DefaultPropertyType docs / behavior mismatch — OpenRTB
  auto-detect wins over the operator default; default only applies
  when neither Site nor App is present.
- Allocate a fresh slice in filterIdentities so future callers that
  reuse the input slice do not see mutated content.
- Reject empty keys in splitKV so a malformed segment does not produce
  invalid sjson paths.
- Drop the unused cache_ttl_seconds config field.
- Add tests for the placement short-circuit and provider-error paths.
The TMP spec recommends the publisher randomize order and delay the
context and identity outbound calls to break timing correlation at a
passive observer. Add both:

- Ordering is always randomized per request (rand.Shuffle on the two
  closures). Zero cost, no config knob needed.
- New DecorrelationMaxDelayMs config field (default 0 = off). When set,
  the second of the two calls sleeps for a uniform random duration in
  [0, N] ms before firing. Guarded against context cancellation so a
  ticking auction deadline still stops the wait promptly.

Tests: verify both orderings appear across 200 iterations and that
the default config path stays fast when the delay is disabled.
…king

Correctness batch first (Fable found the module was silently broken):

- Blocker #1 (fan-out ctx dies on hook return). The framework cancels
  each hook's own ctx the moment the hook returns
  (hooks/hookexecution/execution.go), so a fan-out rooted in an
  entrypoint hook's ctx was Done before the goroutine spawned.
  Consequence: every provider call started already-cancelled, zero
  segments landed, and the failure was silent (errors stayed inside
  providerResult.Errs and analytics reported Success). Removed the
  entrypoint hook entirely; the async holder now allocates inside
  HandleProcessedAuctionHook with a Background-rooted ctx, cancelled
  via defer async.cancel() in the response hook. That also fixes
  finding prebid#6 (response hook stalling for its full group timeout when
  processed-auction never ran) — no holder ever means the response
  hook short-circuits cleanly.
- Blocker prebid#2 (data race on live BidRequest). The fan-out goroutine
  read Site/Imp/Device.Geo/User.EIDs and unmarshalled User.Ext while
  the auction continued to mutate the wrapper (RebuildRequest,
  privacy scrubbing, other modules). deriveInputs now runs
  synchronously on the caller's stack; the goroutine only sees the
  tmpInputs value snapshot and never touches the BidRequest.
- Finding prebid#10 (no hooks-level test). New hooks_test.go exercises
  HandleProcessedAuctionHook → HandleAuctionResponseHook through real
  hookstage plumbing, including simulating the framework's per-hook
  ctx cancellation. This is the test that would have caught #1 in
  the first place; it fails against the pre-fix code.

Then the small independent correctness fixes:

- prebid#4 registry bare-record parse: fall back to decoding the raw
  payload as PropertyRecord when the {"property": {...}} envelope
  isn't found. The old code negative-cached bare records as
  "not found" for 300 s, silently.
- prebid#7 per-provider request IDs: build ctxReq and idReq inside
  callProvider so two colluding providers don't get the same
  request_id pair for the same auction.
- prebid#8 hostile domain: cap site.domain / app.bundle at 253 chars and
  restrict to `[a-z0-9._-]`. Rejects invalid keys before touching
  the LRU or the registry.

Then the design/product calls user directed on:

- prebid#3 fail-closed on identity error: providerResult tracks
  IdentityAttempted (URL configured AND tokens present). When
  IdentityAttempted is true but the call returned no response,
  mergeSegments drops all offers for that provider. A hostile or
  flaky identity endpoint can no longer convert identity-gated
  packages into unconditionally-served packages.
- prebid#5 cap segments + batch per-bid targeting write. New config knobs
  MaxSegments (default 128) and MaxSegmentValueLen (default 256)
  bound both the total segment count and each segment's length,
  regardless of what a provider returns. Response-hook per-bid
  targeting now builds one map from segments and writes it via a
  single sjson.SetBytes per bid at ext.prebid.targeting instead of
  O(bids × segments) rewrites. Provider names are also validated
  in Config.validated() to prevent them from colliding with Prebid's
  reserved targeting prefixes (hb_*).
- prebid#9 wire the masking config. coarseGeo now takes cfg and honors
  PreserveMetro / PreserveZip / PreserveCity / LatLongPrecision
  (with math.Trunc for lat/lon precision); extractIdentities honors
  PreserveMobileIds (drops maid-typed tokens when masking enabled
  and false) and PreserveEids (allowlist over the default set).
  Deleted the now-empty masking.go — every knob is now wired
  through adapter.go at input-derivation time.

Nits also addressed:

- Analytics no longer reports Success when every provider errored;
  errCount from routerResult drives Status/ResultStatus explicitly.
- site.page's query and fragment components are stripped before
  emission as an artifact ref — gclid / click IDs / occasional
  email leak into the identity-free context path.
- Ordering test reframed: with DecorrelationMaxDelayMs > 0 the
  second-to-spawn call is deterministically delayed, so HTTP arrival
  order actually reflects the shuffle instead of scheduler noise.
- Panic-recovery test replaced with one that injects a panicking
  RoundTripper — actually exercises the recover paths in
  callProvider's inner goroutines.
- Nil-guard on payload.BidResponse in the response-hook mutation.
- Removed unused asyncRequest.err field.
Prior shape emitted every segment as `<providerName>_<key>=<value>`, which
doesn't match how ESA / agentic-api-onboarded publishers configure GAM —
their line items target on a well-known custom key (`adcp_package_id`)
holding the raw package_id IN-list, not on `<provider>_package`.

Reworked mergeSegments to cover the four surfaces the AdCP TMP spec calls
out (see adcp-go tmproto/types_gen.go):

1. Matched package IDs → single configurable key (default `adcp_package_id`),
   comma-joined and deduplicated across every provider that responded.
2. ContextMatchResponse.Signals → raw keys.
3. Offer.Macros → raw keys, per-offer creative macros.
4. IdentityMatchResponse.TmpxMacros[] → each macro's Name=Value verbatim.
   Names are already provider-namespaced upstream via the provider's
   registered tmpx_macros list; no transformation.

Fail-closed on identity error now suppresses TMPX macros too — a flaky
identity endpoint cannot inject a token onto an impression whose
eligibility gate should have blocked it.

Cross-provider collisions on non-package keys are last-wins with a warn
log naming both providers, so operators can spot config drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The identity-agent response shape moved from
IdentityMatchResponse.TmpxMacros[] (provider-named ad-server macros) to
ProviderIdentityMatchResponse.TmpxChunks[] (provider-local slot IDs +
opaque values). The publisher, not the provider, now owns the
ad-server destination namespace via a deployment-config map keyed on
(provider_id, slot_id).

This module IS the router in that model, so:

- provider_client decodes ProviderIdentityMatchResponse.
- router.mergeSegments iterates TmpxChunks[] and resolves each chunk's
  (provider, slot_id) against a new publisher-owned tmpx_macro_mapping
  config. Unmapped slots drop the whole provider's chunks atomically
  for that impression (fail-closed per adcp publisher-tmpx-config.json).
- Providers absent from the mapping emit no TMPX targeting on this
  surface — the mapping is per-Prebid-Server deployment.
- Config validation rejects mapping entries that reference providers
  not declared in providers[] or that carry empty slot/macro strings.

Pin the specific adcp-go sub-modules the code imports (tmproto v0.1.0
plus its urlcanon transitive) instead of a pseudo-versioned root
module, matching the module boundary the SDK now publishes.
@ohalushchak-exadel
ohalushchak-exadel force-pushed the adcontextprotocol-tmp-module branch from f49b0f6 to 94e9b1f Compare August 6, 2026 10:15
…vider hop

Follow-up to the publisher-owned TMPX mapping commit. Addresses the
gaps a spec-conformance review turned up against adcp
publisher-tmpx-config.json, provider-identity-match-response.json,
provider-registration.json, tmpx-chunk.json, and
docs/trusted-match/router-architecture.mdx.

- providers[].tmpx_slots: mirror the provider's registered `tmpx_slots`
  in module config. mergeSegments now enforces the adcp#5971
  ordered-prefix invariant on each provider's emitted TmpxChunks — any
  deviation (empty, reordered, sparse, unregistered, over-cap,
  duplicate) drops that provider's chunks atomically. Mirrors adcp-go
  router/slot_contract.go so the module cannot fall out of sync.

- Provider-hop response: reject responses that carry any router-hop or
  envelope-extension field (tmpx_providers, tmpx, tmpx_values,
  tmpx_macros, context, ext) — provider-identity-match-response.json
  encodes that as a `not: {anyOf: [...]}` MUST.

- resolveTmpxChunks: an empty chunk value now fails the whole provider
  closed rather than silently skipping the entry (tmpx-chunk.json marks
  value required with minLength 1).

- providerNameRE: widened to the adcp provider_id charset
  (`^[A-Za-z0-9_]{1,64}$`) — the previous hyphen-inclusive regex
  drifted from the spec.

- tmpx_macro_mapping validation: outer key must match the provider_id
  charset, inner key must match the adcp slot_id charset, entries are
  cross-checked against the provider's registered tmpx_slots (unknown
  slot rejected; missing coverage warns at startup so the operator
  can add it before the fail-closed rule catches it at serve time).
  Value length and slot-count caps mirror publisher-tmpx-config.json.

- README + config-doc drift: uses `provider_id` consistently, documents
  tmpx_slots and the fail-closed rule.

Test coverage: enforceProviderSlotContract cases mirror adcp-go's
reference table; merge-layer regressions for the reorder / empty-value
paths; provider-client test for the forbidden-router-hop-field
rejection; config-validation coverage for every new rule.
The catalog resolve endpoint on agenticadvertising.org is
POST /api/registry/resolve — takes {identifiers, provenance, mode} and
returns {resolved[]{property_rid, status, classification, source}}
per adcp catalog-openapi.ts. The module was hitting the deprecated
GET /api/properties/resolve, which returns a different, un-nested
shape that has no property_rid at all — every real deployment would
silently short-circuit before calling the TMP agents.

- fetch() now POSTs the ResolveRequest body and parses
  resolved[0].property_rid. null property_rid (excluded /
  publisher_mask / unresolved-in-lookup) caches as a negative hit.
- PropertyRegistryConfig gains Mode ("resolve" | "lookup") and
  ProvenanceType / ProvenanceContext, matching the ResolveRequest
  envelope. Defaults: endpoint = agenticadvertising.org,
  mode = resolve, provenance_type = member_assertion.
- Config validation: mode=resolve without an auth_bearer is rejected;
  provenance_type is checked against the adcp FactProvenance.type
  enum (crawl is reserved for server-side pipelines and excluded).
- Identifier type selection: heuristic picks `bundle` for reverse-DNS
  shapes (com./io./org./net./app./co./dev.) and `domain` for
  everything else, matching adcp CatalogIdentifier.type.
- Classification → PropertyType mapping fills in the tmproto enum
  from the catalog's `classification` field when it's a known media
  type; the generic "property" bucket leaves it empty so the OpenRTB
  adapter's heuristic wins.
- Fixture tests updated to serve the new response envelope and
  exercise the mode/provenance forwarding path.
@ohalushchak-exadel
ohalushchak-exadel marked this pull request as ready for review August 6, 2026 12:20
@ohalushchak-exadel
ohalushchak-exadel marked this pull request as draft August 6, 2026 12:22
Move the "at least one of IdentityURL or ContextURL is required" rule
onto the struct doc and expand each field's comment to describe what
happens when it is empty. The rule was already enforced in validated(),
but the shape of the type didn't make it obvious a reader had to jump
there to know it.
The URL is folded into the Ed25519 signing preimage, so a mismatch
between the value here, the publisher's adagents.json
authorized_agents[].url, and the AAO /api/registry/authorizations row
that carries our signing_keys[] is a hard 401 at every receiving
agent. Document that plus AdCP URL canonicalization (case/port/
trailing-slash normalized, path significant) so operators see the
contract at the point of definition rather than reverse-engineering
it from the receiver's error messages.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant