## Context

Аудит capability `download-tracking` (сверка код↔спека после пачки
lifecycle-задач) нашёл расхождение между заявленным и фактическим.

Спека `openspec/specs/download-tracking/spec.md:139-143` требует перечитывать
`source_type` **под блокировкой переходов непосредственно перед добавлением**.
Код `processCatched` (`internal/worker/worker.go:442-517`) делает иначе:

1. `:447-450` — под `w.mu` перечитывает запись (`cur`) и проверяет
   `state == catched`;
2. `:455` — вне замка собирает `addReq` из `cur` (`sourceAddParts` читает байты
   `.torrent` из БД);
3. `:463-478` — вне замка зовёт namer (LLM, секунды);
4. `:484-491` — под `w.mu` перечитывает `before`, но проверяет только
   `state == catched`; **`addReq` из `before` не пересобирается**;
5. `:501-511` — свежий листинг присутствия и `qbt.Add(addReq)`.

`UpgradeCatchedMagnetToTorrent` (`internal/store/download.go:392`) меняет
`source_type` magnet→torrent, пока задача в `catched`. Если апгрейд лёг в окно
шага 3, воркер добавит magnet-ссылку из снимка шага 1, хотя в БД уже
`source_type = torrent`.

Первое окно (между тик-снимком `ListDownloadsByState` и re-read шага 1) уже
закрыто и покрыто тестом `TestProcessCatchedReReadsSourceTypeUnderLock`.

Решение по задаче принято 2026-08-06: **вариант B — привести спеку к коду**.
Вариант A (пересобирать `addReq` из `before` под замком) отклонён: это не
однострочник — `sourceAddParts` читает байты `.torrent` и держать его под
блокировкой нельзя, а при апгрейде корректен был бы и повторный вызов namer'а
(подсказка имени берётся из другого источника).

## Goals / Non-Goals

**Goals:**

- Требование спеки описывает **фактическое** поведение кода: re-read
  `source_type` под блокировкой после тик-снимка, до подготовки запроса на
  добавление.
