Promote tracker patterns to org third parties via worker

Manual moves of a non-extension TrackerPattern lacking a ThirdPartyID
now request mapping, which the tracker-mapping worker resolves with a
four-stage pipeline: exact common_third_party_id link, heuristic
ranking, agent disambiguation, and finally CreateFromCommon. Existing
fuzzy-matched org rows are tagged with common_third_party_id so the
next promotion takes the O(1) exact-link path.

The matching primitives live in pkg/thirdparty (RankCandidates,
LinkToCommon, CreateFromCommon, ScoredCandidate, threshold constants)
so the disambiguation agent and the heuristic share one candidate
type. Cookiebanner orchestrates them; cookie-banner-specific concerns
(pattern -> common-pattern -> common-party navigation, the EXTENSION
gate, and structured logs) stay in the worker.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-05-28 17:20:36 +02:00
parent 33ddba9770
commit a99a4dde14
12 changed files with 1491 additions and 94 deletions

209
pkg/thirdparty/disambiguation_agent.go vendored Normal file
View File

@@ -0,0 +1,209 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 thirdparty
import (
"context"
_ "embed"
"fmt"
"strings"
"time"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/agent"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/llm"
)
//go:embed prompts/disambiguation.txt.tmpl
var disambiguationPrompt string
const (
// disambiguationConfidenceThreshold is the floor below which we
// treat the agent's pick as "no confident match" even when it
// returned a non-nil matched_id. Mirrors the conservative bias
// described in the prompt.
disambiguationConfidenceThreshold = 0.6
// disambiguationTimeout caps a single disambiguation run. The
// agent has no tools and a single turn, so this is mostly a
// guard against a hung LLM provider, not a real budget.
disambiguationTimeout = 60 * time.Second
)
// DisambiguationConfig configures the third-party disambiguation
// agent. The agent has no DB tools and no web-search tools: the
// candidate list is supplied entirely in the prompt and the agent
// only picks among it.
type DisambiguationConfig struct {
LLMClient *llm.Client
Model string
}
// DisambiguationResult is the structured output the disambiguation
// agent returns when picking the best existing org ThirdParty for a
// catalog entry.
type DisambiguationResult struct {
MatchedID *string `json:"matched_id" jsonschema:"GID of the org third party that best matches, or null if none of the candidates is a confident match."`
Confidence float64 `json:"confidence" jsonschema:"Confidence level from 0.0 to 1.0. Below 0.6 means 'no confident match' and matched_id MUST be null."`
Reasoning string `json:"reasoning" jsonschema:"One short sentence describing the rationale."`
}
// BuildDisambiguationAgent wires the agent that picks the best
// existing org ThirdParty for a catalog entry. It deliberately has
// no tools: the candidate list is supplied in the prompt and the
// agent must only choose among it.
func BuildDisambiguationAgent(
cfg DisambiguationConfig,
logger *log.Logger,
) *agent.Agent {
outputType, err := agent.NewOutputType[DisambiguationResult]("third_party_disambiguation")
if err != nil {
panic(fmt.Sprintf("thirdparty: cannot build disambiguation output type: %s", err))
}
return agent.New(
"third-party-disambiguation",
cfg.LLMClient,
agent.WithInstructions(disambiguationPrompt),
agent.WithModel(cfg.Model),
agent.WithOutputType(outputType),
agent.WithMaxTurns(1),
agent.WithLogger(logger),
)
}
// Disambiguate runs the agent against the given catalog third party
// and candidate list, and returns the matched candidate's ID — or
// nil when the agent picks "none", returns a confidence below the
// threshold, or fails. Errors from the agent itself are returned;
// "no confident match" is not an error.
//
// The matched candidate is identified by string equality against the
// IDs supplied in `candidates`; we never invent IDs from the agent's
// output, so a model that hallucinates an ID is treated as "none".
func Disambiguate(
ctx context.Context,
a *agent.Agent,
logger *log.Logger,
commonParty coredata.CommonThirdParty,
commonDomains coredata.CommonThirdPartyDomains,
candidates []ScoredCandidate,
) (*gid.GID, error) {
if a == nil || len(candidates) == 0 {
return nil, nil
}
prompt := buildDisambiguationPrompt(commonParty, commonDomains, candidates)
agentCtx, cancel := context.WithTimeout(ctx, disambiguationTimeout)
defer cancel()
result, err := agent.RunTyped[DisambiguationResult](
agentCtx,
a,
[]llm.Message{
{
Role: llm.RoleUser,
Parts: []llm.Part{llm.TextPart{Text: prompt}},
},
},
)
if err != nil {
return nil, fmt.Errorf("cannot run disambiguation agent: %w", err)
}
out := result.Output
if out.MatchedID == nil || *out.MatchedID == "" {
return nil, nil
}
if out.Confidence < disambiguationConfidenceThreshold {
logger.InfoCtx(
ctx,
"disambiguation agent below confidence threshold",
log.String("matched_id", *out.MatchedID),
log.Float64("confidence", out.Confidence),
)
return nil, nil
}
for _, c := range candidates {
if c.ThirdParty.ID.String() == *out.MatchedID {
id := c.ThirdParty.ID
return &id, nil
}
}
logger.WarnCtx(
ctx,
"disambiguation agent returned id not in candidate list",
log.String("matched_id", *out.MatchedID),
)
return nil, nil
}
// buildDisambiguationPrompt formats the catalog third party and the
// heuristic-ranked candidate list into the user message for the
// disambiguation agent. The prompt is intentionally compact: the
// agent only needs ids, names, websites, and the heuristic score to
// decide.
func buildDisambiguationPrompt(
commonParty coredata.CommonThirdParty,
commonDomains coredata.CommonThirdPartyDomains,
candidates []ScoredCandidate,
) string {
var b strings.Builder
b.WriteString("Catalog third party:\n")
fmt.Fprintf(&b, " name: %s\n", commonParty.Name)
if commonParty.WebsiteURL != nil && *commonParty.WebsiteURL != "" {
fmt.Fprintf(&b, " website: %s\n", *commonParty.WebsiteURL)
}
if len(commonDomains) > 0 {
domains := make([]string, len(commonDomains))
for i, d := range commonDomains {
domains[i] = d.Domain
}
fmt.Fprintf(&b, " domains: %s\n", strings.Join(domains, ", "))
}
b.WriteString("\nCandidate organisation third parties (heuristic-ranked):\n")
for i, c := range candidates {
fmt.Fprintf(&b, "- id: %s\n", c.ThirdParty.ID.String())
fmt.Fprintf(&b, " name: %s\n", c.ThirdParty.Name)
if c.ThirdParty.WebsiteURL != nil && *c.ThirdParty.WebsiteURL != "" {
fmt.Fprintf(&b, " website: %s\n", *c.ThirdParty.WebsiteURL)
}
fmt.Fprintf(&b, " heuristic_score: %.2f\n", c.Score)
if i < len(candidates)-1 {
b.WriteString("\n")
}
}
return b.String()
}

