# state-reconciliation Specification

## Purpose

Сверка записанного состояния загрузки с фактом на файловой системе и в
qBittorrent. Capability описывает периодическую и принудительную проверку
присутствия **источника** (раздача в qBittorrent) и **цели** (разложенные
хардлинки), вывод состояний рассинхрона (`target_missing`/`orphaned`/
`deleted`) из матрицы «источник × цель», их переходы и самовосстановление,
дебаунс пропажи источника, владение целевым путём (один путь — один владелец)
и инвариант безопасного `Undo` (не снимать последнюю копию). Уведомления о
рассинхроне — в `notifications`.
## Requirements
### Requirement: Периодическая сверка состояния с реальностью

`worker` SHALL периодически (на тике поллинга) сверять задачи, для которых
ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и
выводить состояние задачи из двух независимых признаков: присутствия
**источника** (раздача, совпавшая с **любым из известных хешей** загрузки в
`download_infohash`, в выдаче qBittorrent) и присутствия **цели** (см.
требование о владении целевым путём: существуют все ссылки последнего батча
со статусом раскладки, всё ещё принадлежащие этой загрузке).

Сверке по матрице «источник × цель» SHALL подвергаться состояния `done`,
`target_missing`, `orphaned`. Состояние `deleted` сверка трогать SHALL NOT —
оно терминально. Активные (`downloading`/`recognizing`/`review`/`deferred`/
`linking`) и пользовательски-терминальные (`reverted`/`cancelled`) состояния
сверка по матрице трогать SHALL NOT.

**Восстановимые** `failed`/`stuck` (с `error_code` `magnet_timeout` или
`stalled` — задержки, вызванные нашей нетерпеливостью, а не реальной ошибкой)
сверка SHALL рассматривать отдельно — на предмет оживления источника (см.
требование о восстановлении зависшей загрузки), не по матрице «источник ×
цель». Прочие `failed` (например `qbit_error`) сверка трогать SHALL NOT.

Состояние SHALL переписываться только при его изменении (без записи и логов,
когда выведенное состояние совпадает с текущим).

#### Scenario: Источник и цель на месте — состояние не меняется

- **WHEN** для задачи в `done` раздача присутствует в qBittorrent и все её
  разложенные хардлинки существуют
- **THEN** задача остаётся в `done`
- **AND** запись состояния и лог перехода не выполняются

#### Scenario: Частичная пропажа цели считается отсутствием

- **WHEN** часть разложенных хардлинков задачи удалена, а источник на месте
- **THEN** цель считается отсутствующей и задача переходит в `target_missing`

#### Scenario: Задача в deleted сверкой не переоценивается

- **WHEN** задача находится в `deleted`
- **THEN** сверка её не рассматривает и состояние не меняет, даже если по её
  бывшему пути появился файл другой загрузки

#### Scenario: Провал по ошибке qBittorrent восстановлению не подлежит

- **WHEN** задача в `failed` с `error_code` `qbit_error`
- **THEN** сверка её не рассматривает и состояние не меняет

### Requirement: Восстановление зависшей загрузки при оживлении источника

Система SHALL возвращать в активный поток задачу, упавшую из-за нашей
нетерпеливости (`failed`/`magnet_timeout` или `stuck`/`stalled`), если её
источник в qBittorrent жив и продвинулся: переход выводится из текущего
состояния торрента так же, как при штатной сверке загрузки
(`uploading`/`stalledUP`/… → `completed`; `downloading`/`metaDL`/… →
`downloading`). Восстановление SHALL опираться на фактическое состояние
торрента в qBittorrent, а не на время с момента создания записи.

После возврата в любое нетерминальное состояние (`downloading` или
`completed`) повторный приём того же infohash SHALL снова дедуплицироваться
на эту задачу: активность задачи выводится только из её `state`, отдельный
восстанавливаемый ключ идемпотентности отсутствует. Если за время простоя в
`failed`/`stuck` тем же infohash (любым из хешей задачи) уже завладела
другая активная задача (новый приём, пока эта лежала упавшей), система
SHALL NOT воскрешать упавшую задачу и SHALL оставить её в `failed`/`stuck`,
сохраняя инвариант «не более одной активной задачи на infohash».

