specs: в state-reconciliation разведены Cancel и Dismiss по состояниям

- требование «Ручное закрытие загрузки» приведено к коду: два пути закрытия,
  error_code на каждом, раскладка поверхностей — описательно, а не SHALL
- уборка своего торрента после отмены названа исключением по состоянию, а не
  по команде; убрана ложная гарантия «данных пользователя не касается»
- в docs/review.md записан проскочивший дефект гарда окна после add и новый
  вопрос проходу adversary про асимметрию признака владения
This commit is contained in:
av
2026-08-06 15:50:41 +03:00
parent 01e64d60de
commit 0b02a8c224
8 changed files with 1125 additions and 28 deletions
@@ -0,0 +1,68 @@
## Why
Требование «Ручное закрытие загрузки (стоп-кран)» обещает команду «Закрыть»
(dismiss) «из **любого** состояния, кроме `deleted`, во всех транспортах» и
метку `error_code = "user_dismiss"` на переходе. Доменная команда
`worker.Dismiss` это выполняет, но **поверхности** её так не показывают:
веб-UI держит «Закрыть» в danger-зоне только для терминальных состояний
(`Dismissable = IsTerminal() && !deleted && !cancelled`), а закрытие
не-терминальной загрузки идёт кнопкой «Отменить»/«Отклонить» → `worker.Cancel`,
которая пишет **пустой** `error_code`. Telegram показывает «Закрыть» ещё по
третьей раскладке — для `failed`/`stuck` и для `done`/`orphaned`/
`target_missing`, а на `review`/`deferred` даёт «Отклонить» → `Cancel`.
Расходится буква, а не поведение: `Cancel` тоже даёт `cancelled`, файлы и
раздачу не трогает, семантика для пользователя та же. Теряется маркер
`user_dismiss` в диагностике — по `error_code` не отличить «пользователь закрыл
активную» от «пользователь отменил». Пока спека утверждает единый путь закрытия,
разрыв невидим и находится заново каждым аудитом capability.
## What Changes
Меняется **заявленное**, не наблюдаемое. Кода изменение не трогает.
- Требование «Ручное закрытие загрузки (стоп-кран)» разводит два уровня:
**гейты доменных путей** (`Cancel` — не-терминальные, `Dismiss` — любое кроме
`deleted`; это нормативно) и **раскладку кнопок по поверхностям** (описана как
сегодняшнее состояние, нормативной не объявляется).
- Требование называет `error_code` на каждом пути закрытия: `Dismiss`
`"user_dismiss"` (нормативно), `Cancel` (отмена активной / отклонение на
ревью) → сегодня пустой (описательно — собственный код у `Cancel` не
запрещён, вопрос открыт). Прежняя формулировка обещала `user_dismiss` на
любом закрытии.
- Требование называет гарантию, которая держится: страница загрузки веб-UI даёт
путь закрытия из каждого не-`deleted` и не-`cancelled` состояния; какой именно
путь — выбор поверхности, а не обязательство домена.
- Отрицание побочных эффектов остаётся при `Dismiss`; для `Cancel` названо
единственное исключение — уборка воркером собственного, только что
добавленного торрента при отмене в окне после `add` (дом исключения —
`download-tracking`).
- Названо, что **оба** пути замещают прежнюю диагностику записи, а не только
`Cancel`.
- Добавляются сценарии закрытия не-терминальной загрузки из веб-UI, закрытия
терминальной, замещения диагностики и расхождения маркера по поверхностям.
- Названо известное ограничение: маркер сегодня кодирует поверхность, а не
намерение; разрыв описан с ценой, а не замолчан.
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `state-reconciliation`: требование «Ручное закрытие загрузки (стоп-кран)» —
доступность закрытия по состояниям и поверхностям, `error_code` на каждом
пути закрытия, сценарии закрытия не-терминальной загрузки.
## Impact
- `openspec/specs/state-reconciliation/spec.md` — одно требование и его
сценарии.
- `internal/httpapi/download.go`, `web/templates/partials/download_main.html`,
`web/templates/partials/card.html`, `internal/tgbot/render.go`,
`internal/worker/worker.go`**только чтение**, правок не предполагается.
Отсутствие правок под `internal/` и `web/` — критерий приёмки задачи.
- Прецедент приёма — [ADR-2026-08-06-spec-follows-code-on-narrow-window](../../../docs/adr/ADR-2026-08-06-spec-follows-code-on-narrow-window.md);
второй ADR о том же не заводится.