274
pkg/thirdparty/match.go vendored Normal file
View File

@@ -0,0 +1,274 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 thirdparty
import (
"context"
"fmt"
"sort"
"strings"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/slug"
"go.probo.inc/probo/pkg/uri"
)
// Heuristic thresholds for matching a CommonThirdParty to an existing
// org ThirdParty. Exported so callers can short-circuit explicitly
// (skip the agent, fall back to creating, etc.) instead of duplicating
// magic numbers.
const (
// HighConfidenceScore is the floor at which a heuristic match
// is treated as obvious (exact name, suffix-stripped name, slug
// equality). Callers typically link without consulting the
// agent at this score.
HighConfidenceScore = 0.85
// MinAgentScore is the floor below which a candidate is
// statistical noise and should not be shown to the
// disambiguation agent. Below this, callers typically prefer
// to create a fresh row over asking the model to pick among
// weak candidates.
MinAgentScore = 0.6
// MaxAgentCandidates caps the candidate list shown to the
// disambiguation agent. The list is heuristic-ranked, so the
// top few are the only ones worth the agent's tokens.
MaxAgentCandidates = 5
)
// ScoredCandidate is a scored heuristic-match candidate. It is the
// unified currency between the heuristic ranker (RankCandidates) and
// the disambiguation agent (Disambiguate): the agent renders the
// `ThirdParty` fields plus the score directly into its prompt, with
// no intermediate DTO.
type ScoredCandidate struct {
ThirdParty *coredata.ThirdParty
Score float64
}
// corporateSuffixes are the legal-form noise words stripped when
// comparing third-party names heuristically. The list is intentionally
// short and conservative: matching "Foo Inc" to "Foo" is safe, but
// stripping "Group" or "Services" would over-match unrelated entries.
//
// Order matters: stripCorporateSuffixes returns on the first match,
// so longer / comma-prefixed forms must come before their shorter
// siblings (", inc." before " inc.", which itself comes before " inc").
var corporateSuffixes = []string{
" incorporated",
" corporation",
", inc.",
", inc",
" l.l.c.",
" s.a.s.",
" inc.",
" inc",
" llc",
" ltd.",
" ltd",
" limited",
" gmbh",
" s.a.",
" sas",
" sa",
" ag",
" plc",
" corp.",
" corp",
" co.",
" co",
" b.v.",
" bv",
}
// RankCandidates ranks org ThirdParty rows by how likely each is to
// represent the given CommonThirdParty. Returned slice is sorted by
// descending score; only candidates with score > 0 are kept. Pure
// function: no I/O, deterministic on its inputs.
//
// Scoring (highest match wins; website-host overlap can lift a name
// miss to 0.8):
//
// - exact lowercase name = 1.0
// - lowercase name with corporate suffix stripped, equal = 0.9
// - slug equality (slug.Make on the org's name) = 0.85
// - website host (eTLD+1) overlap with the catalog domain set = 0.8
func RankCandidates(
commonParty coredata.CommonThirdParty,
commonDomains coredata.CommonThirdPartyDomains,
candidates coredata.ThirdParties,
) []ScoredCandidate {
commonName := strings.ToLower(strings.TrimSpace(commonParty.Name))
commonStripped := stripCorporateSuffixes(commonName)
commonSlug := commonParty.Slug
commonHost := ""
if commonParty.WebsiteURL != nil {
commonHost = uri.ExtractDomain(*commonParty.WebsiteURL)
}
commonDomainSet := make(map[string]struct{}, len(commonDomains))
for _, d := range commonDomains {
commonDomainSet[strings.ToLower(d.Domain)] = struct{}{}
}
if commonHost != "" {
commonDomainSet[commonHost] = struct{}{}
}
scored := make([]ScoredCandidate, 0, len(candidates))
for _, tp := range candidates {
score := 0.0
orgName := strings.ToLower(strings.TrimSpace(tp.Name))
orgStripped := stripCorporateSuffixes(orgName)
switch {
case orgName != "" && orgName == commonName:
score = 1.0
case orgStripped != "" && orgStripped == commonStripped:
score = 0.9
case commonSlug != "" && slug.Make(tp.Name) == commonSlug:
score = 0.85
}
if tp.WebsiteURL != nil {
orgHost := uri.ExtractDomain(*tp.WebsiteURL)
if orgHost != "" {
if _, hit := commonDomainSet[orgHost]; hit {
if score < 0.8 {
score = 0.8
}
}
}
}
if score == 0 {
continue
}
scored = append(scored, ScoredCandidate{
ThirdParty: tp,
Score: score,
})
}
sort.SliceStable(scored, func(i, j int) bool {
return scored[i].Score > scored[j].Score
})
return scored
}
// stripCorporateSuffixes removes a single trailing legal-form suffix
// from a lowercased name. Only one suffix is stripped to avoid
// mangling names that happen to end in two stop-words (e.g. "Foo Inc
// LLC" → "Foo Inc", not "Foo").
func stripCorporateSuffixes(lowerName string) string {
for _, s := range corporateSuffixes {
if before, ok := strings.CutSuffix(lowerName, s); ok {
return strings.TrimSpace(before)
}
}
return lowerName
}
// LinkToCommon writes common_third_party_id onto an org ThirdParty so
// future matches against the same CommonThirdParty can short-circuit
// to the exact-link path in O(1). No-op when the field is already set
// to commonID; otherwise writes the field via ThirdParty.Update and
// updates the receiver in place.
func LinkToCommon(
ctx context.Context,
tx pg.Tx,
scope coredata.Scoper,
orgThirdParty *coredata.ThirdParty,
commonID gid.GID,
) error {
if orgThirdParty.CommonThirdPartyID != nil && *orgThirdParty.CommonThirdPartyID == commonID {
return nil
}
orgThirdParty.CommonThirdPartyID = &commonID
if err := orgThirdParty.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update third party with common id: %w", err)
}
return nil
}
// CreateFromCommon inserts a new org ThirdParty seeded from the catalog
// row (name, category, addresses, URLs, certifications, …). The new row
// has common_third_party_id pointed at commonParty, an empty Countries
// list, and ShowOnTrustCenter / FirstLevel both false — mirroring the
// front-end CreateThirdPartyDialog's "pick from catalog" seeding shape
// so the result is indistinguishable from a manual creation.
//
// Deliberately bypasses any service-level webhook emission: callers
// that need a webhook for the implicit creation should emit it
// themselves.
func CreateFromCommon(
ctx context.Context,
tx pg.Tx,
scope coredata.Scoper,
organizationID gid.GID,
commonParty coredata.CommonThirdParty,
) (*coredata.ThirdParty, error) {
commonID := commonParty.ID
now := time.Now()
tp := &coredata.ThirdParty{
ID: gid.New(scope.GetTenantID(), coredata.ThirdPartyEntityType),
OrganizationID: organizationID,
CommonThirdPartyID: &commonID,
Name: commonParty.Name,
Category: commonParty.Category,
HeadquarterAddress: commonParty.HeadquarterAddress,
LegalName: commonParty.LegalName,
WebsiteURL: commonParty.WebsiteURL,
PrivacyPolicyURL: commonParty.PrivacyPolicyURL,
ServiceLevelAgreementURL: commonParty.ServiceLevelAgreementURL,
DataProcessingAgreementURL: commonParty.DataProcessingAgreementURL,
BusinessAssociateAgreementURL: commonParty.BusinessAssociateAgreementURL,
SubprocessorsListURL: commonParty.SubprocessorsListURL,
Certifications: commonParty.Certifications,
Countries: coredata.CountryCodes{},
StatusPageURL: commonParty.StatusPageURL,
TermsOfServiceURL: commonParty.TermsOfServiceURL,
SecurityPageURL: commonParty.SecurityPageURL,
TrustPageURL: commonParty.TrustPageURL,
ShowOnTrustCenter: false,
FirstLevel: false,
CreatedAt: now,
UpdatedAt: now,
}
if tp.Certifications == nil {
tp.Certifications = []string{}
}
if err := tp.Insert(ctx, tx, scope); err != nil {
return nil, fmt.Errorf("cannot insert org third party: %w", err)
}
return tp, nil
}

