# Декларативный граф переходов машины состояний

## Why

Легальность переходов машины состояний загрузки сейчас **нигде не записана явно**.
Чтобы понять «из `downloading` куда можно», надо прочитать весь `worker` (три файла:
`worker.go`, `reconcile.go`, `review.go`) плюс store-методы. Единственный
механический гард в `store.setState` — грубый: он запрещает молча оживить
терминальную задачу (`state NOT IN terminal` без `reviveOK`) и держит инвариант
«одна активная на infohash» (в SQL-транзакциях). Но он **не проверяет само ребро**
перехода: `SetDownloadState(review → done)` или `linking → completed` пройдут молча,
хотя таких переходов машина не выполняет.

Это оставляет класс латентных багов без страховки: будущая правка воркера может
записать состояние, которого граф не предусматривает, и мы узнаем об этом только по
странице задачи в неверном состоянии.

Внешнюю библиотеку-FSM мы сознательно НЕ берём (см. `design.md`, «Почему не
библиотека»): настоящий инвариант держится атомарно в SQLite и in-memory FSM в
транзакции участвовать не может; наши переходы — это в основном сверка с реальностью
qBittorrent, а не событийный автомат. Берём соразмерное: **свой декларативный граф**
легальных рёбер в `internal/store`, который `setState` сверяет как дополнительный
предикат, роняя необъявленный переход громко.

## What Changes

- **ADDED Requirement: Легальность переходов задаётся декларативным графом** —
  единый источник истины `from → {разрешённые to}` в `internal/store`; переход, не
  объявленный ребром графа (и не идемпотентный самопереход `from == to`), запись
  состояния отклоняет.
- Гейт встраивается в `store.setState` дополнительным SQL-предикатом
  `AND state IN (<легальные источники для to>)` — атомарно, без отдельного чтения;
  существующий гард терминальности и инвариант «одна активная на infohash»
  сохраняются без изменений и остаются **ортогональны** (граф говорит «ребро есть»,
  гард терминальности — «но не мимо `ActivateIfNoOtherActive`»).
- Тест согласованности: граф хорошо сформирован (все состояния известны), объявленные
  рёбра проходят, необъявленные — отклоняются, а рёбра из терминальных состояний
  проходят только через revive-путь (`ActivateIfNoOtherActive`), но не через
  `SetDownloadState`.
- Поведение существующих легальных переходов НЕ меняется: граф — надмножество всего,
  что воркер уже выполняет.

## Impact

- Specs: `download-tracking` (владелец прямого пути машины состояний).
- Код: `internal/store/download.go` (`setState` + граф), новый тест графа. Воркер и
  транспорты — без изменений (граф прозрачен для легальных переходов).
- Зависимости: ноль новых (принцип «минимум компонентов» соблюдён).
