← Certain Answers
Part II · Five Failures · Chapter 4

Entity Resolution

When three systems disagree on who a customer is, the join key is a bet. The question is whether the bet is visible in the result or buried in a config file nobody reads.

The conversations are in your support tool, the ARR is in the warehouse, the opportunities are in Salesforce. Three systems, three ID spaces, no shared key. Something has to decide that sfdc:Account:0015g, support:org:8842, and warehouse:account_id:A-2201 are the same company.

Whatever that something is — fuzzy name match, domain match, an LLM-based linker — it is probabilistic. And the standard way to use it is to threshold and take connected components, which has a property people consistently underestimate:

The transitivity trap

Entity resolution is not transitive. Connected components are.

"Acme Corp"        ~ "Acme Corporation"     0.94   ✓
"Acme Corporation" ~ "Acme Consulting LLC"  0.88   ✓

At θ = 0.85 those two edges merge three records into one entity. Acme Consulting is a contractor that happens to share a billing domain. You have now silently merged a $1.4M customer with an unrelated vendor, and every downstream number about “Acme” is wrong.

Nothing errored. The join found matches. It found too many matches, which looks exactly like success — arguably better than success, because the coverage metric went up.

4.1 The link graph

Model entity resolution as a weighted graph:

The threshold θ is a parameter, and the set of canonical entities is a function of that parameter. Change θ and you change which records are “the same company.” This is not a bug — it is an inherent property of probabilistic identity. The bug is burying θ in a platform setting where nobody can see it and no downstream query knows it.

@dataclass
class MatchEdge:
    source: str      # e.g. "sfdc:Account:0015g"
    target: str      # e.g. "support:org:8842"
    confidence: float
    method: str      # "domain_match", "name_fuzzy", "verified_id"

@dataclass
class LinkGraph:
    edges: list[MatchEdge]

    def resolve(self, theta: float) -> dict[str, str]:
        """Return mapping: record_id → canonical_entity_id at threshold theta."""
        # Keep only edges at or above threshold
        active = [e for e in self.edges if e.confidence >= theta]
        # Build connected components via union-find
        parent = {}
        def find(x):
            parent.setdefault(x, x)
            while parent[x] != x:
                parent[x] = parent[parent[x]]
                x = parent[x]
            return x
        def union(a, b):
            ra, rb = find(a), find(b)
            if ra != rb:
                parent[ra] = rb
        for e in active:
            union(e.source, e.target)
        return {node: find(node) for node in parent}

4.2 Three design consequences

θ belongs to the query, not the config

If θ is a platform setting, the same question returns different numbers in different quarters (as the linker is retrained or new edges appear) and nobody can reconstruct why. Make θ explicit in the query plan:

{
  "op": "cross_system_join",
  "left": {"source": "warehouse", "table": "accounts"},
  "right": {"source": "support", "table": "organizations"},
  "link_config": {
    "theta": 1.0,
    "max_hops": 1,
    "methods": ["verified_domain", "shared_external_id"]
  }
}

Default to deterministic links only

θ = 1.0 means: only use links derived from shared external identifiers (verified domains, company registration numbers, explicit cross-references set by a human). These are exact. They cannot produce the transitivity trap because they’re not probabilistic.

Fuzzy matching (θ < 1.0) should be opt-in per query. The caller must explicitly request it, and the result carries a confidence annotation showing that it was used.

This will annoy people. The alternative silently merges customers, which annoys different and more senior people, later, in a meeting.

Bound the hops

Unbounded transitive closure over heuristic links will, given enough data, merge a startling fraction of your customer base through shared contractor domains and personal email addresses.

max_hops: 1 means only direct links. max_hops: 2 allows one intermediary. In practice, anything beyond 2 is a sign that the linkage quality is too low to be useful, and the connected components are growing through noise rather than signal.

4.3 Confidence propagation

When a row exists in the result because of a probabilistic link, downstream needs to know. The annotation is the minimum edge confidence along the linking path:

\[ \text{confidence}(\text{path } p) = \min_{e \in p} \text{confidence}(e) \]

Why minimum rather than product? Products decay so fast over multi-hop links that everything looks worthless (0.94 × 0.88 = 0.83, two more hops and you’re below 0.5 regardless of edge quality). Minimum is pessimistic and monotone in θ: if you raise θ, no path’s confidence goes down, and paths that were below the new threshold disappear. That’s the behavior you want.

@dataclass(frozen=True)
class Confidence:
    value: float  # 0.0 to 1.0; 1.0 means deterministic link
    method: str   # what produced this link
    hops: int     # how many edges in the path

    @staticmethod
    def deterministic():
        return Confidence(value=1.0, method="exact", hops=0)

    def chain(self, edge: MatchEdge) -> "Confidence":
        """Extend this confidence through one more edge."""
        return Confidence(
            value=min(self.value, edge.confidence),
            method=f"{self.method}→{edge.method}",
            hops=self.hops + 1
        )

    def meet(self, other: "Confidence") -> "Confidence":
        """Compose two confidence annotations (e.g., in a join)."""
        return Confidence(
            value=min(self.value, other.value),
            method=f"({self.method}) ⊓ ({other.method})",
            hops=max(self.hops, other.hops)
        )
Drill 4.1

A support ticket is linked to a warehouse account through two edges: ticket → support_org (confidence 0.97, verified email domain) and support_org → warehouse_account (confidence 0.91, fuzzy name match). What confidence does the final ticket–account link carry? If you raise θ from 0.85 to 0.92, does this link survive?

Show answer

Path confidence = min(0.97, 0.91) = 0.91. At θ = 0.92, the second edge (0.91) falls below threshold, so the link is dropped. The ticket can no longer be attributed to this warehouse account. This is correct behavior: the link was uncertain, and raising θ surfaces that uncertainty by removing it.

