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

49 lines
4.3 KiB
Markdown

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