From 794447ef5d94828a645d9a94810accf0e5ac7348 Mon Sep 17 00:00:00 2001 From: "Ramin B." Date: Tue, 14 Jul 2026 14:03:45 -0400 Subject: [PATCH] fix(session): session exemption must not swallow a competing attribution param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 'session' self-exemption re-covers a host's ambient lingering signals (a first-party cookie, the initiator) so our own attribution keeps owning the session. But it also dropped ANY later same-network match, including a fresh landing-param / redirect-domain carrying someone else's click id. So after we self-exempt a host, a competitor's click back to that same host was suppressed and we took the sale (the #52 item 3 edge case). applySessionExemptions now keeps (does not suppress) a landing-param or redirect-domain match whose scope is not a self-match on the current navigation. Ambient signals (cookie, initiator) are still re-attributed to us, so the documented ignore_param case is unchanged. This only bites with value-specific self-patterns, which is the documented, correct way to author them. A name-only self-pattern already claims every value of that param as ours, so a competing value looks like a self-match and is suppressed — the same footgun the self-exemption docs already warn about. Verified the competing-click test fails without the change (suppressed) and passes with it; the existing cookie-based ignore_param tests are unchanged. Addresses item 3 of #52 (precise fix). Part of #52. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PXZmuMFkq8dE2e2KLXvitx --- src/session.ts | 40 ++++++++++++++++++-- tests/self-exemption-scope.test.ts | 61 ++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/src/session.ts b/src/session.ts index 87075d9..7b8c38a 100644 --- a/src/session.ts +++ b/src/session.ts @@ -469,8 +469,20 @@ function recordSessionExemptions( /** * Re-apply a host's persisted session exemptions: drop matched rules whose * policy/network was exempted for this host, then recompute the strongest match. - * `disabled-host` matches are never dropped — a hard-disabled host stands down - * regardless of any self-exemption. + * + * Two matches are never dropped, even when their scope is exempted: + * - `disabled-host` — a hard-disabled host stands down regardless of any + * self-exemption. + * - a *fresh competing attribution param* (`landing-param` / `redirect-domain`) + * that is not itself a self-match on this navigation. A persisted exemption + * means "our attribution owns this host's session", which is meant to re-cover + * ambient lingering signals (a first-party cookie, the initiator). It must not + * swallow a later click that carries *someone else's* attribution id for the + * same network, or a competitor could hijack an already-exempted host. This + * only bites when self-patterns are value-specific (the documented, correct + * way to author them); a name-only self-pattern already claims every value of + * that param as ours, so a competing value looks like a self-match and is + * suppressed — the same footgun the self-exemption docs already warn about. */ function applySessionExemptions( advertiserHost: string, @@ -485,6 +497,14 @@ function applySessionExemptions( const policyIds = new Set(record.policyIds); const networkIds = new Set(record.networkIds); + // Scopes for which THIS navigation carried our own (value-matched) attribution. + const selfPolicyIds = new Set( + detection.selfExemptScopes?.map((scope) => scope.policyId), + ); + const selfNetworkIds = new Set( + detection.selfExemptScopes?.map((scope) => scope.networkId), + ); + const filtered = detection.matched.filter((match) => { if (match.kind === 'disabled-host') { return true; @@ -494,7 +514,21 @@ function applySessionExemptions( return true; } - return !(policyIds.has(match.policyId) || networkIds.has(match.networkId)); + if (!(policyIds.has(match.policyId) || networkIds.has(match.networkId))) { + return true; + } + + const isAttributionParam = + match.kind === 'landing-param' || match.kind === 'redirect-domain'; + const isSelfThisNavigation = + selfPolicyIds.has(match.policyId) || selfNetworkIds.has(match.networkId); + + // Keep (do not suppress) a competing attribution param that isn't ours. + if (isAttributionParam && !isSelfThisNavigation) { + return true; + } + + return false; }); if (filtered.length === detection.matched.length) { diff --git a/tests/self-exemption-scope.test.ts b/tests/self-exemption-scope.test.ts index 51102f4..5ea7769 100644 --- a/tests/self-exemption-scope.test.ts +++ b/tests/self-exemption-scope.test.ts @@ -127,6 +127,67 @@ describe('selfExemptionScope', () => { ).resolves.toMatchObject({ standDown: true, policyId: 'beta' }); }); + it('session scope: a competing SAME-network click (different value) still stands down', async () => { + const session = new StanddownSession(new MemoryStateStore(), { + selfExemptionScope: 'session', + }); + // Value-specific self-pattern (the documented, correct way to author one). + const selfPatterns = [ + { name: 'alfa_click', value: 'me', match: 'equals' as const, networkId: 'alfa' }, + ]; + + // Our own click (value-matched) → exempt, records the host exemption. + await expect( + session.ingest( + { url: `${MERCHANT}/p?alfa_click=me`, now: 0, selfPatterns }, + [alfa], + ), + ).resolves.toMatchObject({ standDown: false }); + + // A competitor's alfa click on the same host carries a DIFFERENT value, so it + // is not ours. The persisted exemption must not swallow it. + await expect( + session.ingest( + { url: `${MERCHANT}/p?alfa_click=rival`, now: 1_000, selfPatterns }, + [alfa], + ), + ).resolves.toMatchObject({ standDown: true, policyId: 'alfa' }); + }); + + it('session scope: our own value keeps re-exempting, and the lingering cookie stays ours', async () => { + const session = new StanddownSession(new MemoryStateStore(), { + selfExemptionScope: 'session', + }); + const selfPatterns = [ + { name: 'alfa_click', value: 'me', match: 'equals' as const, networkId: 'alfa' }, + ]; + + await expect( + session.ingest( + { url: `${MERCHANT}/p?alfa_click=me`, now: 0, selfPatterns }, + [alfa], + ), + ).resolves.toMatchObject({ standDown: false }); + + // Our own click id re-appearing is a self-match, so it stays exempt (the fix + // does not over-correct and stand us down on our own attribution). + await expect( + session.ingest( + { url: `${MERCHANT}/p?alfa_click=me`, now: 1_000, selfPatterns }, + [alfa], + ), + ).resolves.toMatchObject({ standDown: false }); + + // The lingering first-party cookie is ambient, not a competing param, so it + // is still re-attributed to us — the ignore_param case is preserved. + await expect( + session.ingest( + { url: `${MERCHANT}/checkout`, now: 2_000, cookieNames: ['alfa_cookie'] }, + [alfa], + ), + ).resolves.toMatchObject({ standDown: false, reason: 'self-exempted-session' }); + }); + it('session scope never lifts an already-active stand-down (monotone)', async () => { const store = new MemoryStateStore(); const session = new StanddownSession(store, {