Files
jellybit/openspec/changes/archive/2026-07-08-state-transition-graph/proposal.md
T
avandClaude Opus 4.8 90fd8640ed Машина состояний: декларативный граф легальных переходов
Единый источник истины `allowedTransitions` (from → {разрешённые to}) в
internal/store; `setState` сверяет переход дополнительным SQL-предикатом
`state IN (<легальные источники>)` — необъявленное ребро (и не самопереход)
отклоняется атомарно, с точным сообщением. Гейт ортогонален гарду
терминальности: ребро из терминального состояния проходит только через
ActivateIfNoOtherActive. Без внешней библиотеки-FSM (обоснование — design.md).

Граф выведен построчно из воркера; ревью дизайна поймало 8 preflight-рёбер
(reconcileToReality → orphaned/deleted) и linking→cancel/defer после краха.
Тест-инвариант «cancel/defer достижимы из любого не-терминального» ловит класс
пропущенного ребра. Фикстуры тестов, форсившие состояния через SetDownloadState,
переведены на прямой UPDATE (forceState).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 09:09:17 +03:00

4.3 KiB

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

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