## MODIFIED Requirements

### Requirement: Ручное закрытие загрузки (стоп-кран)

Система SHALL давать пользователю возможность **закрыть** загрузку — перевести
её в терминальное `cancelled`, убрав из активного списка/внимания, — из
**любого** состояния, кроме `deleted`. Страница загрузки веб-UI
(`/download/{id}`) SHALL предоставлять путь закрытия из каждого не-`deleted` и
не-`cancelled` состояния; список загрузок (`/`) ограничиваться действиями над
активными загрузками MAY. Загрузку в `deleted` закрывать система SHALL NOT
(состояние строго терминально).

Путей закрытия **два**, и они разведены гейтом терминальности:

- **«Отменить»/«Отклонить»** (`Cancel`) — отмена **активной** работы. Команда
  SHALL быть доступна только из не-терминальных состояний и SHALL отклоняться
  с конфликтом из терминальных, включая `cancelled`.
- **«Закрыть»** (`Dismiss`) — стоп-кран для загрузки, которую уже никто не
  двигает: зависшей, спорной или лишней (в т.ч. дубля-близнеца в
  `target_missing`, чьи файлы разложены другой загрузкой). Команда SHALL быть
  доступна из любого состояния, кроме `deleted`, включая терминальные, где
  `Cancel` уже отказывает. В `cancelled` `Dismiss` SHALL быть идемпотентным
  no-op **без записи состояния** — иначе он подменил бы `error_code` прежнего
  закрытия.

Раскладка путей по поверхностям нормативной **не является**: поверхность
выбирает путь по состоянию, и требовать от каждой поверхности **оба** пути
система SHALL NOT. Сегодня раскладка такова: страница загрузки веб-UI даёт
«Закрыть» в danger-зоне из терминальных состояний, кроме `deleted` и
`cancelled`, а из не-терминальных закрывает кнопкой «Отменить» (на экране
ревью — «Отклонить»); Telegram даёт «Закрыть» из `failed`, `stuck`, `done`,
`orphaned`, `target_missing`; «Отклонить» — на карточке ревью
(`review`/`deferred`). Кнопка закрытия в Telegram живёт на **карточке** задачи;
уведомления о готовности и о рассинхроне её сегодня не несут, а из `linking`
карточка не даёт действий вовсе. REST закрытие предоставлять MAY (сегодня
отдаёт только `cancel`).

Ни один путь закрытия SHALL NOT производить действий с файлами или библиотечными
ссылками: система SHALL NOT удалять либо создавать хардлинки под
`paths.movies`/`series` — в т.ч. из `done`/`orphaned` существующие библиотечные
ссылки сознательно остаются на месте. qBittorrent закрытие вызывать SHALL NOT —
раздача не снимается и продолжает раздаваться, — с **одним** исключением, и оно
привязано к состоянию, а не к команде: если закрытие **любым** путём увело
задачу из `catched` в окне между нашим `add` и записью перехода, worker убирает
только что добавленный торрент. Уборка нормирована в
`openspec/specs/download-tracking/spec.md`, требование «Добавление пойманной
загрузки в qBittorrent», и её границы задаёт оно — здесь они не пересказываются
и не расширяются. Синхронный source-preflight ни один из путей выполнять SHALL
NOT (источник в действии не участвует).

`error_code` перехода SHALL **различать** пути закрытия, а не унифицировать их.
`Dismiss` SHALL помечать переход `error_code = "user_dismiss"`
(человекочитаемая причина — в `error_msg`), отличая стоп-кран от отклонения на
ревью, от отмены активной работы и от удаления. Оба пути при этом SHALL замещать
прежнюю диагностику записи: `Dismiss` — на `user_dismiss` с причиной закрытия,
`Cancel` — сегодня на пустые `error_code`/`error_msg`; прежний код состояния
(`stalled` у `stuck`, `qbit_error` у `failed`) из записи пропадает безвозвратно.
Лог перехода закрытия его не дублирует: восстановить причину можно только по
более ранней строке перехода **в** `failed`/`stuck`, пока её держит ретенция
логов. Пустота кода на пути `Cancel` описана как сегодняшнее поведение, а не
закреплена нормативно: собственный непустой код у `Cancel` требованием не
запрещён.

Новый статус ни для одного из путей система вводить SHALL NOT —
переиспользуется существующее терминальное `cancelled` (сверка его не
переоценивает). Из `cancelled` пользователю остаётся доступной перепривязка
(relink), если он передумает; при этом `Undo` и `Delete` из `cancelled`
доступны SHALL NOT (их пол — `done`/`orphaned`/`target_missing`). Закрытие
разложенной загрузки поэтому оставляет её библиотечные ссылки без штатной
команды снятия, и **перепривязка их не снимает**: `Undo` и `Delete` работают
только с последним батчем раскладки, а повторное применение заводит новый.
Снять ссылки прежнего батча система средствами не даёт.