`magnet_timeout`/`stalled` SHALL быть редким страховочным исходом, а не
рабочим механизмом. Две страховочные меры при этом РАЗНЫЕ: `magnet_timeout`
SHALL мериться по **возрасту** торрента (время от добавления в qBittorrent,
`added_on`, с фолбэком на `created_at` задачи), а `stuck_after` — по
**длительности простоя** (время от `last_activity` qBittorrent — момента
последнего движения данных), а НЕ по возрасту. Пока торрент в
`metaDL`/`forcedMetaDL` или иным образом прогрессирует в пределах
страховочного таймаута, задача в `failed`/`stuck` из-за него оказаться
SHALL NOT; в частности, торрент со свежим `last_activity` в `stuck` система
пометить SHALL NOT, даже если его общий возраст превышает `stuck_after` (см.
требование о терпеливости к долгим метаданным и меры таймаутов в
`openspec/specs/download-tracking/spec.md`).

#### Scenario: Метаданные пришли после magnet_timeout

- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
  в qBittorrent уже получил метаданные и качается (`downloading`)
- **WHEN** срабатывает фоновая сверка
- **THEN** задача возвращается в `downloading`
- **AND** повторный приём того же infohash снова дедуплицируется на неё

#### Scenario: Торрент уже завершился, пока задача была в failed

- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
  в qBittorrent уже готов к раскладке (`uploading`/`stalledUP`)
- **WHEN** срабатывает фоновая сверка
- **THEN** задача переходит в `completed` и продолжает обычный поток
  (распознавание/раскладка)

#### Scenario: Источник так и не ожил — состояние не меняется

- **GIVEN** задача в `failed` с `error_code` `magnet_timeout`, а её торрент
  всё ещё висит в `metaDL` без метаданных (или отсутствует в qBittorrent)
- **WHEN** срабатывает фоновая сверка
- **THEN** задача остаётся в `failed`

#### Scenario: infohash уже занят другой активной задачей

- **GIVEN** задача #1 в `failed`/`magnet_timeout`, а тем же infohash уже
  владеет другая активная задача #2 (приём повторили, пока #1 лежала упавшей)
- **WHEN** источник ожил (торрент получил метаданные или готов) и сверка
  пытается воскресить #1
- **THEN** #1 остаётся в `failed` (восстановление не выполняется)
- **AND** активной по этому infohash остаётся #2

#### Scenario: Долго качавшийся торрент на миг зашёл в stalledDL

- **GIVEN** торрент качался часами и двигал данные только что (свежий
  `last_activity`), но на текущем тике qBittorrent показывает его `stalledDL`
- **WHEN** `worker` проверяет таймаут зависания
- **THEN** задача остаётся в `downloading` (простой меньше `stuck_after`),
  несмотря на большой возраст торрента
- **AND** ложного `stuck` со «stalled for <возраст>» и уведомления о падении
  не возникает

### Requirement: Ручной повтор зависшей/упавшей загрузки из транспортов

Система SHALL предоставлять пользователю команду повторной попытки (retry)
для задач в `failed`/`stuck` из веб-UI и Telegram (не только через REST API).
Retry SHALL переводить задачу обратно в `downloading`, не вызывая её
немедленного повторного падения по таймауту: базис отсчёта таймаутов SHALL
сбрасываться.

Сброс базиса система SHALL выполнять сохранением времени retry в поле задачи
(`retried_at`, RFC 3339 UTC), которое приподнимает пол ОБОИХ страховочных мер
(`magnet_timeout` по возрасту и `stuck_after` по простою): отсчёт ведётся от
`max(базис, retried_at)`. `retried_at` SHALL храниться в задаче (не в памяти
процесса), чтобы сброс базиса пережил интервал поллинга и рестарт процесса.
Благодаря этому даже живой, но давно добавленный либо давно простаивающий
торрент после retry SHALL получать свежее окно и на ближайшем тике сверки
падать снова SHALL NOT.