4.4 The link as a disclosure

Here is the part that is genuinely under-explored, and it took time to see.

Suppose a support manager can see support:org:8842 but has no Salesforce access. Your entity resolver knows that org 8842 is the same company as sfdc:Account:0015g. If the agent resolves the entity and surfaces a canonical name, domain, or ID drawn from the Salesforce side, it has just disclosed the existence and identity of a record the user cannot see.

Knowing that A and B are the same thing is information, separate from A and separate from B.

The clean rule:

\[ V_{\text{edge}}(P, a \text{—} b) = V_{\text{row}}(P, a) \wedge V_{\text{row}}(P, b) \]

A principal \(P\) can traverse a link edge between records \(a\) and \(b\) only if \(P\) is authorized to see both endpoints. Resolution runs over the principal-visible subgraph.

The uncomfortable consequence: two principals can legitimately compute different entity partitions over the same data. A “canonical ID” is only canonical relative to a principal and a threshold. That’s ugly. The alternative is a cross-tenant identity oracle, which is uglier.

A pragmatic relaxation

Edges derived from public identifiers — registered domains, company registration numbers, stock tickers — can be marked traversable regardless of endpoint visibility. In practice most of the useful deterministic links qualify. The CRM-internal ones (matching on account owner, internal tags, pipeline stage) don’t, and those are the ones that leak.

@dataclass
class LinkEdge:
    source: str
    target: str
    confidence: float
    method: str
    public: bool  # True = traversable by any principal

    def visible_to(self, principal, authz) -> bool:
        if self.public:
            return True
        return (authz.can_see(principal, self.source) and
                authz.can_see(principal, self.target))
Prior art

Information leakage in data linkage (arXiv 2505.08596)

A 2025 paper making essentially this observation in the record-linkage setting: knowing which of your records matched — and therefore which didn’t — is itself a leak. The SDC literature calls the general version membership disclosure.

This is a different problem from privacy-preserving record linkage (PPRL), which protects the inputs from the party doing the linking. Here the linker is trusted; the question is whether the output linkage fact should reach this particular consumer.

Drill 4.2

Principal A can see support tickets and warehouse accounts but not CRM opportunities. Principal B can see CRM opportunities and warehouse accounts but not support tickets. Both see the same warehouse. An entity has records in all three systems, linked by verified domain (public). What entity information can each principal see? Now suppose one link is internal (CRM opportunity → warehouse account, linked by internal account-owner assignment, non-public). What changes?

Show answer

With all links public: both principals see the full entity (all three systems’ records unified). They may not see the content of the endpoint they lack access to, but they can see that the entity exists and spans those systems. With the CRM→warehouse link marked non-public: Principal A cannot traverse that edge (can’t see the CRM endpoint). Principal A sees only support + warehouse as one entity. The CRM opportunity exists in a separate partition for Principal A. Principal B can still traverse it (sees both CRM and warehouse), so B sees CRM + warehouse as one entity, and the support ticket is separate (B can’t see support). They compute different entity graphs from the same data.

4.5 Entity resolution in the query plan

Pulling this together, a cross-system join in the typed query plan looks like:

{
  "op": "entity_join",
  "left": {"source": "warehouse", "entity_type": "account"},
  "right": {"source": "support", "entity_type": "organization"},
  "resolution": {
    "theta": 1.0,
    "max_hops": 1,
    "principal": "user:sarah@acme.com",
    "methods_allowed": ["verified_domain", "shared_external_id"]
  },
  "output_annotations": {
    "confidence": "propagated",   // min along path
    "completeness": "exact"       // theta=1.0 links are deterministic
  }
}

When θ < 1.0:

{
  "resolution": {
    "theta": 0.85,
    "max_hops": 2,
    "principal": "user:sarah@acme.com",
    "methods_allowed": ["verified_domain", "name_fuzzy", "domain_match"]
  },
  "output_annotations": {
    "confidence": "propagated",
    "completeness": "top_k"  // fuzzy matching may miss valid links
  }
}

Note that fuzzy resolution degrades both annotations: confidence (because edges are probabilistic) and completeness (because valid links below θ are missed, making the entity set incomplete). This is the meet rule applied twice.


Exercises

Implement
  1. Build a LinkGraph with the following edges and resolve entities at θ = 0.95, 0.90, 0.85, and 0.80. Show how the entity count changes at each threshold:
    A ~ B  0.97  (verified domain)
    B ~ C  0.92  (name match)
    C ~ D  0.88  (domain similarity)
    D ~ E  0.84  (fuzzy name)
    F ~ G  0.96  (shared external ID)
    G ~ H  0.79  (weak name match)
    
    At which threshold does the “A-B-C” cluster first merge with “D-E”? At which does the F-G cluster merge with H?
  2. Implement the access-controlled resolution: given a principal and an authorization function, filter the link graph to the principal-visible subgraph before computing connected components. Show that two principals with different visibility produce different entity partitions.
Extend
  1. Your entity resolver uses an LLM to score name matches. The scores are non-deterministic (vary ±0.03 across runs). Design a resolution strategy that is stable under this noise: same inputs should produce the same entities regardless of score jitter. Consider: should you cache scores? Use a hysteresis band (“once merged, stay merged unless confidence drops below θ − ε”)? Accept non-determinism and version the entity graph?
  2. An entity has records in 5 systems. Two users each see 3 of the 5 (overlapping on the warehouse). They’re in a meeting discussing the same customer but seeing different data about it. Design the “shared view”: what entity information can be shown to both simultaneously? How does the system compute the intersection of two principal-specific entity graphs?

Further reading