- Остаточное окно (апгрейд в момент вызова namer'а) названо в спеке явно,
  вместе с достоверным маршрутом восстановления. Читатель спеки не считает окно
  закрытым и не «находит» его аудитом заново.
- Сценарии покрывают оба окна: апгрейд до re-read (закрыт) и апгрейд в окне
  namer'а (открыт).

**Non-Goals:**

- Правки `internal/worker/worker.go` и любого другого кода. Наблюдаемое
  поведение изменению не подлежит.
- Закрытие остаточного окна (вариант A). Заводится отдельной задачей, если окно
  окажется реальной болью. Дельта поэтому не запрещает вариант A нормативно —
  см. D6.
- Починка `error_code = qbit_add` у задачи, упавшей по недоступным байтам
  `.torrent` (диагностика уводит не туда). Отдано урожаем — см. D7.
- Пересмотр `magnet_timeout`, `Retry` и правил восстановления — они живут в
  `download-tracking` («Таймауты-предохранители downloading») и
  `state-reconciliation` и здесь только цитируются.

## Decisions

### D1. Момент re-read формулируется как «после тик-снимка», а не «перед добавлением»

Рациональ исходного требования — «не полагаться на снимок, снятый ранее вне
блокировки» — выполнен первым re-read под замком. Формулировка «непосредственно
перед добавлением» была сильнее рационали и кодом не достигается: между re-read и
`qbt.Add` стоит namer, вынесенный из-под блокировки намеренно (требование
«Медленные вызовы SHALL выполняться вне блокировки»).

Альтернатива — оставить формулировку и починить код (вариант A) — отклонена
задачей: цена (namer под пересборкой, повторный вывод имени) выше ущерба от окна.

### D2. Маршрут восстановления в спеке называется точно, а не «Retry добьёт»

**Постановка задачи описывала восстановление как «`magnet_timeout` → `failed` →
ручной `Retry` перечитает актуальный `source_type` и добьёт». Проверка по коду
показала, что это верно не всегда**, и спека получит достоверную версию:

- зависший magnet стоит в qBittorrent в `metaDL`; `classify` относит `metaDL` к
  `classDownloading` (`internal/worker/worker.go:1130-1142`), то есть торрент
  «жив и здоров»;
- `Retry` при живом здоровом торренте ставит `reAdd = false`
  (`internal/worker/worker.go:1043-1059`) — **перецепляется** к нему и повторный
  `Add` по актуальному `source_type` не делает;
- фоновая сверка тоже не спасает: `metaDL` без метаданных — сценарий «Источник
  так и не ожил» в `state-reconciliation`, задача остаётся в `failed`.

Достоверный маршрут: оператор убирает зависшую magnet-раздачу из qBittorrent,
после чего `Retry` не видит живого источника и добавляет заново **по актуальному
`source_type`** — байтами `.torrent` (они сохранены `UpgradeCatchedMagnetToTorrent`
и никуда не делись). Обходных путей нет: `Delete` из `failed` недоступен
(`state-reconciliation` «Полное удаление загрузки пользователем» — только
`done`/`orphaned`/`target_missing`), а повторный приём `.torrent` без удаления
раздачи заведёт задачу, которая усыновит тот же зависший `metaDL`-торрент.

Записать в спеку недостоверную версию нельзя: спека — нормативный дом поведения,
а неверное утверждение в ней и есть тот самый класс дефекта, который эта задача
закрывает.

Ущерб при этом не универсален: на публичном трекере или при живом DHT magnet
доберёт метаданные и задача пойдёт штатно. Спека это называет — иначе читатель
решит, что окно всегда ведёт в `failed`.

### D3. Окно называется ограничением с ценой, а не «допустимым навсегда»

Стоимость окна выше, чем предполагала постановка: не «подождать и нажать
Retry», а ожидание `magnet_timeout` (дефолт `24h`) плюс ручной шаг в
qBittorrent. Спека это называет; решение «чинить кодом или нет» остаётся
открытым вопросом задачи, потому что меняется цена, на которой B выбирался
против A.

Требование при этом не переезжает в другую capability: окно принадлежит
добавлению пойманной загрузки, а не восстановлению.

### D4. Два сценария вместо одного

Существующий сценарий на апгрейд между тик-снимком и re-read в спеке не записан
(есть только тест). Без него сценарий про открытое окно читался бы как «re-read
не работает вовсе». Поэтому фиксируются оба: закрытое окно с исходом «добавлено
файлом» и открытое с исходом «добавлен magnet, дальше предохранитель».

### D5. Правится и `ingest`, иначе ложная гарантия просто переезжает

Найдено ревью спек (профиль `design`). `ingest` «Приём источника из
`.torrent`-файла» обещает то же самое от своего лица: прозой — «Тем самым воркер
добавит раздачу файлом, а не magnet-хешем», и `AND`-строкой сценария «Апгрейд
catched-magnet до torrent» — «воркер добавит раздачу файлом (не по magnet)».
`GIVEN` этого сценария выполняется и в окне namer'а, а `THEN` — нет.

Оставить это значило бы не убрать ложную гарантию, а перенести её в соседнюю
capability: следующий аудит `ingest` воспроизвёл бы ту же находку, и цель
изменения («читатель спеки не считает окно закрытым») не была бы достигнута.
Поэтому change получает вторую дельту — на `ingest`: обещание обусловливается
(«если апгрейд лёг до подготовки запроса») и появляется сценарий на окно со
ссылкой на `download-tracking`. Нормативный дом ограничения при этом один —
`download-tracking`; `ingest` только не противоречит ему.

### D6. `SHALL NOT` не ставится там, где решение отложено

Найдено тем же ревью. Первая редакция дельты писала «пересобирать запрос под
блокировкой worker SHALL NOT» и «Восстановление SHALL требовать ручного шага».
`SHALL NOT` не откладывает, а **запрещает**: отложенный вариант A при реализации
оказался бы нарушением только что записанного требования, причём обоснование
запрета — деталь текущей реализации (`sourceAddParts` читает байты), а не
свойство поведения. Вариант A в форме «перечитать только `source_type` под
замком и при расхождении пропустить тик» медленных вызовов под блокировкой не
требует вовсе — и уже был бы отрезан.

Оба места переведены в описательный залог. `SHALL` остаётся только там, где
нормируется наблюдаемое поведение: re-read `source_type`, выбор способа
добавления, исход при недоступных байтах.

### D7. Названа ветка «байты `.torrent` недоступны»

Тем же ревью. Требование впервые называет шаг «подготовка запроса на
добавление», а его провал (`worker.go:455-461`) не заказан ни одним сценарием:
`WARN` и повтор на следующем тике. Раз шаг попал в текст, дешевле всего назвать
и его исход — добавлены предложение и сценарий.

За границами остался диагностический дефект той же ветки: устойчиво недоступные
байты доводят задачу до `catch_timeout` и она падает с `error_code = qbit_add`,
хотя qBittorrent ни при чём. Спека этого не узаконивает (про `error_code` в
добавленном тексте не сказано ни слова); находка отдана урожаем.

## Risks / Trade-offs

- **[Спека узаконивает известный дефект]** → окно названо ограничением с
  ценой и маршрутом восстановления, а не «так и надо»; вопрос о варианте A
  записан и уходит владельцу задач. Дефект перестаёт быть невидимым — сейчас он
  невидим ровно потому, что спека утверждает обратное.
- **[Формулировка «после тик-снимка» окажется слабее нужного]** → рациональ
  («не полагаться на снимок, снятый вне блокировки») в тексте требования
  остаётся явной, поэтому будущий код, потерявший re-read вовсе, требованию
  противоречит так же, как и раньше.
- **[Достоверный маршрут восстановления устареет вслед за `Retry`]** →
  требование ссылается на `state-reconciliation` («Ручной повтор зависшей/упавшей
  загрузки из транспортов»), а не переписывает его правила; изменится `Retry` —
  сверка спека↔спека это поймает.
- **[Кода нет — гейт ничего не докажет]** → оракулы задачи это и предполагают:
  `openspec validate --strict`, неизменность `internal/**` в диффе и то, что
  существующий тест продолжает проходить без правок.