Если источник задачи уже жив и ЗДОРОВ в qBittorrent, retry SHALL перецепляться
к существующему торренту, а не добавлять источник повторно вслепую. Если же
живой торрент в состоянии ошибки qBittorrent (`error`/`missingFiles`), retry
SHALL отклоняться с понятным пользователю сообщением — починить раздачу
(`recheck`/восстановить файлы) в qBittorrent и повторить. Повторная отдача
источника такой торрент не чинит (qBittorrent отверг бы дубль), а простой
возврат в `downloading` тут же снова упал бы `qbit_error` по сверке (+ дебаунс
уведомления) — retry выглядел бы сломанным. При отказе состояние задачи
(`failed`/`stuck`) система менять SHALL NOT и повторный `Add` выполнять SHALL NOT.

Повторный `Add` при retry система SHALL выполнять, только когда раздачи в
qBittorrent нет, — **по типу источника** (`source_type`), как и добавление
пойманной загрузки (см. `download-tracking` «Добавление пойманной загрузки в
qBittorrent»): magnet/url — ссылкой; torrent — сохранёнными байтами `.torrent`
файлом. Для torrent-источника retry БЕЗ живой раздачи система SHALL добавлять
раздачу байтами и SHALL NOT активировать задачу в `downloading`, не добавив её
(иначе задача повиснет как «нет в qBittorrent»).

#### Scenario: Retry упавшей magnet-загрузки из веб-UI

- **GIVEN** задача в `failed`, её торрент жив и здоров в qBittorrent
- **WHEN** пользователь нажимает retry в веб-UI
- **THEN** задача возвращается в `downloading` без повторного `Add`
- **AND** не падает снова на ближайшем тике сверки по таймауту

#### Scenario: Retry живого, но давно простаивающего торрента не падает снова

- **GIVEN** задача в `stuck`/`stalled`, её торрент жив в qBittorrent, но
  добавлен давно и данные не двигались дольше `stuck_after`
- **WHEN** пользователь нажимает retry
- **THEN** задача возвращается в `downloading` без повторного `Add`
- **AND** на ближайшем тике сверки НЕ падает снова в `stuck` (базис сброшен
  через `retried_at`)

#### Scenario: Retry доступен в Telegram

- **WHEN** для задачи в `failed`/`stuck` пользователь вызывает retry в
  Telegram-боте
- **THEN** задача возвращается в `downloading`

#### Scenario: Retry без живого источника добавляет источник заново

- **GIVEN** задача в `failed`, раздачи в qBittorrent нет
- **WHEN** пользователь инициирует retry
- **THEN** источник добавляется в qBittorrent заново — magnet/url ссылкой,
  torrent сохранёнными байтами файлом
- **AND** задача переходит в `downloading`

#### Scenario: Retry сломанного живого торрента отклоняется

- **GIVEN** задача в `failed`, её торрент присутствует в qBittorrent, но в
  состоянии ошибки (`error`/`missingFiles`)
- **WHEN** пользователь инициирует retry
- **THEN** retry отклоняется с сообщением починить раздачу (`recheck`) в
  qBittorrent
- **AND** состояние задачи не меняется (остаётся `failed`), повторный `Add` не
  выполняется

#### Scenario: Retry torrent-загрузки без живого источника

- **GIVEN** задача с `source_type = torrent` в `failed`, раздачи в qBittorrent
  нет, байты `.torrent` сохранены
- **WHEN** пользователь инициирует retry
- **THEN** сохранённые байты добавляются в qBittorrent файлом
- **AND** задача переходит в `downloading` (не остаётся без раздачи)

### Requirement: Принудительная проверка источника/цели перед действием

Команда workflow, требующая наличия источника или цели, SHALL синхронно
проверять их присутствие непосредственно перед выполнением действия и SHALL
NOT полагаться только на фоновую сверку `worker` (она отстаёт на интервал
поллинга и дебаунс). Проверка перед действием выполняется как **единичная
немедленная проба без дебаунса**: дебаунс применяется только к фоновому
авто-маркированию.

Под это требование подпадают как минимум: повторная привязка/распознавание
(`relink`, «Распознать заново», «Уточнить») и раскладка (`Apply`) — требуют
**источника**; `Undo` — требует **источника** (чтобы не снять последнюю
копию).

