New Module: adcontextprotocol/tmp — OpenRTB → TMP adapter + router - #4865
Draft
ohalushchak-exadel wants to merge 10 commits into
Draft
New Module: adcontextprotocol/tmp — OpenRTB → TMP adapter + router#4865ohalushchak-exadel wants to merge 10 commits into
ohalushchak-exadel wants to merge 10 commits into
Conversation
5 tasks
ohalushchak-exadel
marked this pull request as draft
July 16, 2026 14:37
ohalushchak-exadel
force-pushed
the
adcontextprotocol-tmp-module
branch
from
July 16, 2026 14:41
1024f4d to
0311e0c
Compare
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
force-pushed
the
adcontextprotocol-tmp-module
branch
from
August 6, 2026 10:15
f49b0f6 to
94e9b1f
Compare
…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
marked this pull request as ready for review
August 6, 2026 12:20
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
site.domain/app.bundleto a stableproperty_ridvia the AdCP property catalog's POST/api/registry/resolveendpoint (adcpResolveRequest/ResolveResponse), with an in-memory expirable LRU cache (single-flight to collapse concurrent misses).context_match_requestfrom site/app/imp/geo fields and, when identity tokens are present onuser.eids, a companionidentity_match_request. The two payloads are structurally separated at the wire level and in this module's code paths, per the TMP privacy contract.X-AdCP-Signature,X-AdCP-Key-Id) per the spec — signature bound to the provider endpoint URL and daily epoch.extunder a configurable key (defaultadcp), with optional mirroring toprebid.targetingfor 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>.segmentsand (when enabled)ext.prebid.targeting:package_targeting_key(defaultadcp_package_id).ContextMatchResponse.signals).Offer.macros) for offers that survived the identity eligibility gate.tmpx_macro_mappingconfig (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 registeredtmpx_slots, and the publisher's deployment configuration owns the ad-server destination namespace.providers[].tmpx_slotsmirrors each provider's registeredtmpx_slotslist (provider-registration.json). The module enforces the adcp#5971 ordered-prefix invariant on every incoming response — any provider whose emittedtmpx_chunks[].slot_idsequence deviates (empty, reordered, sparse, unregistered, over-cap, duplicate) has its chunks dropped atomically. Mirrors the reference implementation inadcp-go router/slot_contract.go.tmpx_macro_mappingis the publisher-authoredprovider_id → slot_id → ad-server macro namemap. Chunks with an unmapped(provider, slot_id)fail the whole provider closed on that impression (perpublisher-tmpx-config.json). Providers absent from the mapping emit no TMPX targeting on this surface.tmpx_providers,tmpx,tmpx_values,tmpx_macros,context,ext) are rejected —provider-identity-match-response.jsonencodes that as anot: {anyOf: [...]}MUST.Config-time validation enforces every layer:
provider_idandslot_idcharsets, v1 slot cap (2), mapping entries cross-checked against registeredtmpx_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/resolvewith{identifiers, provenance, mode}matching theResolveRequestschema, and readsresolved[0].property_ridback. Nullproperty_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.mode—resolve(default) contributes to the catalog and requiresauth_bearer;lookupis a pure unauthenticated read.property_registry.provenance_type— one of the adcpFactProvenance.typeenum values (member_assertionby default).crawlis reserved for server-side pipelines and rejected.bundle; everything else →domain).Design notes
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.0transitive) rather than the root pseudo-version, matching the module split adcp-go 3.0 published.\${ADCP_TMP_SIGNING_KEY_PEM}), matching the pattern used by other modules.References
docs/trusted-match/specification.mdx,docs/trusted-match/router-architecture.mdx)static/schemas/source/trusted-match/provider-identity-match-response.jsonstatic/schemas/source/trusted-match/publisher-tmpx-config.jsoncatalog-openapi.tsResolveRequest/ResolveResponsetmproto,urlcanonTest plan
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 responsego build ./...go test ./modules/... -race -count=1./scripts/format.sh -f false