---
title: Correct merge name-field selection and make a pid rewrite traceable
type: task
status: completed
description: >-
  The JS field-merge picks name columns by longest string, preferring the legal name over the
  known-by one, and update_player_id records no pid rewrite so a JS-path merge erases the donor pid
  untraceably — 20 approve-set pids already have zero trace.
base_uri: user:task/league/correct-merge-name-field-selection-and-pid-traceability.md
created_at: '2026-09-07T15:17:24.898Z'
entity_id: d8f78e83-4d27-4407-a34f-e4b0eed4902f
finished_at: '2026-09-07T16:26:20.172Z'
observations:
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Medium
public_read: true
relations:
  - relates [[███████████████████████-██████-████-███████-█████████]]
  - relates [[█████████████████████-█████████-███████-███████-█████-████-█████████]]
  - follows [[user:guideline/design-toward-clean-end-state.md]]
tags:
  - user:tag/league-xo-football.md
  - user:tag/software-task.md
updated_at: '2026-09-07T16:26:20.172Z'
visibility_analyzed_at: '2026-11-05T23:48:38.847Z'
---

# Implementation Plan: Correct Merge Name-Field Selection and Make a Pid Rewrite Traceable

## Overview

- Two independent defects in the player merge path, both found on 2026-09-07 while resuming the common-name work: the JavaScript field-merge picks name columns by longest string, and a pid rewrite through the JavaScript path leaves no record that the donor pid ever existed.
- **Impact.** The first silently prefers the legal name over the known-by name on any JS-path merge — `Robert` beats `Bobby`, `Christopher` beats `Chris`, `Michael` beats `Mike` — which is exactly the defect [[███████████████████████-██████-████-███████-█████████]] exists to remove. The second has already cost evidence: 20 pids in the 274-cluster approve set are absent from `player` with zero trace anywhere in `player_changelog`, so what happened to them is now unrecoverable.
- **Ordering.** Independent of the common-name plan; neither blocks the other. Relevant to [[█████████████████████-█████████-███████-███████-█████-████-█████████]], whose remaining tranches should run after this lands so their pid rewrites are recorded.

## Background

**Two merge implementations disagree on the same question.**

- `libs-server/merge-player.mjs:29-65` (`merge_player_row_fields`) resolves a two-value conflict by keeping the longer string, its own comment reading "select present values, or longest string, or largest number".
- The dated adhoc SQL the campaign actually runs — `db/adhoc/2026-08-17-merge-nickname-legal-name-player-rows.sql` step 9, inherited from round 5 — fills the survivor by additive `coalesce`, which **never** overwrites a value the survivor already holds.

The SQL rule is the correct one and the campaign has been relying on it. Its 2026-08-18 run over 77 survivors kept the known-by name every time: `PATX-COOG-030516` survives as `Pat` with the `Patrick` row deleted. The survivor is chosen deliberately — that file's header records overriding its own most-dependents rule specifically "to keep a factually-wrong donor name off the surviving row" — and the longest-string rule would throw that adjudication away.

**Longer does not mean more complete for a name.** It is a bias rather than a rule, which makes it worse than a consistent error: `Hollywood` is longer than `Marquise`, so the outcome is unpredictable per player.

**The pid rewrite leaves no breadcrumb.** `libs-server/update-player-id.mjs` never imports `record_changelog`. It repoints every child row, including `player_changelog` itself, from the donor pid to the survivor — so after the donor row is deleted, no row anywhere still carries the donor pid string. The adhoc SQL compensates by hand, inserting a `('pid', drop_pid, keep_pid)` changelog row explicitly. Merges that skipped the SQL path did not.

### Baselines

Measured 2026-09-07 against `league_production`.

| Figure | Value | Source |
| --- | --- | --- |
| Approve pairs with exactly one side already absent from `player` | 69 of 278 | `approve-pairs.txt` left-joined to `player` |
| ...traceable to a recorded pid rewrite | 49 | `player_changelog` where `column_name = 'pid'` and `previous_value` is the absent pid |
| ...with no trace anywhere | 20 | same, no match; and zero `player_changelog`, `player_gamelogs` or `previous_value` rows under those pids |
| Live approve pairs whose two rows hold different `first_name` | 18 of 209 | both sides present, `first_name is distinct from` |
| ...where longest-string picks the legal name | 16 of 18 | length comparison on those pairs |
| `first_name` overwrites toward a longer string ever recorded by a merge source | 2 | `player_changelog` grouped by source, both sides non-null |

### Assumptions

- **The survivor's name is the adjudicated one.** Checked: the adhoc header at `db/adhoc/2026-08-17-merge-nickname-legal-name-player-rows.sql:26-39` records overriding the survivor rule in named pairs to keep a wrong donor name off the survivor.
- **`short_name` and `formatted_name` are derived, not merged.** Checked: `libs-server/update-player.mjs:17` already refuses caller-supplied `formatted_name` via `excluded_props`; `short_name` is not refused and is currently merged by length.
- **No caller depends on longest-string for a name column.** To check in the first step — `merge_player_row_fields` is exported for its spec and called from `merge-player.mjs` plus the `db/adhoc` mjs migrations.
- **The 20 untraceable pids are unrecoverable, not merely unsearched.** Checked: zero rows under those pids in `player_changelog` and `player_gamelogs`, and zero occurrences as any `previous_value`.

## Design

