## Контекст Ядро выбора источника уже есть: `worker.ChooseCandidate(ctx, id, candidateID)` пиннит кандидата (override provider/id/title/year/director), помечает `chosen`, обновляет display_name — но **не** запускает раскладку (человек подтверждает «Применить»). Веб-транспорт зовёт её из `handleChooseCandidate`, валидируя `candidate_id` через `ident.Parse`. В боте механики выбора нет. Карточка ревью в боте (`reviewCard`/`reviewKeyboard`) уже двухшаговая для необратимых действий (delete/dismiss): первая кнопка меняет только клавиатуру (`editMarkup`), подтверждение зовёт домен и обновляет карточку (`refreshCard`). Тот же паттерн переиспользуем для выбора кандидата. ## Решение **Поверхность.** В `reviewKeyboard`, когда `len(rd.Candidates) > 0`, добавляем кнопку «🗂 База (N)» с callback `sources:`. Она не трогает домен — через `editMarkup` разворачивает клавиатуру списка кандидатов: - по одной кнопке на кандидата (full-width row), текст `provider · Title (Year)`, у выбранного (`Chosen`) — префикс «✓ »; дедуп по `provider:provider_id`, потолок кнопок (кандидатов и так ≤ `maxCandidates`=8); - callback кандидата — `pick::`; - нижняя кнопка «← Назад» с callback `srcback:` — возвращает карточку (`refreshCard`), домен не трогает. **Обработка callback (`handleCallback`).** - `sources` → `answer("")` + `editMarkup(candidatesKeyboard(rd))` (перечитываем `ReviewData` ради свежего списка), домен не трогаем; - `srcback` → `answer("Отменено")` + `refreshCard` (как `delete_cancel`); - `pick` → `candidateID` из `val` (третий сегмент, `parseCallback` уже отдал его как `value`) валидируем `ident.Parse` (невалидный → `answer("Кнопка устарела…")`, состояние не меняем), затем `reviewer.ChooseCandidate(ctx, id, candidateID)`; при успехе `answer("Источник выбран") + refreshCard`, при ошибке — общая ветка ошибки. **Интерфейс.** В `Reviewer` (bot.go) добавляем `ChooseCandidate(ctx, id, candidateID string) error`; фейк в тестах дополняем. ## Границы и безопасность - **Недоверенный вход:** `candidate_id` из `callback_data` валидируется как ULID на границе транспорта (как в вебе). Принадлежность кандидата текущему распознаванию доменно проверяет сам `ChooseCandidate` (не наш кандидат → `ErrInvalidInput`). - **Авто-раскладка не двигается:** `ChooseCandidate` только пиннит источник; раскладка — отдельным «Применить». Инвариант «авто только по подтверждённому матчу» не затрагивается. - **Экранирование:** текст inline-кнопок Telegram НЕ парсится как HTML — инъекция разметки из названия кандидата в кнопке невозможна; усечение длинных названий — косметика. `sources`/`srcback` меняют только клавиатуру; `pick` перерисовывает и текст карточки (`refreshCard` → `baseLine` с новым provider/id), но новых **неэкранированных** фрагментов не появляется — `baseLine` уже экранирует provider/id/URL (действующее требование «Показ записи матча…»). ## Бюджет callback_data Лимит Telegram — 64 байта. Худший: `pick:<26>:<26>` = 4+1+26+1+26 = 58 ≤ 64. `sources:<26>` = 34, `srcback:<26>` = 34. Запас есть. ## Отвергнутые альтернативы - **Показывать «без базы» (нейтраль) и ручной ввод id в боте** — это точные правки, зона веба (`ClearProvider`/`SetProviderID`); в боте раздули бы интерфейс и клавиатуру. Бот — быстрый выбор из готового списка кандидатов. - **Отдельное сообщение со списком вместо editMarkup** — расходится с уже принятым двухшаговым паттерном (delete/dismiss) и плодит сообщения.