Add first-party verdict and guards to tracker mapping
The tracker-pattern catalog was binary (linked to a vendor or not), so generic and first-party artifacts (loglevel keys, wallet-extension keys, an org's own trackers) were retried forever and, once one row was wrongly attributed, re-propagated to every organization with no re-check. Give catalog rows a terminal attribution verdict (UNDETERMINED, THIRD_PARTY, FIRST_PARTY): FIRST_PARTY short-circuits the whole mapping pipeline so the artifact is never attributed again. Gate deterministic vendor adoption behind a trust bar so only curated/operator rows auto-propagate; lower-confidence agent/heuristic rows are reused as hints and re-resolved, and an independent agent re-confirmation corroborates and promotes them. Make the mapping agent emit an evidence source and reject any attribution that lacks concrete evidence, and let it declare a first-party verdict. Skip the speculative agent for PRE_EXISTING-source patterns, whose low signal invites invented vendors. Add proboctl "ctp mark-first-party" and an --attribution list filter to audit and remediate existing wrong links, and a cursor rule documenting migration naming so the timestamp is taken from date -u, not invented. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
@@ -9,10 +9,12 @@ Return a structured JSON response with:
|
||||
- third_party_name: the canonical company/service name (e.g. "Google Analytics", not "google" or "GA")
|
||||
- category: the business category of the third party
|
||||
- third_party_confidence: how confident you are about WHICH company or service set this tracker (0.0 to 1.0). This is the confidence in the attribution alone — not a judgment of whether the artifact is a "real" web tracker. A browser-extension artifact whose name names its vendor can still warrant high confidence here.
|
||||
- evidence_source: the concrete evidence that backs the attribution — one of "database_match" (an exact pattern match in the internal database), "naming_convention" (the tracker's meaningful prefix or an embedded vendor name), "web_search" (a web result that names the setter), "browser_page" (a page you opened that names the setter), or "none" (no concrete evidence). It MUST be "none" whenever third_party_name is empty, and it MUST be a concrete kind (not "none") whenever you name a vendor — an attribution with evidence_source "none" is discarded.
|
||||
- is_first_party: true when the artifact has no third party at all — it is the scanned site's own tracker, a generic library or log key (e.g. "loglevel", "debug"), a browser-extension key that embeds the scanned site's origin, or otherwise not attributable to any external vendor. Leave it false whenever a vendor is or might be responsible.
|
||||
</task>
|
||||
|
||||
<instructions>
|
||||
1. First use the search_tracker_patterns tool to look for similar known patterns in the database. Strip variable parts (IDs, UUIDs, timestamps) from the pattern name and search for the fixed prefix or root (e.g. for "_gat_UA-12345678-1", search for "_gat").
|
||||
1. First use the search_tracker_patterns tool to look for similar known patterns in the database. Strip variable parts (IDs, UUIDs, timestamps) from the pattern name and search for the fixed prefix or root (e.g. for "_gat_UA-12345678-1", search for "_gat"). Weigh the results by their confidence: a high-confidence match (a curated/seed entry) is authoritative, but a lower-confidence match is only a hint from an earlier automatic guess — corroborate it with the naming convention or a web/browser result before relying on it, and never treat it as proof on its own. Only count this as evidence_source "database_match" for an exact, high-confidence match on the same pattern.
|
||||
|
||||
2. If search_tracker_patterns returns results with a third party name, use the search_third_parties tool to confirm the exact name in the database and get its category.
|
||||
|
||||
@@ -56,6 +58,10 @@ Return a structured JSON response with:
|
||||
|
||||
10. Only attribute a tracker to a company or service when you have concrete evidence: an exact (perfect) match on the pattern in the database, an unmistakable naming convention where the tracker's meaningful prefix belongs to that vendor (including a vendor name embedded in the key), or a clear web search result whose tracker name shares that meaningful prefix. Absent a shared meaningful prefix or a perfect pattern match, do NOT imagine a vendor — never guess or invent attributions based on vague similarity, a shared generic word, or general knowledge. If no evidence supports a match, return an empty third_party_name with third_party_confidence below 0.3.
|
||||
|
||||
11. For the category field, use one of: {{.Categories}}.
|
||||
11. Always set evidence_source to the kind of evidence you actually used for the attribution, and only to a concrete kind ("database_match", "naming_convention", "web_search", or "browser_page") when that evidence genuinely exists. If you are tempted to name a vendor from general knowledge or a vague resemblance with no concrete evidence, set evidence_source to "none" and leave third_party_name empty instead — an attribution carrying evidence_source "none" is rejected.
|
||||
|
||||
12. Set is_first_party to true when the artifact plainly has no external vendor behind it. This covers: the scanned site's own trackers; generic developer/library keys that any application can write (e.g. "loglevel", "debug", "redux", framework-internal keys); a browser-extension key that embeds the scanned site's own origin (e.g. "ethereum-https://<scanned_site>"); and any key whose only plausible owner is the first party. A true value is a terminal verdict — the tracker will never be re-examined for a vendor — so only set it when you are confident no third party is responsible; when a vendor is or might be responsible, leave it false and attribute (or leave undetermined) instead.
|
||||
|
||||
13. For the category field, use one of: {{.Categories}}.
|
||||
Most cookies fall under ANALYTICS or MARKETING.
|
||||
</instructions>
|
||||
|
||||
@@ -62,17 +62,59 @@ const (
|
||||
// rather than the pattern, so the stored row confidence is a
|
||||
// constant like the other heuristic signals (domain, sibling).
|
||||
agentSourceConfidence = 0.8
|
||||
|
||||
// trustedAttributionConfidence is the bar a catalog row must meet for
|
||||
// its third party to be adopted deterministically by another pattern
|
||||
// (the existing-link and matchByPattern paths). Only curated/seed rows
|
||||
// and operator links (confidence 1.0) clear it; agent (0.8) and
|
||||
// domain/sibling (0.7) attributions do not, so a single low-confidence
|
||||
// guess never becomes an authoritative precedent that auto-propagates
|
||||
// across organizations. Such rows are reused as hints only: the
|
||||
// pattern falls through to the evidence-guarded agent, which can
|
||||
// corroborate the guess (promoting the row to this tier) or override
|
||||
// it.
|
||||
trustedAttributionConfidence float32 = 0.9
|
||||
)
|
||||
|
||||
//go:embed prompts/tracker_identification.txt.tmpl
|
||||
var trackerIdentificationPrompt string
|
||||
|
||||
// Tracker-mapping evidence kinds. The agent must report which concrete
|
||||
// evidence backs a vendor attribution; an attribution without one of the
|
||||
// substantive kinds (i.e. "none" or empty) is discarded so the agent
|
||||
// never attributes a vendor from general knowledge or vague similarity.
|
||||
const (
|
||||
evidenceSourceDatabaseMatch = "database_match"
|
||||
evidenceSourceNamingConvention = "naming_convention"
|
||||
evidenceSourceWebSearch = "web_search"
|
||||
evidenceSourceBrowserPage = "browser_page"
|
||||
evidenceSourceNone = "none"
|
||||
)
|
||||
|
||||
// TrackerMappingAgentResult is the structured output the tracker-mapping
|
||||
// agent returns.
|
||||
type TrackerMappingAgentResult struct {
|
||||
ThirdPartyName string `json:"third_party_name" jsonschema:"Name of the company or service that sets this tracker (e.g. 'Google Analytics', 'Meta Pixel'). Empty string if truly unknown."`
|
||||
Category coredata.ThirdPartyCategory `json:"category" jsonschema:"Third party category"`
|
||||
ThirdPartyConfidence float64 `json:"third_party_confidence" jsonschema:"Confidence (0.0 to 1.0) in which company or service set this tracker, independent of whether the artifact is a classic web tracker. Set below 0.5 if unsure who set it."`
|
||||
EvidenceSource string `json:"evidence_source" jsonschema:"The concrete evidence that backs the attribution: 'database_match' (exact pattern in the database), 'naming_convention' (the tracker's meaningful prefix or an embedded vendor name), 'web_search' (a web result naming the setter), 'browser_page' (a page you opened that names the setter), or 'none' when there is no concrete evidence. Must be 'none' whenever third_party_name is empty."`
|
||||
IsFirstParty bool `json:"is_first_party" jsonschema:"True when the artifact has no third party at all: it is the scanned site's own tracker, a generic library or log key (e.g. 'loglevel'), a browser-extension key that embeds the scanned site's origin, or otherwise not attributable to any external vendor. Leave false when a vendor is or might be responsible."`
|
||||
}
|
||||
|
||||
// evidenceSupportsAttribution reports whether the agent supplied a
|
||||
// concrete evidence kind for a vendor attribution. An empty value or
|
||||
// "none" (or any unrecognized value) does not support an attribution.
|
||||
func evidenceSupportsAttribution(evidenceSource string) bool {
|
||||
switch evidenceSource {
|
||||
case
|
||||
evidenceSourceDatabaseMatch,
|
||||
evidenceSourceNamingConvention,
|
||||
evidenceSourceWebSearch,
|
||||
evidenceSourceBrowserPage:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// buildTrackerMappingAgent builds the tracker-mapping agent. extraTools
|
||||
|
||||
@@ -15,11 +15,14 @@
|
||||
package cookiebanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
func TestNameMatchesSiteDomain(t *testing.T) {
|
||||
@@ -130,6 +133,204 @@ func TestNameIsCookieDatabaseAggregator(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvidenceSupportsAttribution(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
evidence string
|
||||
expected bool
|
||||
}{
|
||||
{name: "database match supports", evidence: evidenceSourceDatabaseMatch, expected: true},
|
||||
{name: "naming convention supports", evidence: evidenceSourceNamingConvention, expected: true},
|
||||
{name: "web search supports", evidence: evidenceSourceWebSearch, expected: true},
|
||||
{name: "browser page supports", evidence: evidenceSourceBrowserPage, expected: true},
|
||||
{name: "none does not support", evidence: evidenceSourceNone, expected: false},
|
||||
{name: "empty does not support", evidence: "", expected: false},
|
||||
{name: "unknown value does not support", evidence: "vibes", expected: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(
|
||||
tt.name,
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, tt.expected, evidenceSupportsAttribution(tt.evidence))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterpretCatalogRow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
vendorID := gid.New(gid.NilTenant, coredata.CommonThirdPartyEntityType)
|
||||
|
||||
t.Run(
|
||||
"first party verdict is terminal",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adopt, untrusted, firstParty := interpretCatalogRow(coredata.CommonTrackerPattern{
|
||||
CommonThirdPartyID: &vendorID,
|
||||
Confidence: 1,
|
||||
Attribution: coredata.CommonTrackerPatternAttributionFirstParty,
|
||||
})
|
||||
|
||||
assert.Nil(t, adopt)
|
||||
assert.Nil(t, untrusted)
|
||||
assert.True(t, firstParty)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"trusted vendor is adopted",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adopt, untrusted, firstParty := interpretCatalogRow(coredata.CommonTrackerPattern{
|
||||
CommonThirdPartyID: &vendorID,
|
||||
Confidence: trustedAttributionConfidence,
|
||||
Attribution: coredata.CommonTrackerPatternAttributionThirdParty,
|
||||
})
|
||||
|
||||
require.NotNil(t, adopt)
|
||||
assert.Equal(t, vendorID, *adopt)
|
||||
assert.Nil(t, untrusted)
|
||||
assert.False(t, firstParty)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"low-confidence vendor is untrusted, not adopted",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adopt, untrusted, firstParty := interpretCatalogRow(coredata.CommonTrackerPattern{
|
||||
CommonThirdPartyID: &vendorID,
|
||||
Confidence: agentSourceConfidence,
|
||||
Attribution: coredata.CommonTrackerPatternAttributionThirdParty,
|
||||
})
|
||||
|
||||
assert.Nil(t, adopt)
|
||||
require.NotNil(t, untrusted)
|
||||
assert.Equal(t, vendorID, *untrusted)
|
||||
assert.False(t, firstParty)
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"unlinked row yields nothing",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adopt, untrusted, firstParty := interpretCatalogRow(coredata.CommonTrackerPattern{
|
||||
Confidence: 0.5,
|
||||
Attribution: coredata.CommonTrackerPatternAttributionUndetermined,
|
||||
})
|
||||
|
||||
assert.Nil(t, adopt)
|
||||
assert.Nil(t, untrusted)
|
||||
assert.False(t, firstParty)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestIsPreExistingSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
preExisting := coredata.CookieSourcePreExisting
|
||||
script := coredata.CookieSourceScript
|
||||
|
||||
assert.True(t, isPreExistingSource(coredata.TrackerPattern{Source: &preExisting}))
|
||||
assert.False(t, isPreExistingSource(coredata.TrackerPattern{Source: &script}))
|
||||
assert.False(t, isPreExistingSource(coredata.TrackerPattern{Source: nil}))
|
||||
}
|
||||
|
||||
func TestVendorAttributionRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := newMappingHandler(nil)
|
||||
ctx := context.Background()
|
||||
tp := coredata.TrackerPattern{Pattern: "_x", TrackerType: coredata.TrackerTypeCookie}
|
||||
|
||||
confident := func(mut func(*TrackerMappingAgentResult)) TrackerMappingAgentResult {
|
||||
r := TrackerMappingAgentResult{
|
||||
ThirdPartyName: "Acme Analytics",
|
||||
Category: coredata.ThirdPartyCategoryAnalytics,
|
||||
ThirdPartyConfidence: 0.9,
|
||||
EvidenceSource: evidenceSourceNamingConvention,
|
||||
}
|
||||
if mut != nil {
|
||||
mut(&r)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
t.Run(
|
||||
"accepts a confident, evidence-backed attribution",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.False(t, h.vendorAttributionRejected(ctx, tp, confident(nil), "https://example.com"))
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"rejects below confidence threshold",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := confident(func(r *TrackerMappingAgentResult) { r.ThirdPartyConfidence = 0.3 })
|
||||
assert.True(t, h.vendorAttributionRejected(ctx, tp, r, "https://example.com"))
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"rejects empty name",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := confident(func(r *TrackerMappingAgentResult) { r.ThirdPartyName = "" })
|
||||
assert.True(t, h.vendorAttributionRejected(ctx, tp, r, "https://example.com"))
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"rejects when evidence source is none",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := confident(func(r *TrackerMappingAgentResult) { r.EvidenceSource = evidenceSourceNone })
|
||||
assert.True(t, h.vendorAttributionRejected(ctx, tp, r, "https://example.com"))
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"rejects when evidence source is empty",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := confident(func(r *TrackerMappingAgentResult) { r.EvidenceSource = "" })
|
||||
assert.True(t, h.vendorAttributionRejected(ctx, tp, r, "https://example.com"))
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"rejects when name matches scanned site",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := confident(func(r *TrackerMappingAgentResult) { r.ThirdPartyName = "Example" })
|
||||
assert.True(t, h.vendorAttributionRejected(ctx, tp, r, "https://example.com"))
|
||||
},
|
||||
)
|
||||
|
||||
t.Run(
|
||||
"rejects cookie-database aggregator",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := confident(func(r *TrackerMappingAgentResult) { r.ThirdPartyName = "Cookiepedia" })
|
||||
assert.True(t, h.vendorAttributionRejected(ctx, tp, r, "https://example.com"))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestBuildAgentPrompt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -136,12 +136,40 @@ func (h *trackerMappingHandler) RecoverStale(ctx context.Context) error {
|
||||
// is the catalog row the signal resolved (or backfilled); commonThirdPartyID
|
||||
// is the catalog third party the signal discovered, when any; thirdPartyID
|
||||
// is an existing org ThirdParty the signal knows directly (e.g. a sibling
|
||||
// pattern already promoted in the same organization). A nil *catalogMatch
|
||||
// means the signal produced nothing.
|
||||
// pattern already promoted in the same organization). firstParty is set
|
||||
// when the resolved catalog row carries the terminal FIRST_PARTY verdict.
|
||||
// untrustedThirdPartyID carries a vendor that was present on the resolved
|
||||
// row but not adopted because its confidence fell below
|
||||
// trustedAttributionConfidence; it lets the agent corroborate the prior
|
||||
// guess. A nil *catalogMatch means the signal produced nothing.
|
||||
type catalogMatch struct {
|
||||
commonPatternID *gid.GID
|
||||
commonThirdPartyID *gid.GID
|
||||
thirdPartyID *gid.GID
|
||||
commonPatternID *gid.GID
|
||||
commonThirdPartyID *gid.GID
|
||||
thirdPartyID *gid.GID
|
||||
untrustedThirdPartyID *gid.GID
|
||||
firstParty bool
|
||||
}
|
||||
|
||||
// interpretCatalogRow maps a resolved catalog row onto the mapping
|
||||
// pipeline's adoption rules. A FIRST_PARTY row is terminal. A vendor is
|
||||
// adopted only when the row clears trustedAttributionConfidence;
|
||||
// otherwise the vendor is surfaced as untrusted so the agent can
|
||||
// corroborate it rather than the pipeline inheriting a low-confidence
|
||||
// precedent.
|
||||
func interpretCatalogRow(cp coredata.CommonTrackerPattern) (adopt *gid.GID, untrusted *gid.GID, firstParty bool) {
|
||||
if cp.Attribution == coredata.CommonTrackerPatternAttributionFirstParty {
|
||||
return nil, nil, true
|
||||
}
|
||||
|
||||
if cp.CommonThirdPartyID == nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
if cp.Confidence >= trustedAttributionConfidence {
|
||||
return cp.CommonThirdPartyID, nil, false
|
||||
}
|
||||
|
||||
return nil, cp.CommonThirdPartyID, false
|
||||
}
|
||||
|
||||
// Process resolves the catalog mapping for a tracker pattern and links it
|
||||
@@ -191,8 +219,15 @@ func (h *trackerMappingHandler) Process(ctx context.Context, tp coredata.Tracker
|
||||
// Phase 2: tracker-mapping agent (no transaction). It runs only when
|
||||
// the deterministic signals could not resolve a catalog third party.
|
||||
// The LLM and web-search calls happen outside any transaction; the
|
||||
// result is persisted in its own short transaction.
|
||||
if commonThirdPartyID == nil && h.mappingEnabled {
|
||||
// result is persisted in its own short transaction. Patterns whose
|
||||
// source is PRE_EXISTING are skipped: that source is the low-signal
|
||||
// catch-all (storage enumerated at SDK init, which bundles extension
|
||||
// state and prior-session artifacts), so a speculative agent run on it
|
||||
// is more likely to invent a vendor than to find a real one. The
|
||||
// deterministic catalog match still applies above, so a known cookie
|
||||
// still maps; and a later SCRIPT/EXTENSION detection upgrades the
|
||||
// source and re-arms mapping, giving the agent a better-grounded run.
|
||||
if commonThirdPartyID == nil && h.mappingEnabled && !det.firstParty && !isPreExistingSource(tp) {
|
||||
ident, err := h.identifyWithAgent(ctx, tp, det.origin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot identify with agent: %w", err)
|
||||
@@ -202,7 +237,13 @@ func (h *trackerMappingHandler) Process(ctx context.Context, tp coredata.Tracker
|
||||
if err := h.pg.WithTx(
|
||||
ctx,
|
||||
func(ctx context.Context, tx pg.Tx) error {
|
||||
match, err := h.persistAgentIdentification(ctx, tx, tp, *ident)
|
||||
var match *catalogMatch
|
||||
|
||||
if ident.firstParty {
|
||||
match, err = h.persistFirstPartyVerdict(ctx, tx, tp)
|
||||
} else {
|
||||
match, err = h.persistAgentIdentification(ctx, tx, tp, *ident, det.untrustedThirdPartyID)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -324,8 +365,10 @@ type deterministicResult struct {
|
||||
commonPatternID *gid.GID
|
||||
commonThirdPartyID *gid.GID
|
||||
directThirdPartyID *gid.GID
|
||||
untrustedThirdPartyID *gid.GID
|
||||
domains []string
|
||||
commonThirdPartyPreexisted bool
|
||||
firstParty bool
|
||||
}
|
||||
|
||||
// resolveDeterministic runs the catalog signals that need no network
|
||||
@@ -357,7 +400,7 @@ func (h *trackerMappingHandler) resolveDeterministic(
|
||||
return res, fmt.Errorf("cannot load linked common tracker pattern: %w", err)
|
||||
}
|
||||
|
||||
res.commonThirdPartyID = commonPattern.CommonThirdPartyID
|
||||
res.commonThirdPartyID, res.untrustedThirdPartyID, res.firstParty = interpretCatalogRow(commonPattern)
|
||||
} else {
|
||||
match, err := h.matchByPattern(ctx, tx, tp)
|
||||
if err != nil {
|
||||
@@ -367,9 +410,18 @@ func (h *trackerMappingHandler) resolveDeterministic(
|
||||
if match != nil {
|
||||
res.commonPatternID = match.commonPatternID
|
||||
res.commonThirdPartyID = match.commonThirdPartyID
|
||||
res.untrustedThirdPartyID = match.untrustedThirdPartyID
|
||||
res.firstParty = match.firstParty
|
||||
}
|
||||
}
|
||||
|
||||
// A terminal FIRST_PARTY verdict short-circuits every remaining
|
||||
// signal: the artifact has no third party, so neither the heuristic
|
||||
// matches nor the agent should run, and no org party is linked.
|
||||
if res.firstParty {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
res.commonThirdPartyPreexisted = res.commonThirdPartyID != nil
|
||||
|
||||
if res.commonThirdPartyID != nil {
|
||||
@@ -467,6 +519,15 @@ func (h *trackerMappingHandler) reenqueueUnmappedSiblings(
|
||||
return nil
|
||||
}
|
||||
|
||||
// isPreExistingSource reports whether the org tracker pattern's source is
|
||||
// PRE_EXISTING. That source is the low-signal catch-all enumerated from
|
||||
// storage at SDK init (it bundles browser-extension state and
|
||||
// prior-session artifacts), so the speculative mapping agent is not run
|
||||
// for it; the deterministic catalog signals still apply.
|
||||
func isPreExistingSource(tp coredata.TrackerPattern) bool {
|
||||
return tp.Source != nil && *tp.Source == coredata.CookieSourcePreExisting
|
||||
}
|
||||
|
||||
// firstNonNil returns a when it is set, otherwise b. It keeps the first
|
||||
// catalog row id resolved by the pipeline stable: later signals upsert
|
||||
// the same row (same key) and return the same id, but the explicit guard
|
||||
@@ -518,9 +579,13 @@ func (h *trackerMappingHandler) matchByPattern(
|
||||
return nil, fmt.Errorf("cannot load common tracker pattern: %w", err)
|
||||
}
|
||||
|
||||
adopt, untrusted, firstParty := interpretCatalogRow(commonPattern)
|
||||
|
||||
return &catalogMatch{
|
||||
commonPatternID: &commonPattern.ID,
|
||||
commonThirdPartyID: commonPattern.CommonThirdPartyID,
|
||||
commonPatternID: &commonPattern.ID,
|
||||
commonThirdPartyID: adopt,
|
||||
untrustedThirdPartyID: untrusted,
|
||||
firstParty: firstParty,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -567,6 +632,7 @@ func (h *trackerMappingHandler) matchByDomain(
|
||||
MatchType: tp.MatchType,
|
||||
MaxAgeSeconds: tp.MaxAgeSeconds,
|
||||
Confidence: 0.7,
|
||||
Attribution: coredata.CommonTrackerPatternAttributionThirdParty,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
@@ -581,10 +647,14 @@ func (h *trackerMappingHandler) matchByDomain(
|
||||
}, nil
|
||||
}
|
||||
|
||||
// agentIdentification carries a confident tracker-mapping agent result
|
||||
// from the no-tx agent phase to the short transaction that persists it.
|
||||
// agentIdentification carries a tracker-mapping agent verdict from the
|
||||
// no-tx agent phase to the short transaction that persists it. Exactly
|
||||
// one outcome is meaningful: firstParty set means the agent declared a
|
||||
// terminal no-third-party verdict; otherwise result holds a defensible
|
||||
// vendor attribution.
|
||||
type agentIdentification struct {
|
||||
result TrackerMappingAgentResult
|
||||
result TrackerMappingAgentResult
|
||||
firstParty bool
|
||||
}
|
||||
|
||||
// identifyWithAgent runs the tracker-mapping agent outside any
|
||||
@@ -664,10 +734,43 @@ func (h *trackerMappingHandler) identifyWithAgent(
|
||||
|
||||
identification := result.Output
|
||||
|
||||
// A defensible vendor attribution wins: record it for the catalog.
|
||||
if !h.vendorAttributionRejected(ctx, tp, identification, siteOrigin) {
|
||||
return &agentIdentification{result: identification}, nil
|
||||
}
|
||||
|
||||
// No defensible vendor. An explicit first-party declaration is a
|
||||
// terminal verdict: persist it so the pipeline stops retrying this
|
||||
// artifact. Otherwise leave the pattern undetermined for a later,
|
||||
// better-informed attempt (the unmatched fallback records it with no
|
||||
// third party).
|
||||
if identification.IsFirstParty {
|
||||
h.logger.InfoCtx(
|
||||
ctx,
|
||||
"agent declared tracker first-party",
|
||||
log.String("pattern", tp.Pattern),
|
||||
)
|
||||
|
||||
return &agentIdentification{firstParty: true}, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// vendorAttributionRejected reports whether the agent's vendor
|
||||
// attribution must be discarded, logging the reason. It enforces, in
|
||||
// order: a confident attribution, a concrete evidence source (no
|
||||
// general-knowledge guesses), the scanned-site backstop, and the
|
||||
// cookie-database-aggregator backstop.
|
||||
func (h *trackerMappingHandler) vendorAttributionRejected(
|
||||
ctx context.Context,
|
||||
tp coredata.TrackerPattern,
|
||||
identification TrackerMappingAgentResult,
|
||||
siteOrigin string,
|
||||
) bool {
|
||||
// The agent's confidence gauges the attribution (who set the
|
||||
// tracker), not whether the artifact is a meaningful tracker. Without
|
||||
// a confident vendor there is nothing to catalog here; the unmatched
|
||||
// fallback records the pattern with no third party instead.
|
||||
// a confident vendor there is nothing to catalog here.
|
||||
if identification.ThirdPartyName == "" || identification.ThirdPartyConfidence < agentThirdPartyConfidenceThreshold {
|
||||
h.logger.InfoCtx(
|
||||
ctx,
|
||||
@@ -676,7 +779,23 @@ func (h *trackerMappingHandler) identifyWithAgent(
|
||||
log.Float64("third_party_confidence", identification.ThirdPartyConfidence),
|
||||
)
|
||||
|
||||
return nil, nil
|
||||
return true
|
||||
}
|
||||
|
||||
// Evidence guard: a vendor is attributed only on concrete evidence (a
|
||||
// database match, a meaningful naming convention, or a web/browser
|
||||
// result that names the setter). An attribution with no evidence
|
||||
// source is a general-knowledge guess and is discarded, so a wrong
|
||||
// precedent never enters the catalog.
|
||||
if !evidenceSupportsAttribution(identification.EvidenceSource) {
|
||||
h.logger.InfoCtx(
|
||||
ctx,
|
||||
"agent attribution lacks concrete evidence, discarding",
|
||||
log.String("pattern", tp.Pattern),
|
||||
log.String("evidence_source", identification.EvidenceSource),
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Backstop for the prompt rule that the scanned site is never a third
|
||||
@@ -693,7 +812,7 @@ func (h *trackerMappingHandler) identifyWithAgent(
|
||||
log.String("pattern", tp.Pattern),
|
||||
)
|
||||
|
||||
return nil, nil
|
||||
return true
|
||||
}
|
||||
|
||||
// Cookie-database and cookie-banner directory sites (Cookifi,
|
||||
@@ -712,12 +831,10 @@ func (h *trackerMappingHandler) identifyWithAgent(
|
||||
log.String("pattern", tp.Pattern),
|
||||
)
|
||||
|
||||
return nil, nil
|
||||
return true
|
||||
}
|
||||
|
||||
return &agentIdentification{
|
||||
result: identification,
|
||||
}, nil
|
||||
return false
|
||||
}
|
||||
|
||||
// nameMatchesSiteDomain reports whether a candidate vendor name refers to
|
||||
@@ -788,11 +905,18 @@ func nameIsCookieDatabaseAggregator(name string) bool {
|
||||
// it resolves or creates the catalog third party and upserts the
|
||||
// catalog pattern row that links to it. It runs inside the caller's
|
||||
// short transaction.
|
||||
//
|
||||
// priorUntrustedThirdPartyID, when set, is the vendor an existing
|
||||
// catalog row carried but that was too low-confidence to adopt
|
||||
// deterministically. When the agent independently lands on the same
|
||||
// vendor, that is corroboration: the row is promoted to the trusted tier
|
||||
// so subsequent patterns adopt it without re-running the agent.
|
||||
func (h *trackerMappingHandler) persistAgentIdentification(
|
||||
ctx context.Context,
|
||||
tx pg.Tx,
|
||||
tp coredata.TrackerPattern,
|
||||
ident agentIdentification,
|
||||
priorUntrustedThirdPartyID *gid.GID,
|
||||
) (*catalogMatch, error) {
|
||||
commonThirdPartyID, err := thirdparty.ResolveOrCreateCommonThirdParty(
|
||||
ctx,
|
||||
@@ -805,6 +929,15 @@ func (h *trackerMappingHandler) persistAgentIdentification(
|
||||
return nil, fmt.Errorf("cannot resolve or create common third party: %w", err)
|
||||
}
|
||||
|
||||
confidence := float32(agentSourceConfidence)
|
||||
|
||||
corroborated := priorUntrustedThirdPartyID != nil &&
|
||||
commonThirdPartyID != nil &&
|
||||
*priorUntrustedThirdPartyID == *commonThirdPartyID
|
||||
if corroborated {
|
||||
confidence = trustedAttributionConfidence
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
commonPattern := coredata.CommonTrackerPattern{
|
||||
ID: gid.New(gid.NilTenant, coredata.CommonTrackerPatternEntityType),
|
||||
@@ -813,7 +946,8 @@ func (h *trackerMappingHandler) persistAgentIdentification(
|
||||
Pattern: tp.Pattern,
|
||||
MatchType: tp.MatchType,
|
||||
MaxAgeSeconds: tp.MaxAgeSeconds,
|
||||
Confidence: agentSourceConfidence,
|
||||
Confidence: confidence,
|
||||
Attribution: coredata.CommonTrackerPatternAttributionThirdParty,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
@@ -828,6 +962,7 @@ func (h *trackerMappingHandler) persistAgentIdentification(
|
||||
log.String("pattern", tp.Pattern),
|
||||
log.String("third_party", ident.result.ThirdPartyName),
|
||||
log.Float64("third_party_confidence", ident.result.ThirdPartyConfidence),
|
||||
log.Bool("corroborated_prior_attribution", corroborated),
|
||||
)
|
||||
|
||||
return &catalogMatch{
|
||||
@@ -836,6 +971,46 @@ func (h *trackerMappingHandler) persistAgentIdentification(
|
||||
}, nil
|
||||
}
|
||||
|
||||
// persistFirstPartyVerdict records the agent's terminal first-party
|
||||
// verdict on the catalog: it upserts the row with no vendor and the
|
||||
// FIRST_PARTY attribution, which the upsert preserves on later automated
|
||||
// runs. Any stray low-confidence vendor a prior run left on the row is
|
||||
// cleared. It runs inside the caller's short transaction.
|
||||
func (h *trackerMappingHandler) persistFirstPartyVerdict(
|
||||
ctx context.Context,
|
||||
tx pg.Tx,
|
||||
tp coredata.TrackerPattern,
|
||||
) (*catalogMatch, error) {
|
||||
now := time.Now()
|
||||
commonPattern := coredata.CommonTrackerPattern{
|
||||
ID: gid.New(gid.NilTenant, coredata.CommonTrackerPatternEntityType),
|
||||
TrackerType: tp.TrackerType,
|
||||
Pattern: tp.Pattern,
|
||||
MatchType: tp.MatchType,
|
||||
MaxAgeSeconds: tp.MaxAgeSeconds,
|
||||
Confidence: agentSourceConfidence,
|
||||
Attribution: coredata.CommonTrackerPatternAttributionFirstParty,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if _, err := commonPattern.Upsert(ctx, tx); err != nil {
|
||||
return nil, fmt.Errorf("cannot upsert first-party common tracker pattern: %w", err)
|
||||
}
|
||||
|
||||
h.logger.InfoCtx(
|
||||
ctx,
|
||||
"recorded first-party tracker verdict",
|
||||
log.String("pattern", tp.Pattern),
|
||||
log.String("tracker_pattern_id", tp.ID.String()),
|
||||
)
|
||||
|
||||
return &catalogMatch{
|
||||
commonPatternID: &commonPattern.ID,
|
||||
firstParty: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// matchBySiblingOrigin finds other tracker patterns on the same banner
|
||||
// that share initiator domains with the current pattern. Sharing an
|
||||
// origin across multiple detected patterns is a strong indicator of the
|
||||
@@ -898,6 +1073,7 @@ func (h *trackerMappingHandler) matchBySiblingOrigin(
|
||||
MatchType: tp.MatchType,
|
||||
MaxAgeSeconds: tp.MaxAgeSeconds,
|
||||
Confidence: 0.7,
|
||||
Attribution: coredata.CommonTrackerPatternAttributionThirdParty,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
@@ -1026,6 +1202,7 @@ func (h *trackerMappingHandler) createUnmatchedPattern(
|
||||
MatchType: tp.MatchType,
|
||||
MaxAgeSeconds: tp.MaxAgeSeconds,
|
||||
Confidence: 0.5,
|
||||
Attribution: coredata.CommonTrackerPatternAttributionUndetermined,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
@@ -1742,3 +1742,203 @@ func TestProcess_NoReenqueueWhenCommonThirdPartyPreexisted(t *testing.T) {
|
||||
|
||||
assert.Nil(t, reloadedUnmapped.MappingRequestedAt, "re-trigger with a pre-existing common third party must not re-enqueue siblings")
|
||||
}
|
||||
|
||||
// TestProcess_FirstPartyVerdictIsTerminal asserts that a pattern whose
|
||||
// matching catalog row carries the FIRST_PARTY verdict is linked to that
|
||||
// row but never attributed a third party: the heuristic signals and the
|
||||
// agent are short-circuited, leaving third_party_id unset.
|
||||
func TestProcess_FirstPartyVerdictIsTerminal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := test.PGClient(t)
|
||||
ctx := context.Background()
|
||||
fx := seedWorkerFixture(t, ctx, client)
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
patternName := "loglevel_" + fx.scope.GetTenantID().String()
|
||||
|
||||
firstPartyCommon := coredata.CommonTrackerPattern{
|
||||
ID: gid.New(gid.NilTenant, coredata.CommonTrackerPatternEntityType),
|
||||
TrackerType: coredata.TrackerTypeLocalStorage,
|
||||
Pattern: patternName,
|
||||
MatchType: coredata.TrackerPatternMatchTypeExact,
|
||||
Confidence: 0.8,
|
||||
Attribution: coredata.CommonTrackerPatternAttributionFirstParty,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
// An org pattern with no catalog link yet, so matchByPattern resolves
|
||||
// the FIRST_PARTY row by (tracker_type, pattern, max_age).
|
||||
target := coredata.TrackerPattern{
|
||||
ID: gid.New(fx.scope.GetTenantID(), coredata.TrackerPatternEntityType),
|
||||
OrganizationID: fx.organizationID,
|
||||
CookieBannerID: fx.banner.ID,
|
||||
CookieCategoryID: fx.normalCategoryID,
|
||||
TrackerType: coredata.TrackerTypeLocalStorage,
|
||||
Pattern: patternName,
|
||||
MatchType: coredata.TrackerPatternMatchTypeExact,
|
||||
DisplayName: patternName,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
if _, err := firstPartyCommon.Upsert(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := target.Insert(ctx, tx, fx.scope); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return target.SetMappingRequested(ctx, tx)
|
||||
}))
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = client.WithTx(context.Background(), func(ctx context.Context, tx pg.Tx) error {
|
||||
_, _ = tx.Exec(ctx, `DELETE FROM common_tracker_patterns WHERE id = $1`, firstPartyCommon.ID)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
|
||||
h := newMappingHandler(client)
|
||||
require.NoError(t, h.Process(ctx, target))
|
||||
|
||||
var reloaded coredata.TrackerPattern
|
||||
|
||||
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||
return reloaded.LoadByID(ctx, conn, fx.scope, target.ID)
|
||||
}))
|
||||
|
||||
require.NotNil(t, reloaded.CommonTrackerPatternID, "the pattern must be linked to the first-party catalog row for coverage")
|
||||
assert.Equal(t, firstPartyCommon.ID, *reloaded.CommonTrackerPatternID)
|
||||
assert.Nil(t, reloaded.ThirdPartyID, "a first-party verdict must never attribute a third party")
|
||||
|
||||
reloadedCommon := coredata.CommonTrackerPattern{}
|
||||
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||
return reloadedCommon.LoadByID(ctx, conn, firstPartyCommon.ID)
|
||||
}))
|
||||
|
||||
assert.Equal(t, coredata.CommonTrackerPatternAttributionFirstParty, reloadedCommon.Attribution, "verdict must remain first-party")
|
||||
assert.Nil(t, reloadedCommon.CommonThirdPartyID, "first-party row must stay vendor-free")
|
||||
}
|
||||
|
||||
// TestProcess_LowConfidenceCatalogVendorNotAdopted asserts that a catalog
|
||||
// row whose vendor was attributed below trustedAttributionConfidence is
|
||||
// not adopted deterministically: the pattern links to the row but is not
|
||||
// promoted to the vendor's org third party, so a single low-confidence
|
||||
// guess never auto-propagates across organizations.
|
||||
func TestProcess_LowConfidenceCatalogVendorNotAdopted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := test.PGClient(t)
|
||||
ctx := context.Background()
|
||||
fx := seedWorkerFixture(t, ctx, client)
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
suffix := fx.scope.GetTenantID().String()
|
||||
patternName := "lowconf_" + suffix
|
||||
|
||||
commonThirdPartyID := gid.New(gid.NilTenant, coredata.CommonThirdPartyEntityType)
|
||||
commonThirdParty := coredata.CommonThirdParty{
|
||||
ID: commonThirdPartyID,
|
||||
Name: "Acme " + suffix,
|
||||
Slug: "acme-lowconf-" + suffix,
|
||||
Category: coredata.ThirdPartyCategoryAnalytics,
|
||||
Certifications: []string{},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
// Agent-tier (0.8) attribution: below the 0.9 trusted bar.
|
||||
lowConfCommon := coredata.CommonTrackerPattern{
|
||||
ID: gid.New(gid.NilTenant, coredata.CommonTrackerPatternEntityType),
|
||||
CommonThirdPartyID: &commonThirdPartyID,
|
||||
TrackerType: coredata.TrackerTypeCookie,
|
||||
Pattern: patternName,
|
||||
MatchType: coredata.TrackerPatternMatchTypeExact,
|
||||
Confidence: agentSourceConfidence,
|
||||
Attribution: coredata.CommonTrackerPatternAttributionThirdParty,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
// An existing org party for the vendor, so promotion WOULD link if the
|
||||
// vendor were adopted.
|
||||
orgThirdParty := coredata.ThirdParty{
|
||||
ID: gid.New(fx.scope.GetTenantID(), coredata.ThirdPartyEntityType),
|
||||
OrganizationID: fx.organizationID,
|
||||
CommonThirdPartyID: &commonThirdPartyID,
|
||||
Name: "Acme LLC",
|
||||
Category: coredata.ThirdPartyCategoryAnalytics,
|
||||
Certifications: []string{},
|
||||
Countries: coredata.CountryCodes{},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
target := coredata.TrackerPattern{
|
||||
ID: gid.New(fx.scope.GetTenantID(), coredata.TrackerPatternEntityType),
|
||||
OrganizationID: fx.organizationID,
|
||||
CookieBannerID: fx.banner.ID,
|
||||
CookieCategoryID: fx.normalCategoryID,
|
||||
TrackerType: coredata.TrackerTypeCookie,
|
||||
Pattern: patternName,
|
||||
MatchType: coredata.TrackerPatternMatchTypeExact,
|
||||
DisplayName: patternName,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
if err := commonThirdParty.Insert(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := lowConfCommon.Upsert(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := orgThirdParty.Insert(ctx, tx, fx.scope); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := target.Insert(ctx, tx, fx.scope); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return target.SetMappingRequested(ctx, tx)
|
||||
}))
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = client.WithTx(context.Background(), func(ctx context.Context, tx pg.Tx) error {
|
||||
_, _ = tx.Exec(ctx, `DELETE FROM common_tracker_patterns WHERE id = $1`, lowConfCommon.ID)
|
||||
_, _ = tx.Exec(ctx, `DELETE FROM common_third_parties WHERE id = $1`, commonThirdPartyID)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
|
||||
h := newMappingHandler(client)
|
||||
require.NoError(t, h.Process(ctx, target))
|
||||
|
||||
var reloaded coredata.TrackerPattern
|
||||
|
||||
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||
return reloaded.LoadByID(ctx, conn, fx.scope, target.ID)
|
||||
}))
|
||||
|
||||
require.NotNil(t, reloaded.CommonTrackerPatternID, "the pattern must still be linked to the catalog row")
|
||||
assert.Equal(t, lowConfCommon.ID, *reloaded.CommonTrackerPatternID)
|
||||
assert.Nil(t, reloaded.ThirdPartyID, "a below-trust catalog vendor must not be adopted/promoted")
|
||||
|
||||
// The catalog row is untouched: its low-confidence vendor remains for
|
||||
// a later evidence-backed corroboration.
|
||||
reloadedCommon := coredata.CommonTrackerPattern{}
|
||||
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||
return reloadedCommon.LoadByID(ctx, conn, lowConfCommon.ID)
|
||||
}))
|
||||
|
||||
require.NotNil(t, reloadedCommon.CommonThirdPartyID, "the catalog vendor must be left in place")
|
||||
assert.Equal(t, commonThirdPartyID, *reloadedCommon.CommonThirdPartyID)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user