Единый источник истины `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>
4.1 KiB
download-tracking Specification
ADDED Requirements
Requirement: Легальность переходов задаётся декларативным графом
Множество легальных переходов машины состояний загрузки SHALL быть объявлено
декларативно в едином месте (internal/store) как отображение from → {разрешённые to}, покрывающее все переходы, которые worker выполняет по всем
capability (прямой путь, state-reconciliation, review). Этот граф SHALL быть
единственным источником истины о легальности рёбер.
Запись состояния (setState, общая основа SetDownloadState и
ActivateIfNoOtherActive) SHALL применять переход, только если он либо объявлен
ребром графа, либо является идемпотентным самопереходом (from == to, переустановка
того же состояния — например, повторная запись ошибки). Переход, не удовлетворяющий
ни одному из условий, запись SHALL отклонять (0 строк UPDATE → ошибка), НЕ применяя
его.
Гейт графа SHALL быть ортогонален остальным гардам записи и НЕ SHALL их ослаблять:
существующий запрет молча оживить терминальную задачу (переход из терминального
состояния разрешён только через ActivateIfNoOtherActive с проверкой владения
хешами) и инвариант «не более одной активной загрузки на infohash» сохраняются. Как
следствие, ребро из терминального состояния (напр. failed → downloading при retry)
SHALL проходить только revive-путём (ActivateIfNoOtherActive) и SHALL отклоняться
обычным SetDownloadState.
Граф SHALL быть надмножеством всех переходов, которые worker уже выполняет: введение гейта НЕ SHALL менять поведение существующих легальных переходов.
Scenario: Объявленный переход применяется
- GIVEN загрузка в состоянии
downloading - WHEN worker записывает переход
downloading → completed(объявленное ребро) - THEN состояние становится
completed
Scenario: Необъявленный переход отклоняется
- GIVEN загрузка в состоянии
review - WHEN делается попытка записать переход
review → done(ребра в графе нет) - THEN запись отклоняется с ошибкой, состояние остаётся
review
Scenario: Идемпотентная переустановка состояния разрешена
- GIVEN загрузка в состоянии
deferred - WHEN записывается переход
deferred → deferred(самопереход) - THEN запись проходит, состояние остаётся
deferred
Scenario: Ребро из терминального состояния только через revive
- GIVEN загрузка в терминальном состоянии
failed - WHEN переход
failed → downloadingделается обычнымSetDownloadState - THEN запись отклоняется (терминальную задачу нельзя оживить мимо гарда владения)
- AND тот же переход через
ActivateIfNoOtherActive(при свободном infohash) проходит