Если предусловие не выполнено, система SHALL NOT выполнять действие, SHALL
привести состояние задачи в соответствие с реальностью (вывести состояние из
матрицы «источник × цель», как при сверке) и SHALL сообщить причину
пользователю.

#### Scenario: Relink проверяет источник перед запуском

- **WHEN** пользователь даёт команду, требующую источника (например
  «Привязать заново»)
- **THEN** система синхронно проверяет наличие раздачи в qBittorrent перед
  запуском распознавания
- **AND** если источника нет — распознавание не запускается, задача
  приводится к `orphaned` либо `deleted` (по наличию цели), причина
  сообщается

#### Scenario: Проверка не ждёт фоновую сверку

- **WHEN** источник уже удалён, а фоновая сверка ещё не отметила это (в БД
  состояние, например, `done`)
- **THEN** команда, требующая источника, немедленно обнаруживает его
  отсутствие собственной проверкой и отказывает, не дожидаясь `worker`

### Requirement: Состояние target_missing и доступность повторной привязки

Когда у задачи источник присутствует, а цель отсутствует, система SHALL
переводить её в состояние `target_missing` и SHALL NOT предпринимать
автоматических действий (не запускать повторное распознавание/раскладку
самостоятельно).

Из `target_missing` система SHALL предоставлять пользовательскую команду
повторной привязки (relink) с переходом `target_missing → recognizing`, как
из `reverted`/`cancelled`; повторная привязка идёт через `review` с ручным
подтверждением.

#### Scenario: Цель удалена, источник на месте

- **WHEN** сверка обнаруживает, что разложенных хардлинков задачи `done`
  больше нет, но раздача в qBittorrent присутствует
- **THEN** задача переходит в `target_missing`
- **AND** система не запускает распознавание или раскладку автоматически

#### Scenario: Пользователь инициирует повторную привязку

- **WHEN** для задачи в `target_missing` пользователь даёт команду «Привязать
  заново»
- **THEN** задача переходит в `recognizing` и далее проходит через `review`
  с ручным подтверждением

### Requirement: Состояние orphaned при пропаже источника

Система SHALL переводить задачу в состояние `orphaned`, когда источник
отсутствует (с учётом дебаунса), а цель присутствует, отражая, что
библиотечный хардлинк остался единственной копией данных.

#### Scenario: Источник удалён, цель на месте

- **WHEN** сверка устойчиво (после дебаунса) не находит раздачу задачи в
  qBittorrent, а её разложенные хардлинки существуют
- **THEN** задача переходит в `orphaned`

### Requirement: Состояние deleted при пропаже источника и цели

Когда отсутствуют и источник (с учётом дебаунса), и цель, система SHALL
переводить задачу в состояние `deleted`. `deleted` терминально: действий над
задачей больше нет, и сверка её больше не переоценивает (источник к
терминальной задаче не возвращается из-за идемпотентности, а цель отбирается
переходом владения путём к другой загрузке).

#### Scenario: Источник и цель удалены

- **WHEN** сверка устойчиво не находит раздачу в qBittorrent и разложенных
  хардлинков задачи на ФС больше нет
- **THEN** задача переходит в `deleted`

#### Scenario: deleted не воскресает при переиспользовании пути

- **GIVEN** задача A в `deleted`
- **WHEN** другая задача раскладывается по бывшему пути A
- **THEN** задача A остаётся в `deleted` (не переходит в `orphaned`)

### Requirement: Владение целевым путём — один путь, один владелец

Целевой путь раскладки (`file_link.dst_path`) SHALL принадлежать не более
чем одной загрузке одновременно. При успешной раскладке загрузки на путь,
который ранее заняла **другая** загрузка, владение SHALL переходить к новой
загрузке: ссылки прежней загрузки на тот же `dst_path` система SHALL
помечать вышедшими из обращения (статус, не относящийся к разложенной цели),
после чего они перестают считаться целью прежней загрузки при сверке.

Присутствие цели при сверке SHALL определяться по **владению**, а не по
факту существования пути: цель загрузки считается присутствующей, только
если существующие на ФС файлы по её путям — это ссылки, всё ещё
принадлежащие этой загрузке (не вышедшие из обращения). Файл, лежащий по
тому же пути, но созданный другой загрузкой, целью первой загрузки
считаться SHALL NOT.