**Converge both paths on the additive rule for names, and record every pid rewrite.**

**Name columns stop being length-decided.** `merge_player_row_fields` keeps the survivor's `first_name` and `last_name` whenever the survivor holds one, and takes the donor's only to fill an absence. That is the SQL path's semantics, so the two implementations stop disagreeing rather than one being taught to imitate the other.

**Derived name columns leave the merge payload entirely.** `short_name` and `formatted_name` are not merged at all; `update-player.mjs` recomputes them, which is the choke point [[███████████████████████-██████-████-███████-█████████]] installs. A merged row that carried a donor `short_name` alongside a survivor `first_name` is the self-inconsistency both plans are removing.

**`update_player_id` records the rewrite.** One `record_changelog` call writing `('pid', current_pid, new_pid)` against the surviving pid, matching the row the adhoc SQL writes by hand — so the breadcrumb no longer depends on which path ran, and the hand-written insert can come out of the next adhoc.

**Why not a general "prefer the shorter string" rule for names.** It is the same kind of guess in the other direction and it is wrong for `Hollywood` over `Marquise`. The survivor choice is a human adjudication that already happened; the merge's job is to not discard it.

**This change coins no new identifiers.**

**Knowledge Base Impact.** The corrected rule belongs in the `merge_player_row_fields` comment, which is where the wrong rule is currently documented in its own words. No `text/` entity describes this surface.

**Orchestrator shape.** Not warranted.

## Plan

### Correct the Field-Merge Rule

- [x] **Enumerate every caller of `merge_player_row_fields`**
  - Purpose: the rule change is behavioural, and the `db/adhoc/*.mjs` migrations that cite it in their comments may depend on the old behaviour.
  - Verify: `rg merge_player_row_fields` at repo scope, not `libs-server/` scope; each hit is read and classified as affected or not.

- [x] **Keep the survivor's name and fill only an absence**
  - File: `libs-server/merge-player.mjs`
  - Changes: name `first_name` and `last_name` as additive-fill columns so the survivor's value wins when both rows hold one; drop `short_name` and `formatted_name` from the merged payload so `update-player.mjs` derives them; rewrite the rule comment, which currently states the wrong rule in its own words.
  - Verify: extend `test/libs-server.merge-player.spec.mjs` with a survivor-holds-`Bobby` / donor-holds-`Robert` case asserting the merged row holds `Bobby`, and an absent-survivor-name case asserting the donor's fills it. The first must fail against the current implementation — run it before the change to confirm it does.

- [x] **Confirm the two paths now agree**
  - Purpose: the point of the change is one rule, not two that happen to match today.
  - Verify: apply both rules to the 18 live divergent-`first_name` approve pairs and assert an identical survivor name from each. A disagreement is the finding.
  - Result 2026-09-07: agreed on all 18, in both survivor orientations — 144 assertions, zero disagreements, covering `first_name`, `last_name` and the absence of `short_name`/`formatted_name` from the payload. The negative control run alongside it, the old longest-string rule against the same rows, disagreed 19 times out of 36, so the check is not vacuous. Script: `scratch/dedupe-nflverse-anchor/confirm-merge-rules-agree.mjs` in the league checkout.

### Make a Pid Rewrite Traceable

- [x] **Record the pid rewrite in `update_player_id`**
  - File: `libs-server/update-player-id.mjs`
  - Changes: call `record_changelog` with `column_name: 'pid'`, `previous_value: current_pid`, `new_value: new_pid` against the surviving pid, with the caller's source.
  - Verify: a spec merging two fixture rows and asserting exactly one `('pid', donor, survivor)` changelog row survives; it must fail with the call removed. Then confirm the count of untraceable absent pids in the approve set stops growing — it is 20 today.

- [x] **Retire the hand-written insert from the adhoc shape**
  - Purpose: leaving both writes in place double-records every future SQL-path merge.
  - Changes: the rule is now stated at the `record_changelog` call in `libs-server/update-player-id.mjs` — a pure-SQL merge never reaches that function and must keep its own insert; an adhoc routing through `update_player_id` or `mergePlayer` must not also write one. No compat branch was added.
  - Result 2026-09-07: no live caller does both, so nothing had to be removed. `player_changelog` holds zero duplicate `(column_name = 'pid', previous_value)` pairs, and every recorded pid rewrite carries either the `sleeper` rekey source (the 2026-07 pid redesign) or one of eight dated SQL adhoc sources — no JS-path merge has ever written one, which is the defect this task fixed.

- [x] **Record the 20 untraceable pids as unrecoverable**
  - Purpose: they are evidence of the defect, and a future session will otherwise spend the same hour rediscovering that no trace exists.
  - Changes: list them in an observation on [[█████████████████████-█████████-███████-███████-█████-████-█████████]] with the three queries that came back empty.
  - Verify: the observation names the pids and the queries, not just the count.

### Testing and Validation

- [x] **Suite and gates**
  - Verify: `yarn test:isolated`, `yarn lint`, `yarn check:types`, then `yarn verify` against the committed sha.
  - Result 2026-09-07: full suite green (7,873 passing, zero failing), lint and types clean. `yarn verify` is satisfied by the guard's own verdict rather than a re-run: `693067dfc` is an ancestor of `2f73ab011`, which carries a `pass` verdict in the machine-owned verify state, and re-running over the shared tree would have tested six sibling commits stacked above it.
