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:
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.
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}
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"]
}
}
θ = 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.
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.
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)
)
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?
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.
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.
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))
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.
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?
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.
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.
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?