# Декларативный граф переходов машины состояний ## 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` + граф), новый тест графа. Воркер и транспорты — без изменений (граф прозрачен для легальных переходов). - Зависимости: ноль новых (принцип «минимум компонентов» соблюдён).