Решили: INetTransport отвечает на вопрос «кто уже подключён» (ConnectedPeers), и все, кто ведёт список участников — состав сеанса, гейт готовности, — спрашивают его на старте, а не только слушают события.

Почему: транспорт поднимается на входе в игру, а сеанс рождается позже и пересоздаётся на каждой смене режима (меню → кампания → площадка). Всё, что жило только на событиях PeerConnected, было слепо к напарнику, пришедшему в этот промежуток: его не было ни в панели участников, ни в планке «(N/M)», и хозяин узнавал о нём лишь тогда, когда тот отключался. Отсюда же и жалоба «пишет, что игрок отключился, хотя о подключении не было ничего» — сообщение об уходе шло от транспорта, а список участников о нём и не знал. Отвергнуто «поднимать сеанс раньше транспорта» — порядок обратный по построению: роль сеанса называют при входе в режим, а транспорт нужен до того, чтобы принять приглашение из оверлея Steam. Отвергнуто «переигрывать события при рождении слушателя» — это журнал подключений, то есть второй владелец факта, который транспорт и так знает точно.

Грабли: у Steam-транспорта «есть _client» не значит «соединение стоит»: между ConnectRelay и OnConnected проходит время, поэтому гостевая половина ответа держится на отдельном признаке. Добавление участника обязано быть идемпотентным — событие о том же пире придёт следом, уже после опроса. У обеих половин оно и было (проверка в Add, HashSet в гейте), но полагаться на это молча нельзя: инвариант закрыт тестом.

Владелец правды: Net/Transport/INetTransport.cs (ConnectedPeers), Game/Session/Net/HostSessionRoster.cs, Game/Session/Net/HostReadyGate.cs; тест LateSessionSeesEarlyPeersTests.