# Design

## Контекст

`linking` — короткое рабочее состояние между «решили раскладывать» и
«разложили». Оно нетерминально и активно, но в отличие от `recognizing` его
никто не листит на рестарте, а переход в него — обычный `SetDownloadState`, чей
сбой раньше проглатывался. Обе дыры (MINOR-7, MAJOR-4) — про то, что `linking`
не был устойчивым владельцем шага.

## Решение 1: `transition` возвращает ошибку — но только там, где она нужна

Параллельный поток правит соседние функции воркера (`Retry`/`checkTimeouts`/
`torrentAge`), поэтому смена сигнатуры `transition` на всех ~20 вызовах
(с добавлением `_ =` в fire-and-forget местах) создала бы лишние конфликты
слияния и шум. Вместо этого:

- `transition(...)` остаётся `void` — обёртка, гасящая ошибку. Все существующие
  вызовы (reconcile, таймауты, команды ревью, финальные переходы `linkPlan`,
  sweep) не трогаются: за ними НЕТ побочного эффекта, зависящего от факта записи
  claim, — переход и есть конец шага.
- `transitionErr(...)` — новая функция, тело прежнего `transition` + `return
  error`. Пинги/скан живут в ней (обёртка делегирует).

**Развилка:** менять сигнатуру `transition` глобально (честнее, но шумно и
конфликтно) против точечного `transitionErr` (`mustTransition` из ревью). Выбран
точечный вариант: минимальный след, локальные правки по функциям, ошибка
возвращается ровно там, где за claim следует побочный эффект.

Использование: `Apply` и авто-раскладка в `finishRecognition` зовут
`transitionErr(StateLinking)` и прерываются при ошибке ДО `linkPlan`. При провале
claim `Apply` остаётся в `review`/`deferred`, а авто-путь — в `recognizing`
(его повторит `recognizePending`); в обоих случаях владелец шага сохраняется.

## Решение 2: провал `CreateFileLinks` уводит в `review`, а не оставляет в `linking`

Хардлинки к этому моменту уже на диске — это учётный, а не безопасностный сбой
(файлы разложены). Оставлять задачу в `linking` нельзя (осиротеет до sweep, а до
того файлы висят без `file_link`). Уводим в `review` с кодом `persist`: повторный
`Apply` идемпотентен — `layout.Apply` вернёт `StatusExists` на уже созданных
ссылках, а `CreateFileLinks` допишет учёт.

**Почему `review`, а не `failed`:** план валиден, сбой транзиентный, самолечение
через повтор естественно ложится в петлю ревью (как коллизия). `failed` уводил
бы в восстановление сверкой, которое к этому кейсу не относится.

Соседний сбой `SupersedeForeignLinks` уже трактуется как учётный (WARN, доводим
до `done`) — тот кейс не меняем: там файлы разложены И учтены, чужой рассинхрон
починит следующий тик сверки.

## Решение 3: sweep осиротевшего `linking` на тике и старте

Новый шаг `sweepLinking` в `pollOnce` (выполняется и первым вызовом до цикла —
это «старт»). Берёт `w.mu`, листит `linking`, каждую переводит `linking → review`
(ребро уже в графе) с кодом `interrupted`.

**Ключ корректности:** активная раскладка (`linkPlan`) держит `w.mu` на весь свой
срок и завершает переход ИЗ `linking` до отпускания замка. Значит любая
`linking`-задача, которую `sweepLinking` видит, взяв `w.mu`, гарантированно НЕ
в полёте — она осталась после краша между claim и финальным переходом. Ложных
срабатываний на живой раскладке нет.

Так `linking` получает владельца на рестарте/тике — по образцу `recognizing`
(`recognizePending`); инвариант «у каждого нетерминального состояния есть
владелец» восстановлен.

## Что НЕ делаем

- Не трогаем граф переходов: ребро `linking → review` уже объявлено в
  `allowedTransitions`.
- Не меняем схему БД.
- Не меняем сигнатуру `transition` глобально (см. Решение 1).