В интерфейсе «Закрыть» SHALL размещаться в отдельной «danger zone» (напр. внизу
страницы загрузки), обособленно от штатных действий, и SHALL быть отделена от
одиночного клика **дополнительным шагом**: раскрытием danger-зоны и явным
подтверждением там, где транспорт его поддерживает (диалог веб-UI при доступном
JS, второй шаг клавиатуры в Telegram). «Отменить»/«Отклонить» подтверждения
требовать SHALL NOT: команда отменяет работу, которая ещё идёт.

Отсюда следует **известное ограничение**: по `error_code` отличима «загрузка
закрыта стоп-краном» от «загрузка отменена активной», но не «пользователь
закрыл активную загрузку» от «пользователь отменил её» — оба пути на
не-терминальном состоянии дают один и тот же пустой код. Хуже того, маркер
сегодня кодирует не намерение, а поверхность: одно и то же состояние `stuck`
закрывается из веб-UI отменой (пустой код), а из Telegram стоп-краном
(`user_dismiss`). Цена — потеря различения в логах и диагностике; поведение и
данные не страдают. Восстановить различение можно, дав `Cancel` собственный
непустой код, — это требованием разрешено и оставлено открытым вопросом, а не
отвергнуто.

#### Scenario: Закрытие записи без цели не трогает раздачу

- **GIVEN** загрузка в `target_missing`: источник присутствует в qBittorrent,
  целевых хардлинков нет (напр. её файлы разложены другой загрузкой)
- **WHEN** пользователь даёт команду «Закрыть»
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
- **AND** раздача с файлами в qBittorrent не удаляется
- **AND** запись пропадает из активного списка

#### Scenario: Закрытие done оставляет библиотечные файлы на месте

- **GIVEN** загрузка в `done` с существующими библиотечными хардлинками
- **WHEN** пользователь даёт команду «Закрыть»
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
- **AND** библиотечные хардлинки не удаляются
- **AND** раздача в qBittorrent не снимается
- **AND** команды `Undo` и `Delete` на записи становятся недоступны, а
  «Привязать заново» остаётся доступной
- **AND** после перепривязки и повторного применения ссылки прежнего батча
  остаются в библиотеке — их не снимает ни `Undo`, ни `Delete`

#### Scenario: Стоп-кран замещает прежнюю диагностику состояния

- **GIVEN** загрузка в `failed` с `error_code = "qbit_error"` и текстом ошибки
  в `error_msg`
- **WHEN** пользователь даёт команду «Закрыть»
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
- **AND** прежний код и текст ошибки в записи не сохраняются
- **AND** лог перехода закрытия их не дублирует — причина отказа восстановима
  только по более раннему переходу в `failed`

#### Scenario: Закрытие не-терминальной загрузки из веб-UI идёт отменой

- **GIVEN** загрузка в не-терминальном состоянии (`downloading`, `recognizing`,
  `review`, `deferred`, `stuck`), её страница открыта в веб-UI
- **WHEN** пользователь нажимает «Отменить» (на экране ревью — «Отклонить»)
- **THEN** запись переходит в `cancelled` и пропадает из активного списка
- **AND** `error_code` и `error_msg` перехода пусты (маркера `user_dismiss` на
  этом пути нет)
- **AND** ни раздача в qBittorrent, ни хардлинки не трогаются

#### Scenario: Стоп-кран доступен там, где отмена уже отказывает

- **GIVEN** загрузка в терминальном состоянии, кроме `deleted` и `cancelled`
  (`done`, `failed`, `reverted`, `target_missing`, `orphaned`)
- **WHEN** пользователь даёт команду «Закрыть» и подтверждает её
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
- **AND** команда `Cancel` на том же состоянии отклоняется с конфликтом

#### Scenario: Один и тот же stuck закрывается разными путями с разных поверхностей

- **GIVEN** загрузка в `stuck`
- **WHEN** пользователь закрывает её из Telegram кнопкой «Закрыть»
- **THEN** запись переходит в `cancelled` с `error_code = "user_dismiss"`
- **AND** та же загрузка, закрытая со страницы веб-UI кнопкой «Отменить», даёт
  `cancelled` с пустым `error_code` — по коду перехода поверхности не
  различить от намерения

#### Scenario: «Закрыть» недоступна для deleted

- **GIVEN** загрузка в `deleted`
- **WHEN** пользователь пытается вызвать «Закрыть»
- **THEN** команда недоступна, состояние остаётся `deleted`

#### Scenario: Повторное закрытие не подменяет причину прежнего

- **GIVEN** загрузка в `cancelled`, закрытая ранее отменой (пустой `error_code`)
- **WHEN** приходит команда «Закрыть»
- **THEN** команда проходит no-op'ом, записи состояния не происходит
- **AND** `error_code` остаётся прежним (пустым), а не подменяется на
  `user_dismiss`
- **AND** команда «Отменить» на том же состоянии отклоняется с конфликтом

#### Scenario: Закрытую запись можно привязать заново

- **GIVEN** запись, закрытая командой «Закрыть» в `cancelled`
- **WHEN** пользователь даёт команду «Привязать заново»
- **THEN** запись уходит на перераспознавание с ручным подтверждением (как relink
  из `cancelled`)