181
pkg/thirdparty/match_test.go vendored Normal file
View File

@@ -0,0 +1,181 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.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 thirdparty
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
//go:fix inline
func ptr[T any](v T) *T { return new(v) }
func TestStripCorporateSuffixes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in string
want string
}{
{name: "llc suffix", in: "google llc", want: "google"},
{name: "comma inc", in: "stripe, inc", want: "stripe"},
{name: "inc dot", in: "meta inc.", want: "meta"},
{name: "ltd", in: "deepmind ltd", want: "deepmind"},
{name: "gmbh", in: "n8n gmbh", want: "n8n"},
{name: "no suffix", in: "cloudflare", want: "cloudflare"},
{name: "trailing space", in: "github inc", want: "github"},
{name: "only one suffix stripped", in: "foo inc llc", want: "foo inc"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, stripCorporateSuffixes(tt.in))
})
}
}
func TestRankCandidates(t *testing.T) {
t.Parallel()
tenantID := gid.NewTenantID()
mkTP := func(name, website string) *coredata.ThirdParty {
tp := &coredata.ThirdParty{
ID: gid.New(tenantID, coredata.ThirdPartyEntityType),
Name: name,
}
if website != "" {
tp.WebsiteURL = new(website)
}
return tp
}
t.Run("exact name match scores 1.0", func(t *testing.T) {
t.Parallel()
common := coredata.CommonThirdParty{Name: "Google", Slug: "google"}
got := RankCandidates(common, nil, coredata.ThirdParties{
mkTP("Google", ""),
mkTP("Stripe", ""),
})
require.Len(t, got, 1)
assert.Equal(t, 1.0, got[0].Score)
assert.Equal(t, "Google", got[0].ThirdParty.Name)
})
t.Run("suffix-stripped name scores 0.9", func(t *testing.T) {
t.Parallel()
common := coredata.CommonThirdParty{Name: "Google", Slug: "google"}
got := RankCandidates(common, nil, coredata.ThirdParties{
mkTP("Google LLC", ""),
})
require.Len(t, got, 1)
assert.Equal(t, 0.9, got[0].Score)
})
t.Run("slug equality scores 0.85", func(t *testing.T) {
t.Parallel()
common := coredata.CommonThirdParty{Name: "Google", Slug: "google"}
got := RankCandidates(common, nil, coredata.ThirdParties{
mkTP("google!", ""),
})
require.Len(t, got, 1)
assert.Equal(t, 0.85, got[0].Score)
})
t.Run("website host overlap scores 0.8 when name does not match", func(t *testing.T) {
t.Parallel()
common := coredata.CommonThirdParty{
Name: "Google Analytics",
Slug: "google-analytics",
WebsiteURL: new("https://google.com"),
}
got := RankCandidates(common, nil, coredata.ThirdParties{
mkTP("Sundar's Search Co", "https://www.google.com/about"),
})
require.Len(t, got, 1)
assert.Equal(t, 0.8, got[0].Score)
})
t.Run("domain set overlap scores 0.8", func(t *testing.T) {
t.Parallel()
common := coredata.CommonThirdParty{Name: "Stripe", Slug: "stripe"}
domains := coredata.CommonThirdPartyDomains{
{Domain: "stripe.com"},
{Domain: "stripe.network"},
}
got := RankCandidates(common, domains, coredata.ThirdParties{
mkTP("Payment Processor", "https://api.stripe.com/v1"),
})
require.Len(t, got, 1)
assert.Equal(t, 0.8, got[0].Score)
})
t.Run("no match returns empty", func(t *testing.T) {
t.Parallel()
common := coredata.CommonThirdParty{Name: "Stripe", Slug: "stripe"}
got := RankCandidates(common, nil, coredata.ThirdParties{
mkTP("Acme", "https://acme.example"),
mkTP("Widgets Inc", "https://widgets.example"),
})
assert.Empty(t, got)
})
t.Run("ranks descending by score", func(t *testing.T) {
t.Parallel()
common := coredata.CommonThirdParty{
Name: "Google",
Slug: "google",
WebsiteURL: new("https://google.com"),
}
got := RankCandidates(common, nil, coredata.ThirdParties{
mkTP("Random", "https://google.com"),
mkTP("Google", ""),
mkTP("Google LLC", ""),
})
require.Len(t, got, 3)
assert.Equal(t, "Google", got[0].ThirdParty.Name)
assert.Equal(t, 1.0, got[0].Score)
assert.Equal(t, "Google LLC", got[1].ThirdParty.Name)
assert.Equal(t, 0.9, got[1].Score)
assert.Equal(t, "Random", got[2].ThirdParty.Name)
assert.Equal(t, 0.8, got[2].Score)
})
}

