Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion adcp/schemas/.bundle-sha256
Original file line number Diff line number Diff line change
@@ -1 +1 @@
7eecf9826aab38b6574cb89e5e99cc80ee860ecda04013144620dcd831633d5f
9fc36017a839e117a1079e8304f13e4818c0aadbdc81c132e9edf707eafc1e83
2 changes: 1 addition & 1 deletion adcp/schemas/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.1.0
3.2.0
1 change: 1 addition & 0 deletions adcp/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ type MediaBuyCapabilities struct {
ReportingDeliveryMethods []string `json:"reporting_delivery_methods,omitempty"`
OfflineDeliveryProtocols []string `json:"offline_delivery_protocols,omitempty"`
SupportsProposals *bool `json:"supports_proposals,omitempty"`
GovernanceAware *bool `json:"governance_aware,omitempty"`
PropagationSurfaces []string `json:"propagation_surfaces,omitempty"`
CreativeApprovalMode string `json:"creative_approval_mode,omitempty"`
Features map[string]any `json:"features,omitempty"`
Expand Down
24 changes: 12 additions & 12 deletions adcp/types_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion internal/generate/go-overlays.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@
"Offer.creative_manifest": {"type": "json.RawMessage", "pointer": true},
"Offer.price": {"type": "OfferPrice", "pointer": true},
"ProviderRegistration.status": {"type": "ProviderStatus"},
"ErrorResponse.code": {"type": "ErrorCode"}
"ErrorResponse.code": {"type": "ErrorCode"},
"IdentityMatchResponse.tmpx_providers": {"type": "map[string]TmpxProviderEntry"}
},
"refs": {
"/schemas/latest/core/property-id.json": "string",
Expand Down
46 changes: 39 additions & 7 deletions router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -620,29 +620,58 @@ func mergeContextResponses(requestID string, responses []contextResult, logger *
// same `package_id` in multiple providers' eligible lists — we log a warning
// naming all reporting providers and emit the union (the spec's "must be in
// both" rule collapses to union when only yes-responses are observable).
// TMPX: collected from whichever provider returned it (last wins; in
// practice only one provider mints the token).
//
// TMPX collection per the spec §"TMPX collection":
// - Each agent's TmpxMacros[] is folded into tmpx_providers[provider_id] so
// per-provider attribution survives the fan-out.
// - The legacy singular `tmpx` field stays populated on the merged response
// for back-compat with consumers that haven't moved to tmpx_providers
// (deprecated, removed in 4.0). Source order: prefer the first provider's
// first macro value when TmpxMacros[] is present; otherwise fall back to
// the agent's legacy `tmpx` field (legacy-only agents during transition).
// - The router MUST NOT carry tmpx_macros[] at the root of the outbound
// response — that field is the agent → router carrier only; leaking it
// alongside tmpx_providers would give the publisher no schema signal for
// which to read.
func mergeIdentityResponses(requestID string, providerIDs []string, responses []*tmproto.IdentityMatchResponse, logger *slog.Logger) *tmproto.IdentityMatchResponse {
eligibleSet := make(map[string]struct{})
pkgProviders := make(map[string][]string) // package_id -> distinct provider IDs that listed it
providerRepeats := make(map[string]map[string]bool) // package_id -> set of providers that repeated it within their own response
minServeWindowSec := -1
var tmpx string
var legacyTmpx string
tmpxProviders := make(map[string]tmproto.TmpxProviderEntry)

for i, resp := range responses {
if resp == nil {
continue
}
if resp.Tmpx != "" {
tmpx = resp.Tmpx
}
if minServeWindowSec < 0 || resp.ServeWindowSec < minServeWindowSec {
minServeWindowSec = resp.ServeWindowSec
}
providerID := ""
if i < len(providerIDs) {
providerID = providerIDs[i]
}
// TMPX: prefer the new TmpxMacros[] carrier — collect into the
// per-provider map. Skip empty providerID entries (defensive: a
// fan-out with mis-aligned parallel slices would otherwise stash
// macros under "", which any consumer would treat as garbage).
if len(resp.TmpxMacros) > 0 && providerID != "" {
tmpxProviders[providerID] = tmproto.TmpxProviderEntry{
Macros: append([]tmproto.TmpxMacro(nil), resp.TmpxMacros...),
}
if legacyTmpx == "" {
legacyTmpx = resp.TmpxMacros[0].Value
}
} else if resp.Tmpx != "" {
// Legacy-only agent during transition — preserve in legacy
// carrier so single-string consumers keep working. No
// tmpx_providers entry: the router does not synthesize names
// from registration metadata in this version.
if legacyTmpx == "" {
legacyTmpx = resp.Tmpx
}
}
// Track DISTINCT providers per package_id and remember if any single
// provider repeats a package_id within its own response — eligible
// arrives raw off the wire with no dedup, so a within-provider repeat
Expand Down Expand Up @@ -719,7 +748,10 @@ func mergeIdentityResponses(requestID string, providerIDs []string, responses []
RequestID: requestID,
EligiblePackageIDs: eligible,
ServeWindowSec: minServeWindowSec,
Tmpx: tmpx,
Tmpx: legacyTmpx,
}
if len(tmpxProviders) > 0 {
merged.TmpxProviders = tmpxProviders
}
return merged
}
Expand Down
63 changes: 63 additions & 0 deletions router/router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,69 @@ func TestMergeIdentityResponses(t *testing.T) {
assert.Equal(t, 300, merged.ServeWindowSec)
}

// TestMergeIdentityResponses_TmpxProvidersFromNewShape verifies that each
// agent's TmpxMacros[] is folded into the merged TmpxProviders map keyed by
// provider_id, preserving per-provider attribution across fan-out. Legacy
// `tmpx` stays populated for back-compat with consumers that haven't moved
// to the new shape, sourced from the first provider's first macro value.
func TestMergeIdentityResponses_TmpxProvidersFromNewShape(t *testing.T) {
r1 := &tmproto.IdentityMatchResponse{
EligiblePackageIDs: []string{"pkg-1"},
ServeWindowSec: 300,
TmpxMacros: []tmproto.TmpxMacro{
{Name: "PIN_TMPX_1", Value: "k1.alpha-value"},
},
}
r2 := &tmproto.IdentityMatchResponse{
EligiblePackageIDs: []string{"pkg-2"},
ServeWindowSec: 300,
TmpxMacros: []tmproto.TmpxMacro{
{Name: "NOVA_TMPX_1", Value: "k2.beta-value"},
},
}

merged := mergeIdentityResponses("id-tmpx", []string{"pinnacle_id", "nova_id"},
[]*tmproto.IdentityMatchResponse{r1, r2}, nil)

require.NotNil(t, merged.TmpxProviders, "tmpx_providers MUST be populated when any agent emitted tmpx_macros")
require.Len(t, merged.TmpxProviders, 2)

pin, ok := merged.TmpxProviders["pinnacle_id"]
require.True(t, ok, "pinnacle_id entry must exist")
require.Len(t, pin.Macros, 1)
assert.Equal(t, "PIN_TMPX_1", pin.Macros[0].Name)
assert.Equal(t, "k1.alpha-value", pin.Macros[0].Value)

nova, ok := merged.TmpxProviders["nova_id"]
require.True(t, ok, "nova_id entry must exist")
assert.Equal(t, "NOVA_TMPX_1", nova.Macros[0].Name)
assert.Equal(t, "k2.beta-value", nova.Macros[0].Value)

// Legacy carrier is the first provider's first slot, so back-compat
// consumers receive a non-empty value without per-provider context.
assert.Equal(t, "k1.alpha-value", merged.Tmpx,
"legacy tmpx must mirror the first provider's first slot for back-compat")
}

// TestMergeIdentityResponses_LegacyOnlyAgent covers a transition case: an
// agent that only emits the deprecated `tmpx` string with no TmpxMacros[].
// The router preserves it in the legacy field but does NOT synthesize a
// tmpx_providers entry — the router doesn't have the registered macro names
// in this code path and shouldn't invent them.
func TestMergeIdentityResponses_LegacyOnlyAgent(t *testing.T) {
legacy := &tmproto.IdentityMatchResponse{
EligiblePackageIDs: []string{"pkg-1"},
ServeWindowSec: 300,
Tmpx: "k1.legacy-value",
}

merged := mergeIdentityResponses("id-legacy", []string{"legacy_provider"},
[]*tmproto.IdentityMatchResponse{legacy}, nil)

assert.Empty(t, merged.TmpxProviders, "no TmpxMacros[] emitted → no tmpx_providers entry")
assert.Equal(t, "k1.legacy-value", merged.Tmpx, "legacy carrier preserved")
}

// TestMergeContextResponses_DuplicatePackageID covers the dedup-warn path the
// router-architecture spec calls out: same package_id from two providers MUST
// keep the first response and SHOULD log a warning naming both providers.
Expand Down
41 changes: 41 additions & 0 deletions targeting/identityagent/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,17 @@ type TMPXConfig struct {
EncryptJWKSTTL time.Duration
Country string
Priority string

// MacroNames is the ordered list of ad-server macro slot names this
// provider's TMPX response fills, matching the provider's registered
// `tmpx_macros` (provider-registration.json). The sealer pairs the
// sealed token with these names to populate IdentityMatchResponse's
// `tmpx_macros[]`. When empty the response carries only the legacy
// singular `tmpx` field (deprecated, removed in 4.0). Capped at 2 by
// the spec; multi-chunk encoding is not yet implemented — when
// configured with more than one name only the first slot is filled,
// matching the single-slot deployment shape.
MacroNames []string
}

// LiveRampSidecarConfig optionally enables calls to the Scope3 LiveRamp
Expand Down Expand Up @@ -410,6 +421,7 @@ func LoadConfigFromEnv() (Config, error) {
EncryptJWKSTTL: jwksTTL,
Country: os.Getenv("TMPX_COUNTRY"),
Priority: os.Getenv("TMPX_PRIORITY"),
MacroNames: parseTmpxMacroNames(os.Getenv("TMPX_MACRO_NAMES")),
},
LiveRamp: LiveRampSidecarConfig{
URL: os.Getenv("LIVERAMP_SIDECAR_URL"),
Expand Down Expand Up @@ -645,6 +657,35 @@ func lookupString(name, def string) string {
return def
}

// parseTmpxMacroNames splits TMPX_MACRO_NAMES (comma-separated, e.g.
// `S3_TMPX` or `S3_TMPX_1,S3_TMPX_2`) into the ordered slot list emitted on
// IdentityMatchResponse.tmpx_macros[]. Empty / whitespace-only values yield
// nil, which keeps the legacy single-`tmpx`-string emission shape — no new
// behavior until the env var is set. The spec caps the registered list at 2
// (provider-registration.json `tmpx_macros.maxItems`); enforcement of that
// happens at registration time, not here — this parser is permissive so an
// operator sees the misconfig surface as a downstream schema-validation
// error rather than a startup panic.
func parseTmpxMacroNames(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
names := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
names = append(names, p)
}
if len(names) == 0 {
return nil
}
return names
}

func lookupInt(name string, def int) (int, error) {
v := os.Getenv(name)
if v == "" {
Expand Down
10 changes: 10 additions & 0 deletions targeting/identityagent/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,17 @@ func (h *identityHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
"request_id", req.RequestID, "error", terr)
h.recorder.StageOutcome(ctx, StageTMPX, OutcomeError)
} else if token != "" {
// Legacy single-string carrier — keep populated for back-compat
// with consumers that haven't moved to tmpx_providers. Deprecated;
// removed in 4.0 per the spec.
resp.Tmpx = token
// New shape: ordered macro/value pairs the router collects into
// tmpx_providers[provider_id]. Emitted only when the operator has
// declared the provider's registered macro slot names via
// TMPX_MACRO_NAMES; otherwise we stay on the legacy carrier.
if entry, ok := h.tmpx.MacroEntry(token); ok {
resp.TmpxMacros = []tmproto.TmpxMacro{entry}
}
h.recorder.StageOutcome(ctx, StageTMPX, OutcomePass)
}
h.recorder.StageDuration(ctx, StageTMPX, time.Since(tmpxStart))
Expand Down
37 changes: 31 additions & 6 deletions targeting/identityagent/tmpx.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,17 @@ type TMPXSealer struct {
country string
encStore tmpxRecipientResolver

// macroNames is the ordered list of ad-server macro slot names this
// provider's sealed token fills on IdentityMatchResponse.tmpx_macros[].
// Sourced from TMPXConfig.MacroNames (env: TMPX_MACRO_NAMES) and MUST
// match the provider's registered tmpx_macros list in
// provider-registration.json. When empty the response carries only the
// legacy singular `tmpx` field (deprecated, removed in 4.0). The spec
// caps the registered list at 2; multi-chunk encoding is not yet
// implemented in this sealer — a single-slot deployment is the only
// shape this version emits, and additional slot names are ignored.
macroNames []string

// priority is the explicit per-spec priority ordering used when the
// resolved identities exceed the 255-byte wire budget. Entries earlier
// in the slice rank higher; entries whose UIDType is absent are
Expand Down Expand Up @@ -209,15 +220,29 @@ func NewTMPXSealer(runCtx context.Context, cfg TMPXConfig, lrClient LiveRampSide
decoders := buildTmpxDecoders(tmpxdecoders.RegistryOptions{LiveRampClient: decoderAdapter})
logDecoderLayout(logger, cfg.Country, decoders, order)
return &TMPXSealer{
country: cfg.Country,
encStore: store,
priority: order,
decoders: decoders,
logger: logger,
recorder: recorder,
country: cfg.Country,
encStore: store,
macroNames: append([]string(nil), cfg.MacroNames...),
priority: order,
decoders: decoders,
logger: logger,
recorder: recorder,
}, nil
}

// MacroEntry returns the {name, value} pair that fills the provider's first
// registered macro slot for the given sealed token, or (zero, false) when no
// macro names are configured. Single-slot is the only emission shape today;
// multi-chunk splitting across more than one registered name is deferred
// until production deployments actually exceed the 255-char ad-server slot
// budget.
func (s *TMPXSealer) MacroEntry(token string) (tmproto.TmpxMacro, bool) {
if s == nil || token == "" || len(s.macroNames) == 0 {
return tmproto.TmpxMacro{}, false
}
return tmproto.TmpxMacro{Name: s.macroNames[0], Value: token}, true
}

// log returns the sealer's logger, falling back to slog.Default() when none
// was configured. Tests that construct TMPXSealer directly without a
// logger still get sensible output.
Expand Down
Loading
Loading