## Context Приём дедуплицирует входящий источник по активной задаче двумя путями: 1. **Быстрый чек** — `Ingest` вызывает `FindActiveByInfohash`, при попадании уходит в `attached()` (дозапись недостающих хешей через `AddInfohashes`). Это основной путь. 2. **Гонка** — быстрый чек пуст, но пока выводили имя/готовили запись, активная задача появилась; `CreateDownloadIfNoActive` внутри своей транзакции находит её и возвращает как дедуп (дозапись хешей внутри той же tx). F1 живёт в пути 2 (неохраняемая дозапись). F6 задевает ОБА пути: в реальном сценарии первым отрабатывает быстрый чек (путь 1), поэтому апгрейд обязан работать и там. ## F1 — пер-хеш гард дозаписи в дедуп-ветке **Решение.** В дедуп-ветке `CreateDownloadIfNoActive` заменяем безусловный цикл `INSERT OR IGNORE` на тот же гард, что в `AddInfohashes`: для каждого хеша `h` проверяем `findActiveByInfohash(ctx, tx, {h}, existing.ID)`; если другая активная задача владеет `h` — пропускаем (не дописываем), иначе `INSERT OR IGNORE`. Транзакция уже открыта, `excludeID = existing.ID` исключает саму дедуп-цель (её собственные хеши не конфликтуют с ней самой). **Почему пропуск, а не ошибка.** Дедуп по контракту не падает — возвращает существующую задачу. Конфликтный хеш принадлежит другой активной задаче; молча его не трогаем — это и есть сохранение инварианта. В отличие от `AddInfohashes`, который сигналит `ErrInfohashTaken` вызывающему (там это осмысленно), у дедуп-ветки наблюдаемого канала ошибки нет и он не нужен: поведение — «присоединиться к найденной задаче, чужое не красть». ## F6 — апгрейд catched-magnet до torrent **Решение.** Новый guarded-метод хранилища: ``` UpgradeCatchedMagnetToTorrent(ctx, downloadID string, torrentBlob []byte) (bool, error) ``` в одной write-транзакции: 1. Пустой `torrentBlob` → `(false, nil)` (защитный no-op). 2. Гардированный UPDATE: `UPDATE download SET source_type='torrent', updated_at=? WHERE id=? AND source_type='magnet' AND state='catched'`. `RowsAffected==0` → задача не подходит (уже `downloading`/отменена/не magnet) → коммит без эффекта, `(false, nil)`. 3. `RowsAffected==1` → `INSERT OR REPLACE INTO download_torrent(download_id, data)` (у magnet блоба нет; `OR REPLACE` — страховка идемпотентности), коммит, `(true, nil)`. **Почему гард `state='catched'`.** Апгрейд имеет смысл только пока worker ещё не отдал источник в qBittorrent. В `catched` worker на шаге добавления выбирает способ по `source_type` (`internal/worker/worker.go:sourceAddParts`): после смены на `torrent` он добавит файлом — метаданные приедут сразу. Если задача уже `downloading`, magnet давно в qBittorrent (застрял в metaDL), и смена `source_type` его не переотдаст; это отдельная забота retry/desync, не приёма. Тот же паттерн ре-валидации, что у `PromoteCatched`: если задачу успели отменить, UPDATE не заденет строк и апгрейд просто не применится. **Где вызываем.** Оба дедуп-пути `Ingest` сводим к `attached()` (в ветке гонки `existing` от `CreateDownloadIfNoActive` уже с подгруженными хешами, так что переиспользование безопасно). В `attached()` после дозаписи хешей: если входящий источник — `torrent` и есть байты, зовём `UpgradeCatchedMagnetToTorrent`. Вызов best-effort: ошибка/неуспех логируются `Warn`/`Info`, приём не валится (как и дозапись хешей). Результат приёма по-прежнему несёт `State` существующей задачи (остаётся `catched`) — меняется лишь способ будущего добавления. **Идемпотентность и повтор.** Повторный `.torrent` того же хеша: первый апгрейд перевёл задачу в `torrent`, гард `source_type='magnet'` на втором даст `RowsAffected==0` → no-op. Байты уже сохранены при создании torrent-ветки — `OR REPLACE` перезапишет теми же данными без вреда. ## Границы Схему не трогаем: `download_torrent` и колонка `source_type` уже есть. ER-схема `docs/specs/database.md` без изменений. Апгрейд — операция над данными.