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