## 1. Фундамент: пакет ident

- [x] 1.1 Добавить зависимость `github.com/oklog/ulid/v2`; пакет
      `internal/ident`: `NewID()` (lowercase, monotonic entropy,
      потокобезопасно), `NewIDAt(t time.Time)` (для миграции/бэкфилла),
      `Parse(s)` (нормализация регистра + валидация); тесты
      (lowercase, сортируемость, отказ на мусоре)

## 2. Схема и миграция

- [x] 2.1 Механизм Go-миграций goose в `internal/store/migrations`
      (регистрация через `goose.AddMigrationContext`, совместный прогон с
      embed SQL-миграциями)
- [x] 2.2 Миграция 0006: новые таблицы с TEXT ULID PK (все шесть), перенос
      данных с маппингом `int → ULID` (timestamp из `created_at`, fallback —
      время миграции), перенос `download.infohash` → `download_infohash`
      (lowercase, `kind` по длине hex), удаление `download.infohash` и
      `download.idempotency_key`, пересоздание индексов
- [x] 2.3 Тест миграции на фикстурной БД: FK-связи сохранены, порядок по
      `id` = порядок по `created_at`, хеши разнесены, `idempotency_key`
      отсутствует

## 3. Store

- [x] 3.1 Типы id `int64 → string` во всех структурах и методах `store`
      (download, recognition, hint, override, metadata_candidate,
      file_link, list); генерация ULID через `ident.NewID()` во ВСЕХ
      Create-методах (вместо `LastInsertId`)
- [x] 3.2 Guarded-методы инварианта (design D4):
      `CreateDownloadIfNoActive` и `ActivateIfNoOtherActive`, каждый — одна
      write-транзакция; `FindActiveByInfohash`/`ExistsByInfohash` join'ом
      по любому хешу (для чтения); убрать CASE-восстановление
      `idempotency_key` из `SetDownloadState`
- [x] 3.3 Методы хешей: добавить недостающие хеши загрузке
      (INSERT OR IGNORE), получать хеши вместе с Download (срез в модели)
- [x] 3.4 Поиск в списке (`listWhere`): `EXISTS`-подзапрос по
      `download_infohash` вместо `LIKE` по удаляемому `download.infohash`

## 4. Ядро и воркер

- [x] 4.1 Приём (`ingest`) и discover-adopt — через
      `CreateDownloadIfNoActive`; хеш из magnet (btih ИЛИ btmh, `kind` по
      длине hex) пишется в той же транзакции; сигнатуры
      `Result.DownloadID`, `notifyFailed`, `Notifier.Notify`,
      `failNotified` — на string
- [x] 4.2 Поллинг/сверка: сопоставление раздачи по любому из хешей загрузки
      (Poll, desync, recovery, preflight); дописывание недостающих v1/v2,
      когда qBittorrent отдаёт оба; scoped-логгеры — первый известный хеш
- [x] 4.3 Retry, recovery-воскрешение и Relink — через
      `ActivateIfNoOtherActive` (сейчас гонку закрывал unique-индекс —
      см. design D4); понятная ошибка при занятом хеше
- [x] 4.4 `apply_batch_id` генерировать через `ident.NewID()`

## 5. Внешние границы

- [x] 5.1 `httpapi`: `ident.Parse` в `pathID` (невалидный → 404 без похода
      в БД) и для `candidate_id` из формы ревью; сентинелы
      `downloadID > 0 → != ""`; REST DTO: `id` string, `infohash` →
      список `infohashes`; карточка показывает все хеши с копированием;
      live-лукап по любому хешу; проверить шаблоны и ссылки
- [x] 5.2 Telegram (`tgbot`): `parseCallback` и callback-data на string-id,
      `pending map[int64]int64 → map[int64]string`, сентинел `id == 0 →
      == ""`, ссылки `/review/{id}`; понятный ответ на устаревшую
      callback-data со старым числовым id

## 6. Логи и документация

- [x] 6.1 Атрибуты `<entity>_id` в логах: `recognition_id` у попыток
      распознавания, `batch_id` у раскладки; сверить с scoped-логгером
- [x] 6.2 `docs/conventions/logging.md`: примеры id в формате ULID, grep по
      голому id как штатная корреляция
- [x] 6.3 Новая `docs/conventions/database.md` (TEXT ULID PK, без
      AUTOINCREMENT, естественные ключи у деталей, lowercase + нормализация);
      ссылки из `docs/conventions/README.md` и `CLAUDE.md`
- [x] 6.4 ER-схема `docs/specs/database.md`: ULID PK, `download_infohash`,
      удалённые столбцы

## 7. Проверка

- [x] 7.1 `task test` и `task lint` зелёные; ручной прогон: приём magnet →
      дедуп повторного приёма → страница `/download/{id}` с ULID в URL
