## Context

`processCatched` (`internal/worker/worker.go`) добавляет пойманные загрузки в
qBittorrent. Медленные вызовы (namer/LLM, `qbt.Add`) идут **вне** блокировки
переходов `w.mu`, под замком — только короткие DB-переходы. Последовательность
на «обычном» пути добавления (торрента в снимке тика нет):

1. под `w.mu` re-read записи → проверка `state == catched`, актуализация
   `source_type` (текущий код: worker.go:447–453);
2. вне замка: `namer.Derive` (секунды) → `qbt.Add`;
3. под `w.mu`: `PromoteCatched` (гард `state='catched'`) → `catched → downloading`.

Гонка F3: отмена (`catched → cancelled`) приходит между шагом 1 и шагом 3. Гард
`PromoteCatched` честно отклоняет переход (`state` уже `cancelled`), но `qbt.Add`
на шаге 2 уже отработал — торрент добавлен под нашей категорией и остался в
qBittorrent без задачи-владельца. `adopt` его назад не подхватит:
`ExistsByInfohash` истинно (хеши принадлежат отменённой записи). Диск занят,
владельца нет.

## Goals / Non-Goals

**Goals**
- Не добавлять источник, если отмена видна ещё до `add`.
- Убрать добавленный нами торрент (с данными), если отмена случилась в окне после
  `add`.
- Никогда не удалять с данными торрент, которого мы не создавали этим `add`.

**Non-Goals**
- Не меняем FSM-граф и семантику `PromoteCatched`/`cancel`.
- Не вводим новых состояний, полей БД, методов `qbt`/`store`.
- Не закрываем окно гонки полностью (у qBittorrent нет транзакции «add+own»);
  сужаем его и гарантированно убираем последствия.
- **Не в scope: апгрейд `source_type` во время namer.** `source_type`/`addReq`
  worker перечитывает перед namer (текущий код), а не в D1-re-read перед `add`.
  Пред-существующий зазор «magnet→torrent апгрейд случился во время вывода имени»
  этой задачей не закрывается и не ухудшается (D1 читает только состояние). D1
  специально держим дешёвым (только `state`) и не тянем чтение байтов торрента под
  `w.mu`; закрытие зазора — отдельная задача.

## Decisions

### D1. Re-read состояния прямо перед `qbt.Add`

После `namer.Derive` (медленный шаг) и **непосредственно перед** `qbt.Add`
worker берёт `w.mu`, перечитывает запись и проверяет `state == catched`. Если
уже не `catched` (отменена во время namer) — `add` не делаем, задачу пропускаем.
Это переносит основную защиту на самый частый сценарий: окно namer (секунды)
куда шире окна между `add` и записью перехода (один сетевой вызов).

Re-read под `w.mu` перечитывает только **состояние** (дёшево, локальный SQLite).
Актуализацию `source_type`/`addReq` он НЕ повторяет — см. «Границы блокировки» и
«Не в scope» ниже.

### D2. Проверка отсутствия infohash перед `add` — признак «своего» торрента

Сразу перед `add` (после D1, но **вне** `w.mu` — это сетевой вызов) worker
свежим листингом qBittorrent подтверждает, что раздачи с любым из infohash
загрузки **ещё нет**. Реализуется переиспользованием `qbt.Torrents(ctx, "")` (тот
же механизм, что снимок тика) — нового API не нужно.

- Если листинг **не удался** (сеть отвалилась) — worker `add` НЕ делает и задачу
  в этот тик пропускает (повтор на следующем). Это критично: без подтверждённого
  отсутствия «своё/чужое» неразличимо, и delete-with-data стал бы небезопасен.
  Поведение то же, что уже принято для листинга тика (сбой → не трогаем).
- Если infohash **уже присутствует** (внешний клиент/пользователь добавил тот же
  торрент в окно гонки после снимка тика) — worker `add` НЕ делает и задачу
  пропускает: на следующем тике её штатно усыновит `promoteExisting` (ветка «уже
  присутствует»). Чужие данные не трогаются.
- Если infohash **отсутствует** — только тогда делаем `add`. Тем самым любой
  торрент, оказавшийся под этим infohash сразу после нашего `add`, — **наш**
  артефакт.

Именно подтверждённое отсутствие-перед-`add` — механизм различения «своё/чужое».
Он не завязан на семантику ответа `qbt.Add` (qBittorrent на дубль отвечает тем же
`Ok.`, не сообщая, создал он раздачу или это был дубль).

### D3. Scoped cleanup при отмене в окне после `add`

Если `add` прошёл (D2 подтвердил отсутствие), а затем `PromoteCatched` не
применил переход, worker принимает решение об уборке **по свежему re-read
состояния под `w.mu`**, а не по тексту/факту ошибки `PromoteCatched`. Причина:
`PromoteCatched` возвращает ошибку в двух разных случаях — (1) гард `n==0`
(`state` действительно уже не `catched`, отмена) и (2) транзиентный сбой БД
(`state` всё ещё `catched`, задача жива). Вешать delete-with-data на «любую
ошибку промоушена» нельзя: при миге БД это снесло бы **свой же, но ещё активный**
торрент.

Поэтому под тем же `w.mu` worker перечитывает запись:

