Files
jellybit/openspec/changes/archive/2026-07-08-state-transition-graph/specs/download-tracking/spec.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.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) проходит