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:
Émile Ré
2026-06-16 13:51:31 +02:00
parent 1faa60bfba
commit 7723b33aec
16 changed files with 1277 additions and 48 deletions

View File

@@ -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

View 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
}

View File

@@ -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
}

View File

@@ -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)
}

View 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;