Переход владения возможен лишь когда путь к моменту раскладки **свободен**
(прежний файл уже удалён): занятый реальным файлом путь по-прежнему даёт
коллизию и уходит в review (новая раскладка не перезаписывает чужой файл).

#### Scenario: Повторная закачка забирает освободившийся путь

- **GIVEN** загрузка A разложена по пути P, но её файл по P удалён вручную
- **WHEN** загрузка B успешно раскладывается по тому же пути P
- **THEN** ссылки A на P помечаются вышедшими из обращения
- **AND** при сверке цель A по пути P считается отсутствующей

#### Scenario: Чужой файл по пути не считается своей целью

- **GIVEN** по пути P лежит файл, созданный загрузкой B
- **WHEN** сверка проверяет присутствие цели загрузки A, чьи ссылки на P
  вышли из обращения
- **THEN** цель A считается отсутствующей, несмотря на существование файла
  по P

#### Scenario: Занятый путь даёт коллизию, а не переход владения

- **GIVEN** файл загрузки A по пути P всё ещё существует
- **WHEN** загрузка B пытается разложиться по тому же пути P
- **THEN** возникает коллизия и B уходит в review
- **AND** владение путём P за A не отбирается

### Requirement: Дебаунс пропажи источника

Система SHALL дебаунсить только **отсутствие источника**, чтобы временная
недоступность qBittorrent (рестарт демона, сбой API) не вызывала ложных
пометок: источник считается удалённым лишь после `N` подряд тиков сверки без
него, где `N = [worker].source_missing_threshold`. Любое обнаружение раздачи
SHALL сбрасывать счётчик пропусков.

Отсутствие цели дебаунсу подвергаться SHALL NOT (локальная проверка ФС
надёжна).

#### Scenario: Кратковременная пропажа источника не помечается

- **WHEN** раздача отсутствует в qBittorrent меньше `source_missing_threshold`
  тиков подряд
- **THEN** источник трактуется как присутствующий и состояние задачи не
  меняется

#### Scenario: Возврат источника сбрасывает счётчик

- **WHEN** раздача снова обнаружена в qBittorrent
- **THEN** счётчик пропусков источника сбрасывается в ноль

### Requirement: Самовосстановление состояния при возврате реальности

Система SHALL возвращать задачу в согласованное состояние, когда реальность
восстановилась (состояние выводится из текущей матрицы «источник × цель»):
при возврате источника и/или цели задача SHALL переходить из
`orphaned`/`target_missing` обратно (в т.ч. в `done`, когда присутствуют
оба). Из терминального `deleted` самовосстановления SHALL NOT быть.

#### Scenario: Источник вернулся

- **WHEN** для задачи в `orphaned` раздача снова появилась в qBittorrent, а
  цель по-прежнему на месте
- **THEN** задача возвращается в `done`

### Requirement: Безопасный Undo не снимает последнюю копию

`Undo` (снятие созданных хардлинков) SHALL отказываться удалять целевую
ссылку, если она является последней копией данных: целевой файл существует и
его счётчик ссылок `nlink <= 1`, либо исходный файл (`src_path`) не
существует. В этом случае система SHALL NOT выполнять `unlink` такого файла и
SHALL явно сообщать причину отказа; задача в `reverted` при отказе переходить
SHALL NOT.

Отсутствующую целевую ссылку (файла уже нет) `Undo` SHALL пропускать как
успешно снятую (идемпотентность). Команда `Undo` для задачи в `orphaned`
SHALL отклоняться сразу с пояснением, что источник удалён.

#### Scenario: Отказ снять единственную копию

- **WHEN** при `Undo` целевой хардлинк существует, но его `nlink <= 1` (или
  исходный файл отсутствует)
- **THEN** система не удаляет файл и сообщает, что это последняя копия
- **AND** задача остаётся в текущем состоянии (не `reverted`)

#### Scenario: Undo снимает лишний хардлинк при живом источнике

