From dd9062603d17a379cc20b77931a9871614784c82 Mon Sep 17 00:00:00 2001 From: Etienne Latendresse Date: Tue, 6 Oct 2026 16:04:56 -0400 Subject: [PATCH 1/3] eidCache: let firstPartyMatchers keep a third-party EID over a first-party one --- lib/core/eid-cache.md | 11 ++++++ lib/core/eid-cache.test.ts | 72 ++++++++++++++++++++++++++++++++++++++ lib/core/eid-cache.ts | 38 +++++++++++++------- 3 files changed, 108 insertions(+), 13 deletions(-) diff --git a/lib/core/eid-cache.md b/lib/core/eid-cache.md index 8be0ec96..2bb2ff94 100644 --- a/lib/core/eid-cache.md +++ b/lib/core/eid-cache.md @@ -29,6 +29,17 @@ So anything read back out of storage is ready to merge as-is, and anything comin - Each EID keeps at most `maxUidsPerEid` UIDs (default 2). - `ortb2.user.data` comes from the new response, falling back to the cached one. - The inputs are never mutated. The merged cache is built from copies. + +## First-party preference + +`firstPartyMatchers` names the domains the edge labels first-party matches with. Set it and a first-party EID will not displace a third-party one already cached on that source; anything else still wins, so a call keeps refreshing its own entries, and the kept EID keeps its refs. The edge labels each EID with `eid.matcher`, and an EID with no matcher counts as third-party. + +```typescript +const { merged } = mergeCache(replaceCache(response), cached, { firstPartyMatchers: ["acme.com"] }); +``` + +This is what lets a targeting call resolved with `skip_matchers: ["1p"]` run alongside a tokenize call: the two fill the same sources, and whichever lands second no longer overwrites the other. The decision is made on the label rather than on arrival order, so the calls can go out in parallel instead of tokenize waiting for targeting to come back empty. Leave it unset and every new EID wins its source, as before. + - Cached EIDs are wire EIDs: refresh material never sits on them, so every consumer — RTD, `pubProvidedId`, anything else — can hand them to bidding as-is, with nothing to strip. ## UID2 refresh material diff --git a/lib/core/eid-cache.test.ts b/lib/core/eid-cache.test.ts index 9f69fabd..d7c57ae5 100644 --- a/lib/core/eid-cache.test.ts +++ b/lib/core/eid-cache.test.ts @@ -254,3 +254,75 @@ describe("mergeCache", () => { expect(staleUid2s).toEqual([]); }); }); + +describe("mergeCache - firstPartyMatchers", () => { + const FP = { firstPartyMatchers: ["acme.com"] }; + const idOn = (merged: unknown, source: string) => + (merged as any).ortb2?.user?.eids?.find((e: any) => e.source === source)?.uids?.[0]?.id; + + it("keeps a third-party EID when a first-party one lands on the same source", () => { + const held = cache([eid("liveramp.com", { matcher: "3p.example", uids: [{ id: "third-party" }] })]); + const incoming = cache([eid("liveramp.com", { matcher: "acme.com", uids: [{ id: "first-party" }] })]); + + const { merged } = mergeCache(incoming as any, held as any, FP); + + expect(idOn(merged, "liveramp.com")).toBe("third-party"); + }); + + it("lets a third-party EID displace a first-party one", () => { + const held = cache([eid("liveramp.com", { matcher: "acme.com", uids: [{ id: "first-party" }] })]); + const incoming = cache([eid("liveramp.com", { matcher: "3p.example", uids: [{ id: "third-party" }] })]); + + const { merged } = mergeCache(incoming as any, held as any, FP); + + expect(idOn(merged, "liveramp.com")).toBe("third-party"); + }); + + it("lets a first-party EID refresh its own entry", () => { + const held = cache([eid("liveramp.com", { matcher: "acme.com", uids: [{ id: "stale" }] })]); + const incoming = cache([eid("liveramp.com", { matcher: "acme.com", uids: [{ id: "fresh" }] })]); + + const { merged } = mergeCache(incoming as any, held as any, FP); + + expect(idOn(merged, "liveramp.com")).toBe("fresh"); + }); + + it("treats an EID with no matcher as third-party", () => { + const held = cache([eid("liveramp.com", { uids: [{ id: "unlabelled" }] })]); + const incoming = cache([eid("liveramp.com", { matcher: "acme.com", uids: [{ id: "first-party" }] })]); + + const { merged } = mergeCache(incoming as any, held as any, FP); + + expect(idOn(merged, "liveramp.com")).toBe("unlabelled"); + }); + + it("keeps the held EID's refs when a first-party EID is turned away", () => { + const kept = ref({ advertising_token: "kept" }); + const held = cache([eid("uidapi.com", { matcher: "3p.example" })], { refs: { "uidapi.com": kept } }); + const incoming = cache([eid("uidapi.com", { matcher: "acme.com" })], { + refs: { "uidapi.com": ref({ advertising_token: "discarded" }) }, + }); + + const { merged } = mergeCache(incoming as any, held as any, FP); + + expect(getRefData(merged, "uidapi.com")).toEqual(kept); + }); + + it("still revokes a source the new response sends with no uids", () => { + const held = cache([eid("id5-sync.com", { matcher: "3p.example" })]); + const incoming = cache([{ source: "id5-sync.com", uids: [] }]); + + const { merged } = mergeCache(incoming as any, held as any, FP); + + expect(merged.ortb2?.user?.eids).toEqual([]); + }); + + it("without the option, a first-party EID wins its source as before", () => { + const held = cache([eid("liveramp.com", { matcher: "3p.example", uids: [{ id: "third-party" }] })]); + const incoming = cache([eid("liveramp.com", { matcher: "acme.com", uids: [{ id: "first-party" }] })]); + + const { merged } = mergeCache(incoming as any, held as any); + + expect(idOn(merged, "liveramp.com")).toBe("first-party"); + }); +}); diff --git a/lib/core/eid-cache.ts b/lib/core/eid-cache.ts index ee0edba7..7f5d8891 100644 --- a/lib/core/eid-cache.ts +++ b/lib/core/eid-cache.ts @@ -18,6 +18,8 @@ type Uid2RefData = { type CachedEid = { source: string; + // Matching domain the edge labelled this EID with; absent counts as third-party. + matcher?: string; uids?: Array<{ id?: string; atype?: number; @@ -129,16 +131,22 @@ export function replaceCache(response: T): T { * result. The cache read back from storage already is, since setTargeting * writes through replaceCache; a wire response goes through replaceCache * first. + * + * With firstPartyMatchers set, a first-party EID never displaces a third-party + * one already held on that source. Unset, every new EID wins its source. */ export function mergeCache( newObj: ResolvedCache | null | undefined, oldObj: ResolvedCache | null | undefined, - options?: { maxUidsPerEid?: number } + options?: { maxUidsPerEid?: number; firstPartyMatchers?: string[] } ): { merged: ResolvedCache; staleUid2s: StaleUid2[] } { const oldEids = oldObj?.ortb2?.user?.eids || []; const newEids = newObj?.ortb2?.user?.eids || []; const maxUids = options?.maxUidsPerEid ?? DEFAULT_MAX_UIDS_PER_EID; + const firstPartyMatchers = new Set(options?.firstPartyMatchers ?? []); + const isFirstParty = (eid: CachedEid): boolean => !!eid.matcher && firstPartyMatchers.has(eid.matcher); + // Copies are wire-clean: capped uids, and the ref pointer into the response // refs map is dropped since the sidecar replaces it. const copyOf = (eid: CachedEid): CachedEid => ({ @@ -146,27 +154,31 @@ export function mergeCache( uids: (eid.uids || []).slice(0, maxUids).map(stripRefPointer), }); - const newSources = new Set(newEids.map((e) => e.source)); const eidMap = new Map(); const refs: Record = {}; - // Carry over old EIDs whose source is not in the new response, along with - // their refs entry. + // A source sent with no uids is revoked: the old entry goes. + const revokedSources = new Set(newEids.filter((eid) => !eid.uids?.length).map((eid) => eid.source)); + + // Every other old EID is a candidate, contested sources included: the + // collision is settled below rather than by arrival order. oldEids.forEach((eid) => { - if (!eid.uids?.length) return; - if (!newSources.has(eid.source)) { - eidMap.set(eid.source, copyOf(eid)); - const ref = getRefData(oldObj, eid.source); - if (ref) { - refs[eid.source] = ref; - } + if (!eid.uids?.length || revokedSources.has(eid.source)) return; + eidMap.set(eid.source, copyOf(eid)); + const ref = getRefData(oldObj, eid.source); + if (ref) { + refs[eid.source] = ref; } }); - // New EIDs overwrite old ones with the same source, and so does their refs - // entry: a source re-resolved without one has its stale entry dropped. + // New EIDs overwrite their source, refs included: a source re-resolved + // without one has its stale entry dropped. A first-party EID yields to a + // third-party one already held, which keeps its own refs. newEids.forEach((eid) => { if (!eid.uids?.length) return; + const held = eidMap.get(eid.source); + if (held && isFirstParty(eid) && !isFirstParty(held)) return; + eidMap.set(eid.source, copyOf(eid)); const ref = getRefData(newObj, eid.source); if (ref) { From afd0d0a8df0385c95c05199d5ecf8aef9d14e9a7 Mon Sep 17 00:00:00 2001 From: Etienne Latendresse Date: Fri, 9 Oct 2026 13:31:43 -0400 Subject: [PATCH 2/3] eidCache: stop a first-party revocation from dropping a held third-party EID --- lib/core/eid-cache.md | 2 +- lib/core/eid-cache.test.ts | 45 ++++++++++++++++++++++++++++++++++++++ lib/core/eid-cache.ts | 22 ++++++++++--------- 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/lib/core/eid-cache.md b/lib/core/eid-cache.md index 2bb2ff94..70c76eaa 100644 --- a/lib/core/eid-cache.md +++ b/lib/core/eid-cache.md @@ -32,7 +32,7 @@ So anything read back out of storage is ready to merge as-is, and anything comin ## First-party preference -`firstPartyMatchers` names the domains the edge labels first-party matches with. Set it and a first-party EID will not displace a third-party one already cached on that source; anything else still wins, so a call keeps refreshing its own entries, and the kept EID keeps its refs. The edge labels each EID with `eid.matcher`, and an EID with no matcher counts as third-party. +`firstPartyMatchers` names the domains the edge labels first-party matches with. Set it and a first-party EID will not displace a third-party one already cached on that source, revocation included: a first-party response sending the source with no uids leaves the cached third-party EID alone. Anything else still wins, so a call keeps refreshing and revoking its own entries, and the kept EID keeps its refs. The edge labels each EID with `eid.matcher`, and an EID with no matcher counts as third-party. ```typescript const { merged } = mergeCache(replaceCache(response), cached, { firstPartyMatchers: ["acme.com"] }); diff --git a/lib/core/eid-cache.test.ts b/lib/core/eid-cache.test.ts index d7c57ae5..10f34d08 100644 --- a/lib/core/eid-cache.test.ts +++ b/lib/core/eid-cache.test.ts @@ -326,3 +326,48 @@ describe("mergeCache - firstPartyMatchers", () => { expect(idOn(merged, "liveramp.com")).toBe("first-party"); }); }); + +describe("mergeCache - first-party revocation", () => { + const FP = { firstPartyMatchers: ["acme.com"] }; + const sources = (merged: unknown) => (merged as any).ortb2?.user?.eids?.map((e: any) => e.source); + + it("does not let a first-party revocation drop a held third-party EID", () => { + const kept = ref({ advertising_token: "kept" }); + const held = cache([eid("uidapi.com", { matcher: "3p.example" })], { refs: { "uidapi.com": kept } }); + const incoming = cache([{ source: "uidapi.com", matcher: "acme.com", uids: [] }]); + + const { merged } = mergeCache(incoming as any, held as any, FP); + + expect(sources(merged)).toEqual(["uidapi.com"]); + expect(getRefData(merged, "uidapi.com")).toEqual(kept); + }); + + it("lets a third-party revocation drop a held first-party EID", () => { + const held = cache([eid("uidapi.com", { matcher: "acme.com" })], { refs: { "uidapi.com": ref() } }); + const incoming = cache([{ source: "uidapi.com", matcher: "3p.example", uids: [] }]); + + const { merged } = mergeCache(incoming as any, held as any, FP); + + expect(sources(merged)).toEqual([]); + expect(getRefData(merged, "uidapi.com")).toBeNull(); + }); + + it("lets an unlabelled revocation drop a held first-party EID", () => { + const held = cache([eid("id5-sync.com", { matcher: "acme.com" })]); + const incoming = cache([{ source: "id5-sync.com", uids: [] }]); + + const { merged } = mergeCache(incoming as any, held as any, FP); + + expect(sources(merged)).toEqual([]); + }); + + it("without the option, any revocation still drops the held EID", () => { + const held = cache([eid("uidapi.com", { matcher: "3p.example" })], { refs: { "uidapi.com": ref() } }); + const incoming = cache([{ source: "uidapi.com", matcher: "acme.com", uids: [] }]); + + const { merged } = mergeCache(incoming as any, held as any); + + expect(sources(merged)).toEqual([]); + expect(getRefData(merged, "uidapi.com")).toBeNull(); + }); +}); diff --git a/lib/core/eid-cache.ts b/lib/core/eid-cache.ts index 7f5d8891..05ee001b 100644 --- a/lib/core/eid-cache.ts +++ b/lib/core/eid-cache.ts @@ -157,13 +157,10 @@ export function mergeCache( const eidMap = new Map(); const refs: Record = {}; - // A source sent with no uids is revoked: the old entry goes. - const revokedSources = new Set(newEids.filter((eid) => !eid.uids?.length).map((eid) => eid.source)); - - // Every other old EID is a candidate, contested sources included: the - // collision is settled below rather than by arrival order. + // Every old EID is a candidate, contested sources included: the collision is + // settled below rather than by arrival order. oldEids.forEach((eid) => { - if (!eid.uids?.length || revokedSources.has(eid.source)) return; + if (!eid.uids?.length) return; eidMap.set(eid.source, copyOf(eid)); const ref = getRefData(oldObj, eid.source); if (ref) { @@ -171,14 +168,19 @@ export function mergeCache( } }); - // New EIDs overwrite their source, refs included: a source re-resolved - // without one has its stale entry dropped. A first-party EID yields to a - // third-party one already held, which keeps its own refs. + // New EIDs take their source, refs included: a source re-resolved without one + // has its stale entry dropped, and one sent with no uids is revoked. A + // first-party EID yields to a third-party one held, revocation included. newEids.forEach((eid) => { - if (!eid.uids?.length) return; const held = eidMap.get(eid.source); if (held && isFirstParty(eid) && !isFirstParty(held)) return; + if (!eid.uids?.length) { + eidMap.delete(eid.source); + delete refs[eid.source]; + return; + } + eidMap.set(eid.source, copyOf(eid)); const ref = getRefData(newObj, eid.source); if (ref) { From 01862130b78da3122f5705db8a2c0d6721b89248 Mon Sep 17 00:00:00 2001 From: Etienne Latendresse Date: Wed, 14 Oct 2026 16:30:16 -0400 Subject: [PATCH 3/3] eidCache: rename the option to nonOverridingMatchers, after what it does --- lib/core/eid-cache.md | 8 ++++---- lib/core/eid-cache.test.ts | 6 +++--- lib/core/eid-cache.ts | 12 ++++++------ 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/core/eid-cache.md b/lib/core/eid-cache.md index 70c76eaa..2460340e 100644 --- a/lib/core/eid-cache.md +++ b/lib/core/eid-cache.md @@ -30,15 +30,15 @@ So anything read back out of storage is ready to merge as-is, and anything comin - `ortb2.user.data` comes from the new response, falling back to the cached one. - The inputs are never mutated. The merged cache is built from copies. -## First-party preference +## Non-overriding matchers -`firstPartyMatchers` names the domains the edge labels first-party matches with. Set it and a first-party EID will not displace a third-party one already cached on that source, revocation included: a first-party response sending the source with no uids leaves the cached third-party EID alone. Anything else still wins, so a call keeps refreshing and revoking its own entries, and the kept EID keeps its refs. The edge labels each EID with `eid.matcher`, and an EID with no matcher counts as third-party. +`nonOverridingMatchers` lists matcher values that lose a contested source. An EID whose `eid.matcher` is listed will not displace one already cached on that source from a matcher that is not, revocation included: a listed matcher sending the source with no uids leaves the cached EID alone. Anything else still wins, so a call keeps refreshing and revoking its own entries, and the kept EID keeps its refs. An EID with no matcher is never listed, so it always wins. ```typescript -const { merged } = mergeCache(replaceCache(response), cached, { firstPartyMatchers: ["acme.com"] }); +const { merged } = mergeCache(replaceCache(response), cached, { nonOverridingMatchers: ["acme.com"] }); ``` -This is what lets a targeting call resolved with `skip_matchers: ["1p"]` run alongside a tokenize call: the two fill the same sources, and whichever lands second no longer overwrites the other. The decision is made on the label rather than on arrival order, so the calls can go out in parallel instead of tokenize waiting for targeting to come back empty. Leave it unset and every new EID wins its source, as before. +Listing a publisher's own domains is what lets a targeting call resolved with `skip_matchers: ["1p"]` run alongside a tokenize call: the two fill the same sources, and whichever lands second no longer overwrites the other. The decision is made on the label rather than on arrival order, so the calls can go out in parallel instead of tokenize waiting for targeting to come back empty. Leave it unset and every new EID wins its source, as before. - Cached EIDs are wire EIDs: refresh material never sits on them, so every consumer — RTD, `pubProvidedId`, anything else — can hand them to bidding as-is, with nothing to strip. diff --git a/lib/core/eid-cache.test.ts b/lib/core/eid-cache.test.ts index 10f34d08..df4ba0a6 100644 --- a/lib/core/eid-cache.test.ts +++ b/lib/core/eid-cache.test.ts @@ -255,8 +255,8 @@ describe("mergeCache", () => { }); }); -describe("mergeCache - firstPartyMatchers", () => { - const FP = { firstPartyMatchers: ["acme.com"] }; +describe("mergeCache - nonOverridingMatchers", () => { + const FP = { nonOverridingMatchers: ["acme.com"] }; const idOn = (merged: unknown, source: string) => (merged as any).ortb2?.user?.eids?.find((e: any) => e.source === source)?.uids?.[0]?.id; @@ -328,7 +328,7 @@ describe("mergeCache - firstPartyMatchers", () => { }); describe("mergeCache - first-party revocation", () => { - const FP = { firstPartyMatchers: ["acme.com"] }; + const FP = { nonOverridingMatchers: ["acme.com"] }; const sources = (merged: unknown) => (merged as any).ortb2?.user?.eids?.map((e: any) => e.source); it("does not let a first-party revocation drop a held third-party EID", () => { diff --git a/lib/core/eid-cache.ts b/lib/core/eid-cache.ts index 05ee001b..7a1e9f13 100644 --- a/lib/core/eid-cache.ts +++ b/lib/core/eid-cache.ts @@ -132,20 +132,20 @@ export function replaceCache(response: T): T { * writes through replaceCache; a wire response goes through replaceCache * first. * - * With firstPartyMatchers set, a first-party EID never displaces a third-party - * one already held on that source. Unset, every new EID wins its source. + * An EID whose matcher is in nonOverridingMatchers never displaces one held on + * that source from a matcher that is not. Unset, every new EID wins its source. */ export function mergeCache( newObj: ResolvedCache | null | undefined, oldObj: ResolvedCache | null | undefined, - options?: { maxUidsPerEid?: number; firstPartyMatchers?: string[] } + options?: { maxUidsPerEid?: number; nonOverridingMatchers?: string[] } ): { merged: ResolvedCache; staleUid2s: StaleUid2[] } { const oldEids = oldObj?.ortb2?.user?.eids || []; const newEids = newObj?.ortb2?.user?.eids || []; const maxUids = options?.maxUidsPerEid ?? DEFAULT_MAX_UIDS_PER_EID; - const firstPartyMatchers = new Set(options?.firstPartyMatchers ?? []); - const isFirstParty = (eid: CachedEid): boolean => !!eid.matcher && firstPartyMatchers.has(eid.matcher); + const nonOverriding = new Set(options?.nonOverridingMatchers ?? []); + const yieldsToHeld = (eid: CachedEid): boolean => !!eid.matcher && nonOverriding.has(eid.matcher); // Copies are wire-clean: capped uids, and the ref pointer into the response // refs map is dropped since the sidecar replaces it. @@ -173,7 +173,7 @@ export function mergeCache( // first-party EID yields to a third-party one held, revocation included. newEids.forEach((eid) => { const held = eidMap.get(eid.source); - if (held && isFirstParty(eid) && !isFirstParty(held)) return; + if (held && yieldsToHeld(eid) && !yieldsToHeld(held)) return; if (!eid.uids?.length) { eidMap.delete(eid.source);