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:
@@ -30,20 +30,21 @@ import (
|
||||
|
||||
type (
|
||||
CommonTrackerPattern struct {
|
||||
ID gid.GID `db:"id"`
|
||||
CommonThirdPartyID *gid.GID `db:"common_third_party_id"`
|
||||
TrackerType TrackerType `db:"tracker_type"`
|
||||
Pattern string `db:"pattern"`
|
||||
MatchType TrackerPatternMatchType `db:"match_type"`
|
||||
Description string `db:"description"`
|
||||
MaxAgeSeconds *int `db:"max_age_seconds"`
|
||||
Confidence float32 `db:"confidence"`
|
||||
EnrichmentRequestedAt *time.Time `db:"enrichment_requested_at"`
|
||||
Enrichment json.RawMessage `db:"enrichment"`
|
||||
EnrichmentAttempts int `db:"enrichment_attempts"`
|
||||
LastEnrichmentAttemptAt *time.Time `db:"last_enrichment_attempt_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
ID gid.GID `db:"id"`
|
||||
CommonThirdPartyID *gid.GID `db:"common_third_party_id"`
|
||||
TrackerType TrackerType `db:"tracker_type"`
|
||||
Pattern string `db:"pattern"`
|
||||
MatchType TrackerPatternMatchType `db:"match_type"`
|
||||
Description string `db:"description"`
|
||||
MaxAgeSeconds *int `db:"max_age_seconds"`
|
||||
Confidence float32 `db:"confidence"`
|
||||
Attribution CommonTrackerPatternAttribution `db:"attribution"`
|
||||
EnrichmentRequestedAt *time.Time `db:"enrichment_requested_at"`
|
||||
Enrichment json.RawMessage `db:"enrichment"`
|
||||
EnrichmentAttempts int `db:"enrichment_attempts"`
|
||||
LastEnrichmentAttemptAt *time.Time `db:"last_enrichment_attempt_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
CommonTrackerPatterns []*CommonTrackerPattern
|
||||
@@ -64,6 +65,7 @@ SELECT
|
||||
description,
|
||||
max_age_seconds,
|
||||
confidence,
|
||||
attribution,
|
||||
enrichment_requested_at,
|
||||
enrichment,
|
||||
enrichment_attempts,
|
||||
@@ -115,6 +117,7 @@ SELECT
|
||||
description,
|
||||
max_age_seconds,
|
||||
confidence,
|
||||
attribution,
|
||||
enrichment_requested_at,
|
||||
enrichment,
|
||||
enrichment_attempts,
|
||||
@@ -159,6 +162,10 @@ func (p CommonTrackerPattern) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Tx,
|
||||
) error {
|
||||
if p.Attribution == "" {
|
||||
p.Attribution = CommonTrackerPatternAttributionUndetermined
|
||||
}
|
||||
|
||||
q := `
|
||||
INSERT INTO common_tracker_patterns (
|
||||
id,
|
||||
@@ -169,6 +176,7 @@ INSERT INTO common_tracker_patterns (
|
||||
description,
|
||||
max_age_seconds,
|
||||
confidence,
|
||||
attribution,
|
||||
enrichment_requested_at,
|
||||
enrichment,
|
||||
enrichment_attempts,
|
||||
@@ -184,6 +192,7 @@ INSERT INTO common_tracker_patterns (
|
||||
@description,
|
||||
@max_age_seconds,
|
||||
@confidence,
|
||||
@attribution,
|
||||
@enrichment_requested_at,
|
||||
@enrichment,
|
||||
@enrichment_attempts,
|
||||
@@ -202,6 +211,7 @@ INSERT INTO common_tracker_patterns (
|
||||
"description": p.Description,
|
||||
"max_age_seconds": p.MaxAgeSeconds,
|
||||
"confidence": p.Confidence,
|
||||
"attribution": p.Attribution,
|
||||
"enrichment_requested_at": p.EnrichmentRequestedAt,
|
||||
"enrichment": p.Enrichment,
|
||||
"enrichment_attempts": p.EnrichmentAttempts,
|
||||
@@ -232,6 +242,10 @@ func (p *CommonTrackerPattern) Upsert(
|
||||
// enrichment, and re-arming resets the attempt counter and drops the
|
||||
// prior payload so the row reads as not-yet-completed again (see the
|
||||
// enrichment CASE below).
|
||||
if p.Attribution == "" {
|
||||
p.Attribution = CommonTrackerPatternAttributionUndetermined
|
||||
}
|
||||
|
||||
q := `
|
||||
INSERT INTO common_tracker_patterns (
|
||||
id,
|
||||
@@ -242,6 +256,7 @@ INSERT INTO common_tracker_patterns (
|
||||
description,
|
||||
max_age_seconds,
|
||||
confidence,
|
||||
attribution,
|
||||
enrichment_requested_at,
|
||||
enrichment,
|
||||
enrichment_attempts,
|
||||
@@ -257,6 +272,7 @@ INSERT INTO common_tracker_patterns (
|
||||
@description,
|
||||
@max_age_seconds,
|
||||
@confidence,
|
||||
@attribution,
|
||||
CASE WHEN @description = '' THEN NOW() ELSE NULL END,
|
||||
NULL,
|
||||
0,
|
||||
@@ -266,13 +282,29 @@ INSERT INTO common_tracker_patterns (
|
||||
)
|
||||
ON CONFLICT (tracker_type, pattern, COALESCE(max_age_seconds, -1)) DO UPDATE
|
||||
SET
|
||||
common_third_party_id = EXCLUDED.common_third_party_id,
|
||||
-- A terminal FIRST_PARTY row stays vendor-free: an automated upsert
|
||||
-- must never attach a third party to an artifact an operator (or the
|
||||
-- agent) ruled has none. Other rows take the incoming vendor.
|
||||
common_third_party_id = CASE
|
||||
WHEN common_tracker_patterns.attribution = 'FIRST_PARTY' THEN NULL
|
||||
ELSE EXCLUDED.common_third_party_id
|
||||
END,
|
||||
match_type = EXCLUDED.match_type,
|
||||
description = CASE
|
||||
WHEN EXCLUDED.description = '' THEN common_tracker_patterns.description
|
||||
ELSE EXCLUDED.description
|
||||
END,
|
||||
confidence = EXCLUDED.confidence,
|
||||
-- A FIRST_PARTY verdict is terminal: it is only ever set by an
|
||||
-- explicit operator action (proboctl mark-first-party). Automated
|
||||
-- mapping upserts must never downgrade it back to a vendor or
|
||||
-- UNDETERMINED, otherwise a stray domain/sibling match would
|
||||
-- resurrect the very attribution the operator suppressed.
|
||||
attribution = CASE
|
||||
WHEN common_tracker_patterns.attribution = 'FIRST_PARTY'
|
||||
THEN common_tracker_patterns.attribution
|
||||
ELSE EXCLUDED.attribution
|
||||
END,
|
||||
-- A blank, unlinked catalog row that now gains a third party is
|
||||
-- re-queued for enrichment: the enrichment agent leaves descriptions
|
||||
-- blank when it cannot substantiate a purpose, and knowing the vendor
|
||||
@@ -314,6 +346,7 @@ RETURNING
|
||||
description,
|
||||
max_age_seconds,
|
||||
confidence,
|
||||
attribution,
|
||||
enrichment_requested_at,
|
||||
enrichment,
|
||||
enrichment_attempts,
|
||||
@@ -333,6 +366,7 @@ RETURNING
|
||||
"description": p.Description,
|
||||
"max_age_seconds": p.MaxAgeSeconds,
|
||||
"confidence": p.Confidence,
|
||||
"attribution": p.Attribution,
|
||||
"created_at": p.CreatedAt,
|
||||
"updated_at": p.UpdatedAt,
|
||||
}
|
||||
@@ -386,6 +420,7 @@ SELECT
|
||||
description,
|
||||
max_age_seconds,
|
||||
confidence,
|
||||
attribution,
|
||||
enrichment_requested_at,
|
||||
enrichment,
|
||||
enrichment_attempts,
|
||||
@@ -505,6 +540,7 @@ SELECT
|
||||
description,
|
||||
max_age_seconds,
|
||||
confidence,
|
||||
attribution,
|
||||
enrichment_requested_at,
|
||||
enrichment,
|
||||
enrichment_attempts,
|
||||
@@ -554,6 +590,7 @@ SELECT
|
||||
description,
|
||||
max_age_seconds,
|
||||
confidence,
|
||||
attribution,
|
||||
enrichment_requested_at,
|
||||
enrichment,
|
||||
enrichment_attempts,
|
||||
@@ -743,6 +780,7 @@ SELECT
|
||||
description,
|
||||
max_age_seconds,
|
||||
confidence,
|
||||
attribution,
|
||||
enrichment_requested_at,
|
||||
enrichment,
|
||||
enrichment_attempts,
|
||||
@@ -812,6 +850,7 @@ SELECT
|
||||
description,
|
||||
max_age_seconds,
|
||||
confidence,
|
||||
attribution,
|
||||
enrichment_requested_at,
|
||||
enrichment,
|
||||
enrichment_attempts,
|
||||
@@ -916,11 +955,12 @@ ORDER BY pattern ASC
|
||||
// RelinkCommonThirdPartyByIDs repoints the given common tracker patterns
|
||||
// at a different common third party (or unlinks them when thirdPartyID is
|
||||
// nil). Linking is a manual operator attribution - the highest-trust
|
||||
// signal - so it bumps confidence to 1 to match the curated/seed tier;
|
||||
// unlinking makes no attribution and leaves confidence untouched. It only
|
||||
// touches the catalog rows; callers re-arm enrichment and remap the
|
||||
// org-scoped tracker patterns separately. Returns the number of rows
|
||||
// updated.
|
||||
// signal - so it bumps confidence to 1 to match the curated/seed tier and
|
||||
// sets the attribution verdict to THIRD_PARTY; unlinking makes no
|
||||
// attribution, returns the verdict to UNDETERMINED so the pipeline can
|
||||
// re-probe the row, and leaves confidence untouched. It only touches the
|
||||
// catalog rows; callers re-arm enrichment and remap the org-scoped tracker
|
||||
// patterns separately. Returns the number of rows updated.
|
||||
func (ps *CommonTrackerPatterns) RelinkCommonThirdPartyByIDs(
|
||||
ctx context.Context,
|
||||
tx pg.Tx,
|
||||
@@ -932,6 +972,10 @@ UPDATE common_tracker_patterns
|
||||
SET
|
||||
common_third_party_id = @third_party_id,
|
||||
confidence = CASE WHEN @third_party_id::text IS NOT NULL THEN 1 ELSE confidence END,
|
||||
attribution = CASE
|
||||
WHEN @third_party_id::text IS NOT NULL THEN 'THIRD_PARTY'::common_tracker_pattern_attribution
|
||||
ELSE 'UNDETERMINED'::common_tracker_pattern_attribution
|
||||
END,
|
||||
updated_at = NOW()
|
||||
WHERE
|
||||
id = ANY(@ids)
|
||||
@@ -950,6 +994,42 @@ WHERE
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// SetAttributionByIDs records a terminal attribution verdict on the given
|
||||
// catalog rows. It is an operator action: marking a row FIRST_PARTY (or
|
||||
// UNDETERMINED) clears any vendor link, because a non-third-party verdict
|
||||
// cannot keep a common_third_party_id. THIRD_PARTY is not a valid verdict
|
||||
// here - that attribution carries a vendor and must go through
|
||||
// RelinkCommonThirdPartyByIDs. Callers re-arm the org-scoped tracker
|
||||
// patterns separately. Returns the number of rows updated.
|
||||
func (ps *CommonTrackerPatterns) SetAttributionByIDs(
|
||||
ctx context.Context,
|
||||
tx pg.Tx,
|
||||
ids []gid.GID,
|
||||
attribution CommonTrackerPatternAttribution,
|
||||
) (int64, error) {
|
||||
q := `
|
||||
UPDATE common_tracker_patterns
|
||||
SET
|
||||
attribution = @attribution,
|
||||
common_third_party_id = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE
|
||||
id = ANY(@ids)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"ids": ids,
|
||||
"attribution": attribution,
|
||||
}
|
||||
|
||||
result, err := tx.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot set common tracker pattern attribution: %w", err)
|
||||
}
|
||||
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// RequestEnrichmentByIDs arms enrichment on the given common tracker
|
||||
// patterns by stamping enrichment_requested_at, which is the only column
|
||||
// the enrichment worker claims on. It resets enrichment_attempts to 0 so
|
||||
|
||||
88
pkg/coredata/common_tracker_pattern_attribution.go
Normal file
88
pkg/coredata/common_tracker_pattern_attribution.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// CommonTrackerPatternAttribution is the terminal verdict a catalog row
|
||||
// carries about who, if anyone, sets the tracker.
|
||||
//
|
||||
// CommonTrackerPatternAttributionUndetermined: the pipeline has not
|
||||
// resolved a vendor yet. The deterministic signals and the mapping agent
|
||||
// keep probing it (this is the state of the unmatched fallback row).
|
||||
//
|
||||
// CommonTrackerPatternAttributionThirdParty: a third party has been
|
||||
// resolved; the row carries a common_third_party_id.
|
||||
//
|
||||
// CommonTrackerPatternAttributionFirstParty: terminal verdict that the
|
||||
// artifact has no third party — it is the scanned site's own, a generic
|
||||
// library/log key, an extension key embedding the site origin, or
|
||||
// otherwise not attributable to any vendor. The mapping pipeline never
|
||||
// attributes such a row again.
|
||||
type CommonTrackerPatternAttribution string
|
||||
|
||||
const (
|
||||
CommonTrackerPatternAttributionUndetermined CommonTrackerPatternAttribution = "UNDETERMINED"
|
||||
CommonTrackerPatternAttributionThirdParty CommonTrackerPatternAttribution = "THIRD_PARTY"
|
||||
CommonTrackerPatternAttributionFirstParty CommonTrackerPatternAttribution = "FIRST_PARTY"
|
||||
)
|
||||
|
||||
var (
|
||||
_ fmt.Stringer = CommonTrackerPatternAttribution("")
|
||||
_ encoding.TextMarshaler = CommonTrackerPatternAttribution("")
|
||||
_ encoding.TextUnmarshaler = (*CommonTrackerPatternAttribution)(nil)
|
||||
)
|
||||
|
||||
func CommonTrackerPatternAttributions() []CommonTrackerPatternAttribution {
|
||||
return []CommonTrackerPatternAttribution{
|
||||
CommonTrackerPatternAttributionUndetermined,
|
||||
CommonTrackerPatternAttributionThirdParty,
|
||||
CommonTrackerPatternAttributionFirstParty,
|
||||
}
|
||||
}
|
||||
|
||||
func (v CommonTrackerPatternAttribution) IsValid() bool {
|
||||
switch v {
|
||||
case
|
||||
CommonTrackerPatternAttributionUndetermined,
|
||||
CommonTrackerPatternAttributionThirdParty,
|
||||
CommonTrackerPatternAttributionFirstParty:
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (v CommonTrackerPatternAttribution) String() string {
|
||||
return string(v)
|
||||
}
|
||||
|
||||
func (v CommonTrackerPatternAttribution) MarshalText() ([]byte, error) {
|
||||
return []byte(v.String()), nil
|
||||
}
|
||||
|
||||
func (v *CommonTrackerPatternAttribution) UnmarshalText(text []byte) error {
|
||||
val := CommonTrackerPatternAttribution(text)
|
||||
if !val.IsValid() {
|
||||
return fmt.Errorf("invalid CommonTrackerPatternAttribution value: %q", string(text))
|
||||
}
|
||||
|
||||
*v = val
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -79,6 +79,7 @@ type CommonTrackerPatternFilter struct {
|
||||
linked *bool
|
||||
described *bool
|
||||
state *CommonTrackerPatternEnrichmentState
|
||||
attribution *CommonTrackerPatternAttribution
|
||||
}
|
||||
|
||||
func NewCommonTrackerPatternFilter() *CommonTrackerPatternFilter {
|
||||
@@ -130,6 +131,11 @@ func (f *CommonTrackerPatternFilter) WithState(state *CommonTrackerPatternEnrich
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *CommonTrackerPatternFilter) WithAttribution(attribution *CommonTrackerPatternAttribution) *CommonTrackerPatternFilter {
|
||||
f.attribution = attribution
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *CommonTrackerPatternFilter) SQLFragment() string {
|
||||
if f == nil {
|
||||
return "TRUE"
|
||||
@@ -188,6 +194,12 @@ func (f *CommonTrackerPatternFilter) SQLFragment() string {
|
||||
enrichment_requested_at IS NULL AND enrichment IS NULL
|
||||
ELSE TRUE
|
||||
END
|
||||
AND
|
||||
CASE
|
||||
WHEN @filter_attribution::text IS NOT NULL THEN
|
||||
attribution = @filter_attribution::common_tracker_pattern_attribution
|
||||
ELSE TRUE
|
||||
END
|
||||
)`
|
||||
}
|
||||
|
||||
@@ -203,6 +215,7 @@ func (f *CommonTrackerPatternFilter) SQLArguments() pgx.StrictNamedArgs {
|
||||
"filter_state_queued": false,
|
||||
"filter_state_enriched": false,
|
||||
"filter_state_unenriched": false,
|
||||
"filter_attribution": nil,
|
||||
}
|
||||
|
||||
if f == nil {
|
||||
@@ -248,5 +261,9 @@ func (f *CommonTrackerPatternFilter) SQLArguments() pgx.StrictNamedArgs {
|
||||
}
|
||||
}
|
||||
|
||||
if f.attribution != nil {
|
||||
args["filter_attribution"] = string(*f.attribution)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -430,3 +430,179 @@ func TestCommonTrackerPattern_ResetStaleEnrichments_RespectsMaxAttempts(t *testi
|
||||
reloadedExhausted := loadCommonTrackerPattern(t, ctx, client, exhausted.ID)
|
||||
assert.Nil(t, reloadedExhausted.EnrichmentRequestedAt, "row at the max-attempts ceiling must not be re-queued")
|
||||
}
|
||||
|
||||
// TestCommonTrackerPatternAttribution_IsValid pins the enum's accepted
|
||||
// values.
|
||||
func TestCommonTrackerPatternAttribution_IsValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, v := range coredata.CommonTrackerPatternAttributions() {
|
||||
assert.True(t, v.IsValid(), "%q must be valid", v)
|
||||
}
|
||||
|
||||
assert.False(t, coredata.CommonTrackerPatternAttribution("").IsValid())
|
||||
assert.False(t, coredata.CommonTrackerPatternAttribution("nonsense").IsValid())
|
||||
}
|
||||
|
||||
// TestCommonTrackerPattern_Upsert_RoundTripsAttribution pins that the
|
||||
// attribution verdict is persisted and read back, and that an empty
|
||||
// verdict defaults to UNDETERMINED.
|
||||
func TestCommonTrackerPattern_Upsert_RoundTripsAttribution(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := test.PGClient(t)
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
cp := coredata.CommonTrackerPattern{
|
||||
ID: gid.New(gid.NilTenant, coredata.CommonTrackerPatternEntityType),
|
||||
TrackerType: coredata.TrackerTypeLocalStorage,
|
||||
Pattern: "attr_default_" + gid.New(gid.NilTenant, coredata.CommonTrackerPatternEntityType).String(),
|
||||
MatchType: coredata.TrackerPatternMatchTypeExact,
|
||||
Confidence: 0.5,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
insertCommonTrackerPattern(t, ctx, client, cp)
|
||||
|
||||
reloaded := loadCommonTrackerPattern(t, ctx, client, cp.ID)
|
||||
assert.Equal(t, coredata.CommonTrackerPatternAttributionUndetermined, reloaded.Attribution, "empty verdict must default to UNDETERMINED")
|
||||
}
|
||||
|
||||
// TestCommonTrackerPattern_Upsert_PreservesFirstPartyVerdict pins the
|
||||
// terminal contract: once a row is FIRST_PARTY, an automated upsert that
|
||||
// carries a vendor neither flips the verdict nor attaches the vendor.
|
||||
func TestCommonTrackerPattern_Upsert_PreservesFirstPartyVerdict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := test.PGClient(t)
|
||||
ctx := context.Background()
|
||||
|
||||
party := seedCommonThirdParty(t, ctx, client)
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
pattern := "first_party_terminal_" + gid.New(gid.NilTenant, coredata.CommonTrackerPatternEntityType).String()
|
||||
|
||||
firstParty := coredata.CommonTrackerPattern{
|
||||
ID: gid.New(gid.NilTenant, coredata.CommonTrackerPatternEntityType),
|
||||
TrackerType: coredata.TrackerTypeLocalStorage,
|
||||
Pattern: pattern,
|
||||
MatchType: coredata.TrackerPatternMatchTypeExact,
|
||||
Confidence: 0.8,
|
||||
Attribution: coredata.CommonTrackerPatternAttributionFirstParty,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
insertCommonTrackerPattern(t, ctx, client, firstParty)
|
||||
|
||||
// An automated upsert (same key) that tries to attach a vendor.
|
||||
intruder := coredata.CommonTrackerPattern{
|
||||
ID: gid.New(gid.NilTenant, coredata.CommonTrackerPatternEntityType),
|
||||
CommonThirdPartyID: &party.ID,
|
||||
TrackerType: coredata.TrackerTypeLocalStorage,
|
||||
Pattern: pattern,
|
||||
MatchType: coredata.TrackerPatternMatchTypeExact,
|
||||
Confidence: 0.7,
|
||||
Attribution: coredata.CommonTrackerPatternAttributionThirdParty,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now.Add(time.Minute),
|
||||
}
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
_, err := intruder.Upsert(ctx, tx)
|
||||
return err
|
||||
}))
|
||||
|
||||
reloaded := loadCommonTrackerPattern(t, ctx, client, firstParty.ID)
|
||||
assert.Equal(t, coredata.CommonTrackerPatternAttributionFirstParty, reloaded.Attribution, "FIRST_PARTY verdict must survive an automated upsert")
|
||||
assert.Nil(t, reloaded.CommonThirdPartyID, "a terminal first-party row must stay vendor-free")
|
||||
}
|
||||
|
||||
// TestCommonTrackerPatterns_SetAttributionByIDs pins that the operator
|
||||
// helper records the verdict and clears any vendor link.
|
||||
func TestCommonTrackerPatterns_SetAttributionByIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := test.PGClient(t)
|
||||
ctx := context.Background()
|
||||
|
||||
party := seedCommonThirdParty(t, ctx, client)
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
linked := coredata.CommonTrackerPattern{
|
||||
ID: gid.New(gid.NilTenant, coredata.CommonTrackerPatternEntityType),
|
||||
CommonThirdPartyID: &party.ID,
|
||||
TrackerType: coredata.TrackerTypeCookie,
|
||||
Pattern: "to_first_party_" + gid.New(gid.NilTenant, coredata.CommonTrackerPatternEntityType).String(),
|
||||
MatchType: coredata.TrackerPatternMatchTypeExact,
|
||||
Confidence: 0.8,
|
||||
Attribution: coredata.CommonTrackerPatternAttributionThirdParty,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
insertCommonTrackerPattern(t, ctx, client, linked)
|
||||
|
||||
var affected int64
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
var ps coredata.CommonTrackerPatterns
|
||||
var err error
|
||||
|
||||
affected, err = ps.SetAttributionByIDs(ctx, tx, []gid.GID{linked.ID}, coredata.CommonTrackerPatternAttributionFirstParty)
|
||||
|
||||
return err
|
||||
}))
|
||||
|
||||
assert.Equal(t, int64(1), affected)
|
||||
|
||||
reloaded := loadCommonTrackerPattern(t, ctx, client, linked.ID)
|
||||
assert.Equal(t, coredata.CommonTrackerPatternAttributionFirstParty, reloaded.Attribution)
|
||||
assert.Nil(t, reloaded.CommonThirdPartyID, "marking first-party must clear the vendor link")
|
||||
}
|
||||
|
||||
// TestCommonTrackerPatterns_RelinkCommonThirdPartyByIDs_SetsAttribution
|
||||
// pins that linking sets THIRD_PARTY and unlinking returns the row to
|
||||
// UNDETERMINED.
|
||||
func TestCommonTrackerPatterns_RelinkCommonThirdPartyByIDs_SetsAttribution(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := test.PGClient(t)
|
||||
ctx := context.Background()
|
||||
|
||||
party := seedCommonThirdParty(t, ctx, client)
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
row := coredata.CommonTrackerPattern{
|
||||
ID: gid.New(gid.NilTenant, coredata.CommonTrackerPatternEntityType),
|
||||
TrackerType: coredata.TrackerTypeCookie,
|
||||
Pattern: "relink_attr_" + gid.New(gid.NilTenant, coredata.CommonTrackerPatternEntityType).String(),
|
||||
MatchType: coredata.TrackerPatternMatchTypeExact,
|
||||
Confidence: 0.5,
|
||||
Attribution: coredata.CommonTrackerPatternAttributionUndetermined,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
insertCommonTrackerPattern(t, ctx, client, row)
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
var ps coredata.CommonTrackerPatterns
|
||||
_, err := ps.RelinkCommonThirdPartyByIDs(ctx, tx, []gid.GID{row.ID}, &party.ID)
|
||||
return err
|
||||
}))
|
||||
|
||||
linked := loadCommonTrackerPattern(t, ctx, client, row.ID)
|
||||
assert.Equal(t, coredata.CommonTrackerPatternAttributionThirdParty, linked.Attribution, "linking must set THIRD_PARTY")
|
||||
require.NotNil(t, linked.CommonThirdPartyID)
|
||||
assert.Equal(t, party.ID, *linked.CommonThirdPartyID)
|
||||
assert.Equal(t, float32(1), linked.Confidence, "linking must bump confidence to the curated tier")
|
||||
|
||||
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
||||
var ps coredata.CommonTrackerPatterns
|
||||
_, err := ps.RelinkCommonThirdPartyByIDs(ctx, tx, []gid.GID{row.ID}, nil)
|
||||
return err
|
||||
}))
|
||||
|
||||
unlinked := loadCommonTrackerPattern(t, ctx, client, row.ID)
|
||||
assert.Equal(t, coredata.CommonTrackerPatternAttributionUndetermined, unlinked.Attribution, "unlinking must return the verdict to UNDETERMINED")
|
||||
assert.Nil(t, unlinked.CommonThirdPartyID)
|
||||
}
|
||||
|
||||
38
pkg/coredata/migrations/20260616T114832Z.sql
Normal file
38
pkg/coredata/migrations/20260616T114832Z.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
-- Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
--
|
||||
-- Permission to use, copy, modify, and/or distribute this software for any
|
||||
-- purpose with or without fee is hereby granted, provided that the above
|
||||
-- copyright notice and this permission notice appear in all copies.
|
||||
--
|
||||
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
-- PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
-- Give the global tracker-pattern catalog a terminal attribution verdict so a
|
||||
-- row can record that it has no third party (a first-party or generic
|
||||
-- artifact) rather than only "linked" vs "not yet linked". UNDETERMINED rows
|
||||
-- are still probed by the mapping pipeline; THIRD_PARTY rows carry a vendor;
|
||||
-- FIRST_PARTY rows are terminal and the pipeline never attributes them again.
|
||||
CREATE TYPE common_tracker_pattern_attribution AS ENUM (
|
||||
'UNDETERMINED',
|
||||
'THIRD_PARTY',
|
||||
'FIRST_PARTY'
|
||||
);
|
||||
|
||||
ALTER TABLE common_tracker_patterns
|
||||
ADD COLUMN attribution common_tracker_pattern_attribution NOT NULL DEFAULT 'UNDETERMINED';
|
||||
|
||||
-- Backfill: any row already carrying a vendor is, by definition, attributed to
|
||||
-- a third party. The DEFAULT covers the rest (UNDETERMINED).
|
||||
UPDATE common_tracker_patterns
|
||||
SET attribution = 'THIRD_PARTY'
|
||||
WHERE common_third_party_id IS NOT NULL;
|
||||
|
||||
-- The DEFAULT only backfills existing rows; drop it so inserts must supply the
|
||||
-- value explicitly, matching the cookie_source convention.
|
||||
ALTER TABLE common_tracker_patterns
|
||||
ALTER COLUMN attribution DROP DEFAULT;
|
||||
Reference in New Issue
Block a user