## Контекст

Ядро выбора источника уже есть: `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:<id>`. Она не трогает домен — через
`editMarkup` разворачивает клавиатуру списка кандидатов:

- по одной кнопке на кандидата (full-width row), текст `provider · Title (Year)`,
  у выбранного (`Chosen`) — префикс «✓ »; дедуп по `provider:provider_id`, потолок
  кнопок (кандидатов и так ≤ `maxCandidates`=8);
- callback кандидата — `pick:<downloadID>:<candidateID>`;
- нижняя кнопка «← Назад» с callback `srcback:<id>` — возвращает карточку
  (`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) и плодит сообщения.