- **WHEN** при `Undo` целевой хардлинк существует, исходный файл на месте и
  `nlink > 1`
- **THEN** система снимает целевой хардлинк, оставляя исходный файл нетронутым

### Requirement: Восстановление задачи, застрявшей в linking

Система SHALL на каждом тике поллинга и при старте выявлять задачи в состоянии
`linking` и возвращать их в `review` с причиной «прерванная раскладка» (код
`interrupted`), откуда человек повторит применение (повтор идемпотентен).
`linking` — нетерминальное активное состояние, и у него, как у каждого
нетерминального состояния, ДОЛЖЕН быть владелец, продвигающий задачу; иначе
краш процесса между переходом в `linking` и финальным переходом оставил бы
задачу без владельца — её не листит ни один штатный шаг (ни поллинг активных,
ни распознавание, ни матрица сверки, ни восстановление `failed`/`stuck`).

Выявление SHALL выполняться под той же блокировкой переходов, что и раскладка:
активная раскладка удерживает блокировку весь свой срок и завершает переход из
`linking` до её отпускания, поэтому любая `linking`-задача, наблюдаемая под
блокировкой, по построению устарела (осталась после краха) — восстановление НЕ
SHALL задевать раскладку в полёте.

#### Scenario: Осиротевший linking возвращается в review

- **GIVEN** задача осталась в `linking` после краха между claim и финальным
  переходом
- **WHEN** выполняется тик поллинга (или старт сервиса)
- **THEN** задача переходит в `review` с причиной «прерванная раскладка»
  (код `interrupted`)
- **AND** её можно повторно применить из ревью

#### Scenario: Прочие состояния sweep не задевает

- **GIVEN** задачи в состояниях `done` и `review`
- **WHEN** выполняется тик поллинга
- **THEN** восстановление `linking` их состояние не меняет

### Requirement: Полное удаление загрузки пользователем

Система SHALL предоставлять пользователю команду **«Удалить»** (delete),
доступную из состояний `done`, `orphaned` и `target_missing` во всех транспортах
(веб-UI и Telegram, опц. REST). Команда SHALL снимать **обе** стороны загрузки —
целевые библиотечные хардлинки И раздачу с файлами в qBittorrent — и переводить
задачу в терминальное `deleted`. Из прочих состояний команда доступна SHALL NOT.

Перечень состояний, из которых удаление допустимо, SHALL иметь **единственный
дом** — предикат состояния в модели данных. Транспорты, решающие, показывать
действие или нет, SHALL опираться на него, а не на собственный список. Проверку
допуска в ядре это SHALL NOT отменять: транспорт решает, что показать, ядро — что
допустить, и допуск SHALL держаться без транспорта.

Снятие цели SHALL идти по механике снятия ссылок последнего батча (как в `Undo`:
`superseded` пропускаются как забранные другой загрузкой), но **отдельным путём с
выключенным** гардом последней копии — не переиспользуя guarded-`Undo`: в отличие
от `Undo`, delete SHALL снимать целевую ссылку, даже если она — последняя копия
данных (`nlink <= 1`). Это осознанный выход за
инвариант «источник неприкосновенен», поэтому delete SHALL требовать явного
**подтверждения** пользователя перед выполнением и SHALL NOT срабатывать по
одиночному клику/тапу. Снятие цели SHALL затрагивать только собственные ссылки
загрузки строго под `paths.movies`/`series`; файлы источника под
`paths.downloads` система сама трогать SHALL NOT — их удаляет qBittorrent по
вызову API с `deleteFiles=true`.

Подтверждение SHALL быть допустимо **одно на пачку** загрузок, когда транспорт
даёт групповое удаление: ослаблением требования это не является, если
подтверждение называет каждую загрузку пачки поимённо. Условия допустимости
групповой путь смягчать SHALL NOT — каждая загрузка пачки проходит те же
проверки и тот же отказ по конфликту, что и при поштучном удалении, а отказ на
одной остальных отменять SHALL NOT.

