| name | heater-appb-player-identity |
| description | Use when joining player data across tables, databases, machines, or services in HEATER — matching players by name, building PlayerRefs, syncing/relaying stats, headshots 404ing, a player showing another player's stats, or ids that don't line up between local and Railway. The "Muncy DNA" bug class. |
Player Identity Integrity — key on mlb_id, never on name, never on player_id across DBs
The invariant
Three identifier tiers:
mlb_id (MLBAM id) — the ONLY stable cross-machine, cross-DB key. Relays, enrichment, dialogs, headshots all key on it.
player_id — a LOCAL SQLite autoincrement. App B's Railway draft_tool.db and the local dev DB assign DIFFERENT player_ids to the same human. Valid only within one DB; fine for intra-request joins against the same pool.
- name — never a join key alone. MLB has real duplicates (two Max Muncys, two Will Smiths, three Luis Garcias).
The founding failure (Muncy DNA)
Name-only upsert/dedup collapsed LAD Muncy (mlb_id 571970) and ATH Muncy (mlb_id 691777) into one row; the engine served the wrong player's stats for a rostered player, and a one-shot migration kept being destroyed by the next bootstrap's name-only dedup. Fix (self-healing, PR #104 + 2026-05-21 evening): upsert_player_bulk matches by mlb_id first; deduplicate_players skips same-name groups with >1 distinct mlb_id. Guards: tests/test_no_dna_collision_dedup.py, test_players_name_lookup_case_insensitive.py (every WHERE name = ? on players uses COLLATE NOCASE), test_roster_sync_mlb_id_matching.py (name-only match to a different team than team_abbr → WARNING).
It recurred in App B: the /start-sit page joined candidate→verdict by mlbId/name — review caught it pre-ship; fixed to join on the authoritative HEATER player_id within the single response (WS3, 2026-06-27). Same-name players in one payload WILL collide on name.
Rules by situation
| Situation | Rule |
|---|
| Cross-machine relay (Statcast) | Payload keys on mlb_id; each machine maps mlb_id→its own player_id at upsert (src/statcast_relay.py:18-20, upsert 69-189 — also clear-replaces its 6 owned stat cols so stale rows can't linger) |
| Building API player objects | api/services/player_ref.py: make_player_ref (65-89) with _coerce_mlb_id (34-42: NaN/0/negative → None) + _coerce_team_abbr; player_ref_from_pool (92-139) prefers the pool's name/positions and degrades gracefully — never fabricate an mlb_id |
| Frontend | PlayerDialog keyed by mlbId, fetch-on-open with skeletonDetail fallback; PlayerLink emits `data-bubba-tag="name |
| statsapi lookups | statsapi.lookup_player needs an explicit season= (a prior-year id lookup silently misses without it — mlb_id backfill lesson); the person hydrate does NOT return currentTeam.abbreviation — resolve team via the team-id map (P0 caught live, 2026-07-02) |
| Ranking/relevance | Raw pool is ~9k rows incl. minor leaguers + test fixtures; rank only over player_rankings.fantasy_relevant_pool (25-73: MLB-level AND drafted<999 OR owned>0 OR real production) or scrubs outrank stars |
| Identity repair | protect_identity upsert + _enrich_player_identity bootstrap phase heal corrupted team=''/level='AAA' rows — don't hand-patch rows a bootstrap will overwrite |
When NOT to apply
Within one already-joined DataFrame from a single pool load, player_id is fine (same DB, same request). Team-entity (not player) naming has its own trap — emoji-prefixed Yahoo team names need resolve_viewer_team_name-style reconciliation, and legacy OAK/WSN codes need team_name_to_abbr+canonicalize_team — that's engine-seam territory (heater-appb-engine-composition).