---
title: Track duplicate-person-rows detection and close the merge-side gate gap
type: task
status: completed
description: >-
  Tracks the recurring duplicate-person-rows data check in the league database and closes the gap
  that lets a merge or birth-date repair mint the next round of duplicate shells.
base_uri: user:task/league/track-duplicate-person-rows-detection.md
created_at: '2026-08-29T18:41:10.209Z'
entity_id: 09f1a0e6-8017-46bf-a455-fb7ea25210c0
finished_at: '2026-08-29T20:52:47.908Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
priority: Low
public_read: true
tags:
  - user:tag/league-xo-football.md
updated_at: '2026-08-29T20:52:47.908Z'
visibility_analyzed_at: '2026-11-05T23:50:33.018Z'
---

## Context

The `duplicate-person-rows` registered check (db/checks/registry.mjs) fired signals 126936 and 127175; round 6
(2026-08-29-dedupe-duplicate-person-rows-round-6.sql, applied) merged the last 11 true duplicates and the
check now grades clean (0 unparked findings, 0 stale-parked, 17 parked adjudicated shells). The class recurs
by construction -- repairing corrupt sentinel birth dates makes further pairs agree on a date they never
agreed on, so each repair can mint the next round's shell.

The registry invariant stated the class has "NO detector on any surface -- no audit script, no gate, no
monitoring script grades it." That was demonstrably stale: the registered check grades this class (it is
what surfaced 126936) and rejects unresolved findings on the `data-check-findings-duplicate-person-rows`
fingerprint.

## Plan

1. **Done** (`aba4dadd8`) -- corrected the invariant wording in db/checks/registry.mjs. The false
   "no detector" claim is replaced with the real gap: `audit_player_row_merges` cannot run after the
   fact, so the dated-SQL repair path has to carry the losslessness proof in its own steps.
2. **Done, redesigned.** The original item -- wire merge-side parity with the conflated-identity class --
   was rejected on validation and replaced with a documented contract in `repair_command`. Two independent
   grounds: docs/guides/gates.md names db/gates/check-conflated-player-rows.mjs as an anti-pattern (a
   standing data condition in the cluster gates, deferring every session's push), and a push-time gate
   cannot audit a merge at all, since the audit compares against per-table counts captured before the
   write. The gap was also smaller than assumed -- round 6 already carries the proof in-transaction. So
   `repair_command` now points at round 6 rather than round 5 and names the five steps that constitute the
   proof, making the shape the contract.
3. **Done.** Watcher retired: channel `league-duplicate-person-row-merge` archived, scratch directory
   removed. No watcher process was running.
4. **Done.** Measured directly against production 2026-08-29: 17 candidate shells, all 17 parked,
   0 unparked, 0 stale-parked, 28,796 player rows. The on-host cron run is the ongoing guard, not a
   step to trigger.

## The interrupted run's 15 merges, audited

The 2026-08-28 `--apply` died after landing 15 of its 26 planned merges, and the payload marked those 15
unverified. They are now verified. The apply log had printed all 26 plans before the process died, so the
survivor, donor, before-reference totals and donor birth date were all recoverable, and three of the four
`audit_player_row_merges` invariants could be checked retroactively against production:

- No surviving fold -- every donor pid is gone from `player` and referenced by none of the 147
  pid-carrying tables.
- Reference conservation -- every survivor holds at least the sum the two halves held, with the entire
  excess (1 to 5 rows) accounted for by `player_changelog` growth, which the audit permits.
- Birth date -- every survivor carries the donor's real `date_of_birth`, not the `0000-00-00` sentinel.
  This is the defect the fold exists to unwind, and the one a bad merge produces.

Only per-column union-of-values across the full row is unrecoverable, since the log printed reference
totals rather than row snapshots.

```
ANGE-CRAI-017031 -> ANGE-CRAI-024657    DEAN-WRIG-015773 -> DEAN-WRIG-015768
BENJ-PITT-009807 -> BENJ-PITT-012623    DEMA-MINT-003764 -> DEMA-MINT-024562
BERN-LAYX-010157 -> BERN-LAYX-016185    DOUG-DATI-020353 -> DOUG-DATI-024609
BLAK-SCHL-019052 -> BLAK-SCHL-007958    GEOR-BUSS-016192 -> GEOR-BUSS-016463
BOBB-FOST-001083 -> BOBB-FOST-024697    JAME-MOND-021265 -> JAME-MOND-021349
CODY-BROW-009350 -> CODY-BROW-019783    JAYX-MOOR-004672 -> JAYX-MOOR-024619
DACO-RODG-008591 -> DACO-RODG-003113
DANX-BAZU-002650 -> DANX-BAZU-024611
DARR-RICH-020084 -> DARR-RICH-024706
```

## Out of scope

Re-adjudicating parked shells; the 17 parked pairs are adjudicated different people and must stay parked.
