Прод 09.08.2026, пара (1, cian): строка `banned:cian, ban_count=1, до 00:21`
после упавшей пробы стала `probe:browser, ban_count=2, до 07:43`. ON CONFLICT
DO UPDATE в mark_banned переписывал reason безусловно, поэтому проба забирала
чужую строку себе.
Фильтр «снимаю только своё» (clear_source_bans(only_reason=...)) при этом цел и
работает — удалить чужую строку проба не может. Но присвоенная строка уже
«своя», и следующий зелёный robots.txt снял бы ею бан, поставленный боевым
сбором по настоящему отказу площадки. Плюс метка перестаёт быть свидетельством:
'banned:cian' («площадка нас отбила») и 'probe:browser' («наша проба не смогла»)
— разные факты (ловушка #2764), а ban_count складывает события разного рода в
одну эскалацию: отдых пары вырос с 6 ч до 12 ч на счётчике, который заработан
не был.
Правило теперь в WHERE у DO UPDATE: строку берём, только если она истекла
(живого владельца нет), либо она уже наша, либо мы боевой сбор. Продление чужого
бана вместо «ничего не делать» отвергнуто: срок пересчитывается от now() по
НАШЕЙ эскалации и способен укоротить уже эскалированный чужой бан.
Асимметрия намеренная: боевой сбор строку пробы перехватывает. Его вердикт
сильнее, пара остаётся забаненной, метка становится точнее. Запрет и ему оставил
бы строку за пробой — и её же успех снёс бы настоящий бан площадки, то есть тот
же дефект зеркально. ban_count при перехвате наследуется: обнуление на смене
владельца стирало бы память об эскалации боевых банов.
Диагностика: 0 rows у апсерта теперь означает три разные вещи (чужой владелец /
защита последнего узла / нет узла) — mark_banned возвращает исход строкой и
пишет отдельный лог на каждый, а счётчик pair_banned больше не объявляет баном
то, чего не записал.