View File

@@ -0,0 +1,25 @@
<role>
You are a third-party catalog matcher. The product groups web trackers under a global catalog of "common" third parties (Google Analytics, Stripe, Meta Pixel, …). Each customer organisation also maintains its own list of "third parties" — sometimes seeded from the catalog, sometimes typed manually. Your only job is to decide whether one of the organisation's existing third parties already represents a given catalog entry, so we don't create a duplicate.
</role>
<task>
You are given:
- A catalog third party: name, website, and known domains.
- A small list of candidate organisation third parties: each with a stable id, a name, and (optionally) a website.
Pick the candidate that best represents the catalog third party, or none.
Return a structured JSON response with:
- matched_id: the candidate id, or null if none of them is a confident match.
- confidence: 0.0 to 1.0; below 0.6 means "no confident match" (set matched_id to null in that case).
- reasoning: one short sentence describing the rationale.
</task>
<instructions>
1. The candidates have already been ranked by a heuristic; the list is small (usually a handful). Use that as a hint, but do not trust it blindly.
2. Treat corporate suffixes (LLC, Inc, Ltd, GmbH, SA, …) as noise. "Google" and "Google LLC" are the same company.
3. Treat brand/product names as the same when the parent company is obvious: "Google Analytics" maps to "Google" if the org only has the parent. Only do this when the catalog domains plainly match the parent's domains.
4. Website hostnames and known domains are the strongest signal. If the catalog domain (or its eTLD+1) matches a candidate's website host, they are almost certainly the same.
5. Be conservative. If two candidates look plausible and you cannot rule one out, return matched_id=null with confidence < 0.6 — we will create a fresh org third party from the catalog rather than risk a wrong link.
6. Do not invent ids. Return only ids that appear verbatim in the candidate list.
</instructions>