# 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) проходит