- `state != catched` (подтверждённая отмена) → удаляем добавленный торрент из
  qBittorrent **с данными**: `qbt.Delete(ctx, hashes, deleteFiles=true)` по
  infohash загрузки. `Delete` идемпотентен (неизвестный хеш qBittorrent
  игнорирует). Сбой удаления — `ERROR`-лог, состояние задачи (`cancelled`) не
  трогаем.
- `state == catched` (транзиентный сбой БД) или re-read сам упал → торрент НЕ
  удаляем, `WARN` «promote failed, will retry»: на следующем тике
  `promoteExisting` усыновит присутствующую (нашу же) раздачу — переход
  доведётся, ничего не потеряно.

Cleanup достижим ТОЛЬКО по пути, где D2 подтвердил отсутствие infohash перед
`add`, — поэтому удаляемый торрент гарантированно создан этим `add`.

### Границы блокировки (сериализация переходов)

Порядок вокруг `add` соблюдает инвариант «медленные/сетевые вызовы вне `w.mu`»:

1. `[под w.mu]` re-read состояния (D1);
2. `[вне w.mu]` свежий листинг присутствия (D2) — сетевой вызов;
3. `[вне w.mu]` `qbt.Add`;
4. `[под w.mu]` `PromoteCatched` + (при неуспехе) re-read состояния для решения об
   уборке (D3);
5. `[вне w.mu]` `qbt.Delete` при подтверждённой отмене.

Между шагами 1–3 остаётся окно (описанный TOCTOU), но `add` защищён свежим
подтверждением отсутствия (шаг 2), а любая пропажа гарантии деградирует к
безопасному «не удаляем» (D3).

### D4. Логи

- `WARN "torrent left in qbittorrent after cancel, removing"` — вход в cleanup,
  поле-причина промаха `PromoteCatched`.
- `WARN "added torrent removed after cancel"` — факт успешного удаления.
- `ERROR` — если `qbt.Delete` не удался (мусор остался, нужен разбор).
- D1/D2-пропуски — `INFO` (штатная развилка, не проблема).

Все записи несут `download_id`/`infohash` (scoped-логгер `cctx`), без секретов.
Сам вызов `qbt.Delete`/`Torrents` логирует клиент (`ext.*`).

## Инвариант «источник неприкосновенен» и негативная гарантия

**Артикуляция решения.** Инвариант «источник неприкосновенен» (CLAUDE.md,
architecture.md, ADR-hardlinks) защищает **пользовательские данные** под
`paths.downloads`/существующие раздачи от НАШИХ прямых fs-операций
(`unlink`/`rename`). Торрент, который jellybit добавил секундами ранее — уже
после намерения отмены, — это **наш собственный артефакт**, а не пользовательские
данные. Его удаление через API qBittorrent (не прямыми fs-операциями) —
легитимная уборка своего мусора. Прецедент уже есть: команда «Удалить» (`delete`)
осознанно сносит раздачу с данными через `qbt.Delete(..., true)`
(state-reconciliation) — там обход инварианта санкционирован пользователем; здесь
удаляется лишь то, что мы сами только что создали вопреки уже выраженной отмене.

**Негативная гарантия (КРИТИЧНО).** Удаление-с-данными недопустимо для торрента,
который присутствовал в qBittorrent ДО нашего `add` (пользователь уже раздавал
тот же infohash / внешний торрент с тем же хешем). Удалить его с данными означало
бы снести чужие данные — прямое нарушение инварианта. Защита — D2: удаление
достижимо только на пути, где отсутствие infohash подтверждено непосредственно
перед `add`; при обнаруженном присутствии `add` не делается вовсе, а торрент
уходит на усыновление. Так удаляется исключительно созданное нами этим `add`.

**Остаточное окно (честно).** Между проверкой присутствия (D2) и самим `add`
остаётся микроскопический TOCTOU-зазор: внешний клиент теоретически мог добавить
тот же infohash в этот промежуток (два последовательных сетевых вызова). У
qBittorrent нет атомарного «create-or-fail по infohash» и `add` не сообщает,
создал он раздачу или присоединился к дублю, — устранить зазор имеющимся API
нельзя. Мы сознательно выбираем `add` только при подтверждённом отсутствии
непосредственно перед ним (зазор на порядки меньше окна namer из D1) и считаем
это санкцией на уборку. Дальнейшее сужение (напр. сверка `added_on` раздачи со
временем нашего `add`) — возможное усиление на будущее, в этой задаче не делаем:
базис по времени хрупок (скос часов, грубое разрешение), а вероятность
внешнего добавления ровно в этот под-`add` зазор пренебрежимо мала.

## Risks / Trade-offs

- **Лишний листинг qBittorrent перед каждым `add`** пойманной загрузки. Добавления
  событийны и редки (не поллинг), стоимость незначительна; переиспользуем
  существующий `Torrents`.
- **Cleanup зависит от доступности qBittorrent.** Если `qbt.Delete` не прошёл —
  торрент временно остаётся, но это уже отменённая задача; `ERROR`-лог фиксирует
  для разбора. Повторной авто-уборки не вводим (не усложняем): случай редкий.

## Migration Plan

Изменение чисто поведенческое в `worker.processCatched`. Схема БД, конфиг, API
`qbt`/`store` не меняются. Откат — возврат прежней ветки добавления.

## Open Questions

Нет.
