diff --git a/services/api/workers/resolve.py b/services/api/workers/resolve.py index b2e5b5e..8090ad5 100644 --- a/services/api/workers/resolve.py +++ b/services/api/workers/resolve.py @@ -1,9 +1,13 @@ """Thin adapter from the Python pipeline to 0pt's canonical TS ER engine. Python no longer scores identity. It sends opaque records to -/api/0pt/internal/er/candidates, then only applies the returned canonical -`match` decisions. If the canonical engine is unavailable, records are -returned UNMERGED (fail closed against false identity merges). +/api/0pt/internal/er/candidates, then applies only explicit hard-rule `match` +decisions. Score/fuzzy decisions are review evidence only and can never merge +records in this adapter, even if an older or compromised bridge returns +`status=match, decided_by=score`. + +If the canonical engine is unavailable, records are returned UNMERGED (fail +closed against false identity merges). A canonical hard non-match is also a cluster-wide constraint: transitive matches are never allowed to pull two records with conflicting stable identity @@ -91,15 +95,18 @@ def _apply_matches(records: list[dict[str, Any]], pairs: list[dict[str, Any]]) - members.pop(drop, None) return True + # Defence in depth: only stable-identifier hard rules may create canonical + # identity links. Score/fuzzy "match" responses are ignored at the consumer + # boundary even if an upstream/legacy route emits one. match_pairs = [ p for p in pairs if p.get("status") == "match" + and p.get("decided_by") not in (None, "score") and p.get("record_a") in known_ids and p.get("record_b") in known_ids ] match_pairs.sort( key=lambda p: ( - 0 if p.get("decided_by") not in (None, "score") else 1, -float(p.get("score") or 0.0), str(p.get("candidate_id") or ""), )