В отличие от прочих команд, требующих источника, delete синхронный source-preflight
выполнять SHALL NOT и под требование «Принудительная проверка источника/цели перед
действием» не подпадает: цель delete — снять источник, поэтому его отсутствие
трактуется как уже снятая сторона, а не как повод привести состояние сверкой и
отказать. Удаление SHALL быть идемпотентным к отсутствующей стороне: в `orphaned`
(нет источника) отсутствие раздачи в qBittorrent ошибкой считаться SHALL NOT; в
`target_missing` (нет цели) пустой список живых ссылок обрабатывается как «нечего
снимать». Если qBittorrent вернул ошибку при удалении присутствующей раздачи,
система в `deleted` переходить SHALL NOT (не заявляем освобождение места, которого
не произошло), SHALL сообщить пользователю причину отказа (это не `ErrConflict`,
а ошибка внешнего сервиса — транслируется как таковая), и повторный delete
идемпотентно дожимает удаление, опираясь на оставшийся `done` либо приведённый
сверкой к реальности `target_missing` (кратковременное рассогласование до тика
сверки ожидаемо).

Инициатора перехода в `deleted` система SHALL отличать от фоновой сверки:
пользовательское удаление SHALL помечаться `error_code = "user_delete"` (сверка
кладёт `"reconcile"`), человекочитаемую причину — в `error_msg` и лог перехода.
Новый статус для этого система вводить SHALL NOT — переиспользуется существующее
терминальное `deleted` (сверка его не переоценивает, см. требование о `deleted`).

#### Scenario: Удаление из done снимает обе стороны и освобождает место

- **GIVEN** задача в `done`: раздача присутствует в qBittorrent, её библиотечные
  хардлинки существуют
- **WHEN** пользователь подтверждает «Удалить»
- **THEN** библиотечные ссылки последнего батча снимаются
- **AND** раздача с файлами удаляется из qBittorrent (`deleteFiles=true`)
- **AND** задача переходит в `deleted` с `error_code = "user_delete"`

#### Scenario: Удаление из orphaned снимает последнюю копию осознанно

- **GIVEN** задача в `orphaned`: источник пропал, библиотечный хардлинк остался
  единственной копией данных (`nlink <= 1`)
- **WHEN** пользователь подтверждает «Удалить»
- **THEN** библиотечная ссылка снимается несмотря на то, что она последняя копия
  (гард последней копии выключен, в отличие от `Undo`)
- **AND** отсутствие раздачи в qBittorrent ошибкой не считается
- **AND** задача переходит в `deleted`

#### Scenario: Удаление из target_missing сносит остаточную раздачу

- **GIVEN** задача в `target_missing`: источник присутствует, цель уже удалена
  вручную
- **WHEN** пользователь подтверждает «Удалить»
- **THEN** снятие цели идемпотентно (живых ссылок нет)
- **AND** раздача с файлами удаляется из qBittorrent
- **AND** задача переходит в `deleted`

#### Scenario: Удаление требует подтверждения

- **GIVEN** задача в `done`
- **WHEN** пользователь инициирует «Удалить», но не подтверждает действие
- **THEN** ни ссылки, ни раздача не удаляются, состояние остаётся `done`

#### Scenario: Удаление недоступно из прочих состояний

- **GIVEN** задача в `review` (или ином состоянии вне `done`/`orphaned`/
  `target_missing`)
- **WHEN** приходит команда «Удалить»
- **THEN** команда отклоняется с конфликтом, состояние не меняется

#### Scenario: Ошибка qBittorrent не метит deleted ложно

- **GIVEN** задача в `done`, раздача присутствует, но qBittorrent вернул ошибку
  на удаление
- **WHEN** пользователь подтверждает «Удалить»
- **THEN** задача в `deleted` не переходит (место не освобождено)
- **AND** пользователю сообщается причина отказа (ошибка qBittorrent, не тихий успех)
- **AND** повторный delete идемпотентно дожимает удаление

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

- **GIVEN** транспорт даёт групповое удаление, и человек подтвердил пачку, где
  каждая загрузка названа поимённо
- **WHEN** одна из загрузок пачки находится в состоянии, из которого удаление
  недоступно
- **THEN** по ней приходит тот же отказ по конфликту, что и при поштучном
  удалении
- **AND** остальные загрузки пачки удаляются

### 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`)
