## 1. Дедуп: reingest-blocking states (предотвращение дубля) - [x] 1.1 В `internal/store/download.go` ввести множество reingest-blocking состояний (active ∪ `{target_missing, orphaned}`) и **отдельный** метод поиска по любому из хешей (напр. `FindReingestBlockingByInfohash`) с приоритетом активной записи над desync-записью. НЕ расширять общий `findActiveByInfohash` — на нём стоят `ActivateIfNoOtherActive`/ `AddInfohashes`, где «активная» ОБЯЗАНА значить строго не-терминальную (иначе ломается инвариант «≤1 активной»). Desync-проверка — устойчивый пред-рид ДО create, активный гард (`CreateDownloadIfNoActive`) остаётся про строго активные. - [x] 1.2 В `internal/ingest/ingest.go` (`Ingest`/`attached`) при найденной desync-записи (`target_missing`/`orphaned`) вернуть её как `Deduplicated` «спящей»: без перевода в активное, без обращения к qBittorrent; добавить в результат приёма признак «требует relink/закрытия». - [x] 1.3 Убедиться, что донесение недостающих хешей и атомарный гард создания (`CreateDownloadIfNoActive`) не крадут хеш у другой активной загрузки и что гонка двух приёмов на свежую desync-запись не рождает близнеца. - [x] 1.4 Обновить ответы транспортов при attach на desync-запись: бот (`internal/tgbot`) и HTTP/веб — текст «существует как #id без цели — привяжите заново или закройте». ## 2. Команда «Закрыть» (dismiss) — универсальный стоп-кран - [x] 2.1 В `internal/store/download.go` добавить рёбра `allowedTransitions` в `cancelled` из терминальных `done`/`failed`/`reverted`/`target_missing`/ `orphaned` (у нетерминальных цель уже есть; `deleted` исключён). Обновить граф-тесты (`internal/store/transition_test.go`): (а) РАСЩЕПИТЬ `TestCancelDeferReachableFromEveryNonTerminal` — `cancelled` теперь цель из любого состояния кроме `deleted` (в т.ч. терминальных), `deferred` — по-прежнему только из не-терминальных (не расширять общий цикл наивно, иначе ложно потребует `deferred` из терминалов или оставит дырявый гард); (б) в `TestSetStateAllowsDeclaredEdges` добавить новые терминал→`cancelled` рёбра (переход обычным `SetDownloadState`, без `ActivateIfNoOtherActive`). - [x] 2.2 В `internal/worker/review.go` реализовать **отдельный** обработчик «Закрыть» (не ветка `Cancel`, который отклоняет терминальные): перевод `<любое, кроме deleted> → cancelled` с `error_code = "user_dismiss"` и причиной в `error_msg`/логе. Только setState: qBittorrent не вызывать, хардлинки не трогать (в т.ч. из `done`/`orphaned`), source-preflight не выполнять; из `deleted` — отказ; из `cancelled` — короткозамкнуть без `setState` (иначе идемпотентный no-op перезапишет `error_code`, подменив причину прежнего `Отклонить`). - [x] 2.3 Веб-UI: отдельная danger zone внизу страницы загрузки с кнопкой «Закрыть» (доступна из любого состояния, кроме `deleted`; htmx, деградация без JS, ошибка на htmx-пути = 200 + фрагмент). Рассмотреть подтверждение для `done`/терминальных. - [x] 2.4 Telegram (`internal/tgbot`): добавить действие «Закрыть» для загрузок. ## 3. Спеки, документация, проверка - [x] 3.1 Обновить описание статусов/переходов в `docs/specs/database.md` (рёбра `<терминальные> → cancelled`, `error_code = "user_dismiss"`). - [x] 3.2 `task test` и `task lint` зелёные; добавить тесты: дедуп-attach на `target_missing`/`orphaned`, повторный приём после `cancelled`/`done` заводит новую, команда «Закрыть» переводит в `cancelled` из разных состояний и НЕ трогает qBittorrent/хардлинки (в т.ч. из `done`), отказ из `deleted`. - [x] 3.3 `openspec validate dedup-target-missing-and-dismiss --strict`.