Files
jellybit/openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/design.md
T
av 0b02a8c224 specs: в state-reconciliation разведены Cancel и Dismiss по состояниям
- требование «Ручное закрытие загрузки» приведено к коду: два пути закрытия,
  error_code на каждом, раскладка поверхностей — описательно, а не SHALL
- уборка своего торрента после отмены названа исключением по состоянию, а не
  по команде; убрана ложная гарантия «данных пользователя не касается»
- в docs/review.md записан проскочивший дефект гарда окна после add и новый
  вопрос проходу adversary про асимметрию признака владения
2026-08-06 15:50:41 +03:00

285 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Context
Аудит capability `state-reconciliation` (сверка код↔спека) нашёл расхождение в
требовании «Ручное закрытие загрузки (стоп-кран)». Спека утверждает единый путь
закрытия из любого состояния кроме `deleted`, помечаемый
`error_code = "user_dismiss"`. Код даёт **два** пути, разведённые по состояниям.
Что показало чтение кода (провенанс каждого факта — путь:строка):
| Факт | Где |
| --- | --- |
| `Cancel` отклоняет терминальные состояния, ставит пустые `error_code`/`error_msg` | `internal/worker/worker.go:973` |
| `Dismiss` доступен из любого состояния кроме `deleted`, ставит `user_dismiss`, из `cancelled` — no-op без записи | `internal/worker/worker.go:1001` |
| Веб-UI: «Закрыть» только для терминальных, кроме `deleted`/`cancelled` | `internal/httpapi/download.go:129` |
| Веб-UI: «Отменить» только для не-терминальных | `web/templates/partials/download_main.html:26`, `partials/card.html:32` |
| Экран ревью: «Отклонить» → `Cancel` | `web/templates/partials/review_main.html:77` |
| Telegram: «Закрыть» на `failed`/`stuck` и на `done`/`orphaned`/`target_missing` | `internal/tgbot/render.go:271`, `:290` |
| Telegram: «Отклонить» → `Cancel` на карточке ревью | `internal/tgbot/render.go:119`, `bot.go:325` |
| Telegram: `downloading`/`recognizing`/`linking`/`completed`/`catched` — карточка без действий (только ссылка в веб) | `internal/tgbot/render.go:39` |
| REST: есть `cancel` и `retry`, `dismiss` нет | `internal/httpapi/httpapi.go:152` |
| `failed` терминален, `stuck` — нет | `internal/store/download.go:54` |
| Граф переходов уже описывает пару `Cancel`/`Dismiss` прозой | `internal/store/download.go:86` |
Важное следствие таблицы: постановка задачи описывала разрыв как
«веб-UI против домена», а он шире — раскладка путей **разная у разных
поверхностей**. Telegram даёт `Dismiss` из `stuck`, то есть из не-терминального
состояния, а на `review` даёт `Cancel`; веб-UI режет строго по терминальности.
Ограничение по постановке: **кода изменение не трогает** — критерий приёмки
задачи требует пустого дифа под `internal/` и `web/`.
## Goals / Non-Goals
**Goals:**
- Нормативный дом поведения перестаёт утверждать то, чего поверхности не дают.
- Требование называет `error_code` на каждом пути закрытия, а не на одном.
- Требование называет гарантию, которая действительно держится: закрытие
доступно из любого не-`deleted` состояния хотя бы одной поверхностью.
- Разрыв наблюдаемости назван с ценой, чтобы следующий аудит не открывал его
заново.
**Non-Goals:**
- Не менять поведение: `Dismiss` из не-терминальных состояний веб-UI не
зовётся, гейт `Dismissable` остаётся как есть.
- Не заводить второй ADR о приёме «спека следует за кодом» — приём уже описан
в [ADR-2026-08-06-spec-follows-code-on-narrow-window](../../../docs/adr/ADR-2026-08-06-spec-follows-code-on-narrow-window.md).
- Не переписывать требование `review` о команде «Отклонить»: его предмет —
экран ревью, а не закрытие как класс.
- Не заводить `dismiss` в REST: REST помечен опциональным транспортом.
## Decisions
### D1. Спека двигается к коду — но только в той половине, где приём ADR применим
Приём и его границы заданы ADR-2026-08-06: двигается тот, **чья формулировка
сильнее рационали**. Требование содержит **два** обещания, и приём применим
только к одному из них — ревью предложения показало, что первая редакция этого
не различала.
- **Обещание доступности** («закрыть можно любую загрузку кроме `deleted`»).
Рациональ кодом выполнен: страница загрузки веб-UI даёт путь закрытия из
каждого не-`deleted`, не-`cancelled` состояния. Формулировка «команда
`dismiss` из любого состояния во всех транспортах» была сильнее рационали и
требовала от каждой поверхности **обе** кнопки. Здесь спека и правда
двигается к коду.
- **Обещание различимости** («закрытие помечено `user_dismiss`»). Рациональ —
различать инициатора в диагностике — кодом **не** достигнут, а по правилу
того же ADR недостающая гарантия чинится кодом. Поэтому спека этот пункт
**не закрепляет**: сегодняшняя пустота кода на пути `Cancel` описана как
факт, а не как `SHALL`, и вопрос оставлен открытым (D7).
Альтернатива C (оставить всё как есть) отвергается по тому же основанию, что в
ADR: расхождение воспроизводится каждым аудитом, а читатель спеки считает
маркер гарантированным.
### D2. Требование разводит доменную команду и обязательства поверхностей
Прежний текст смешивал два уровня в одном предложении («команда доступна из
любого состояния во всех транспортах»). Новый текст разводит: нормативны
**гарантия закрытия** (страница загрузки веб-UI даёт путь из каждого
не-`deleted`/`cancelled` состояния) и **гейты доменных путей** (`Cancel`
не-терминальные, `Dismiss` — всё кроме `deleted`). Раскладка кнопок по
поверхностям нормативной **не объявляется**: она описана как сегодняшнее
состояние, перечислением состояний, а не оборотом «там, где карточка вообще
даёт действия».
Первая редакция закрепляла раскладку через `SHALL`, и ревью предложения нашло
в этом две беды разом: она противоречила заявке этого же решения («спека
фиксирует гарантию, не вёрстку»), а Telegram-половина формулировки была
круговой — истинной при любом поведении кода, включая удаление кнопки.
Описательная форма с перечнем состояний фальсифицируема (по ней строится
таблица «состояние × поверхность») и не превращает будущее выравнивание
поверхностей в нарушение спеки.
Альтернатива — перечислить в спеке точную раскладку клавиатур Telegram —
отклонена: спека стала бы копией `render.go` и краснела бы от косметической
правки кнопки.
### D3. `error_code` называется на каждом пути; нормативен только `user_dismiss`
Пустой код — тоже наблюдаемое обязательство, и умолчание о нём породило задачу.
Пишем прямо: `Dismiss``"user_dismiss"` (нормативно), `Cancel` → сегодня
пусто (описательно, см. D1 и D7). К этому добавлено то, чего в старом тексте не
было вовсе: **оба** пути замещают прежнюю диагностику записи. Первая редакция
приписывала затирание только `Cancel` и тем создавала ложное впечатление, что
маркерный путь причину сохраняет; `worker.go:1016` пишет `user_dismiss` поверх
`qbit_error` ровно так же.
### D4. Ограничение названо в теле требования, а не спрятано в design
Форма взята из ADR-2026-08-06: молчащая ложная гарантия дороже названного
ограничения. Абзац «известное ограничение» стоит в самом требовании, потому что
архив change'а читают реже, чем спеку.
### D5. `review` не трогаем
Команда «Отклонить» описана в `review` («Команды ревью и их эффекты»,
`review/spec.md:33`, `:370`) — там её дом. Здесь она упоминается как **один из
путей закрытия** со ссылкой на состояние, но требование `review` не
переписывается: иначе у одного факта появилось бы два нормативных дома.
Цена решения названа честно: `error_code` пути «Отклонить» теперь стоит в
`state-reconciliation`, а сама команда описана в `review`. Разнесение
оправдано тем, что предмет здесь — **закрытие как класс**, и сравнение двух
путей имеет смысл только рядом.
### D6. Сценарии добавляются на оба новых утверждения
Постановка требовала сценарий закрытия не-терминальной загрузки из веб-UI.
Добавляются четыре: веб-UI не-терминальная (пустой код), терминальная (маркер +
отказ `Cancel`), один и тот же `stuck` с двух поверхностей (маркер зависит от
поверхности), замещение прежней диагностики стоп-краном. Плюс переписан
сценарий повторного закрытия `cancelled` (no-op без записи **и** отказ `Cancel`
конфликтом).
Выброшены два кандидата первой редакции. Сценарий «Закрытие зависшей загрузки»
с `GIVEN stuck (или failed/deferred)` обещал пользовательский путь «Закрыть»
для `deferred`, которого нет ни в одной поверхности, — ровно та ложная
гарантия, ради снятия которой задача и заведена. Сценарий «Отклонение на ревью
из Telegram» привязывал норму к кнопке карточки ревью, то есть заводил второй
дом факту, живущему в `review` (D5), и дублировал сценарий веб-UI.
### D7. Собственный код у `Cancel` — открытый вопрос, а не отвергнутый вариант
Первая редакция обосновывала потерю наблюдаемости так: «единый маркер
потребовал бы звать `Dismiss` из не-терминальных состояний веб-UI, то есть
менять рабочее поведение». Архитектурный проход показал, что это ложное
основание: рассматривались вариант A (звать `Dismiss` из веб-UI — да, меняет
поведение) и вариант C (ничего не делать), а **третья форма не
рассматривалась** — дать `Cancel` собственный непустой код (`user_cancel`).
Она меняет одну строку `worker.go:985`, не трогает ни состояний, ни файлов, ни
интерфейса, и восстанавливает различение полностью.
Эта форма выходит за объявленную границу задачи: критерий приёмки A3 требует
пустого диффа под `internal/` и `web/`. Решать её не пайплайну — вопрос
записан наружу (см. «Open Questions»), а изменение урезано до остатка:
**спека перестаёт лгать и перестаёт цементировать пустоту**. Отсюда описательная
форма в D3 — будущая одностроковая починка не станет нарушением спеки и не
потребует нового change.
Заодно назван настоящий масштаб потери, которого постановка не видела: маркер
сегодня кодирует **поверхность**, а не намерение. Один и тот же `stuck`,
закрытый из Telegram, получает `user_dismiss`, а закрытый из веб-UI — пустой
код. Это зафиксировано сценарием, чтобы вопрос не пришлось открывать заново.
### D9. Исключение про уборку торрента висит на состоянии, а не на команде
Правка чекпоинта 2. Редакция D8 повесила carve-out на `Cancel` — и промахнулась
осью. Уборка в `worker.go:534` срабатывает по условию `after.State !=
StateCatched`, то есть безразлична к тому, какая команда увела задачу из
`catched`; `Dismiss` из `catched` даёт тот же `torrents/delete` с
`deleteFiles=true`. Проход `adversary` воспроизвёл это тестом
(`TestDismissInAddWindowCallsQbitDelete`). Гейт `Dismiss` при этом **шире**
гейта `Cancel` — значит формулировка «`Dismiss` не зовёт qBittorrent никогда»
была ложной ровно там, где ошибиться дороже всего.
Исключение переформулировано по условию («закрытие любым путём, уведшее задачу
из `catched` в окне после `add`») — как оно и записано в `download-tracking`,
командо-нейтрально.
Заодно убрано утверждение «и данных пользователя не касается». Оно сильнее того,
что гарантирует дом исключения: гард подтверждает отсутствие **записи торрента**
в qBittorrent, но не отсутствие **данных** на диске, и `adversary` построил путь,
на котором сносятся пред-существующие файлы пользователя
(`TestCancelInAddWindowDeletesPreExistingData`). Сам этот дефект — в коде и
пред-существующий; здесь снимается только ложная гарантия в тексте, а дефект
уходит урожаем.
### D10. Перепривязка названа не-лекарством
Правка чекпоинта 2. Первая редакция писала, что закрытие оставляет ссылки «без
штатной команды снятия **до повторной привязки**» — то есть подавала relink
лекарством. `adversary` показал тестами (`TestDismissRelink`,
`TestStrandedLink`), что это не так: `Apply` заводит новый батч, а `Undo` и
`Delete` работают только с последним, поэтому ссылка прежнего батча брошена
навсегда, и последующее «полное удаление» перестаёт освобождать место. Текст
приведён к факту; расширение `Undo`/`Delete` на все батчи — урожай.
### D11. Подтверждение и лог перехода описаны как есть, а не как хотелось
Правка чекпоинта 2. Два новых обязательства первой редакции въезжали уже
нарушенными:
- «SHALL требовать отдельного подтверждающего шага» — без JS диалога в веб-UI
нет (гейт там только раскрытие `<details>` и явный submit), а в Telegram нет
danger-зоны. Норма сужена до фактического гейта: обособление плюс
дополнительный шаг там, где транспорт его поддерживает.
- «прежний код остаётся в логе перехода» — лог-строки `Cancel` и `Dismiss`
прежнюю диагностику не пишут вовсе (`worker.go:985-989`, `:1016-1020`).
Формулировка исправлена: причина восстановима только по более раннему
переходу в `failed`/`stuck` и только пока её держит ретенция логов.
Оба раза выбран вариант «привести текст к факту», а не «оставить `SHALL` целью»:
нормативный дом с заведомо невыполненным `SHALL` — это ровно тот дефект, ради
снятия которого заведена задача.
### D8. Отрицание побочных эффектов сужено до `Dismiss` с явным исключением
Первая редакция обобщила «команда SHALL только менять статус, qBittorrent не
звать» с `Dismiss` на **оба** пути — и тем самым нормативно запретила уборку
воркером собственного, только что добавленного торрента при отмене в окне после
`add`. Эта уборка предписана `download-tracking` («Добавление пойманной
загрузки в qBittorrent»), закреплена тестом
`TestProcessCatchedCancelledAfterAddRemovesTorrent` и стоит вторым исключением
инварианта «источник неприкосновенен» в `CLAUDE.md`. Изменение, чья цель —
снять ложную гарантию, чуть не завело новую, да ещё и противоречащую соседней
capability.
Отрицание возвращено к `Dismiss`, а для `Cancel` дано с явной ссылкой на дом
исключения. Общее правило, которое отсюда следует: универсальное «SHALL NOT
трогать X» обязано перечислять известные carve-out'ы или ссылаться на
требование, где они живут.
## Risks / Trade-offs
- **[Асимметрия поверхностей описана и тем самым узаконена, а завтра её
захотят выровнять]** → Описательная форма (D2) выравнивание не блокирует:
спека не станет ложной от того, что веб-UI добавит «Закрыть» на `stuck`.
Нормативны только гарантия закрытия и гейты доменных путей.
- **[Прецедент «спека следует за кодом» применяется буквально]** → Риск
унаследован от ADR-2026-08-06 и там же оговорён. Условие приёма проверено
поимённо и выполнено **не целиком**: для обещания доступности — да, для
обещания различимости — нет, поэтому второе спекой не закрепляется (D1, D7).
Это и есть защита от буквального применения.
- **[Новые сценарии не имеют теста-оракула]** → Изменение чисто текстовое,
гейт его регрессию не покрасит. Оракулом остаётся `openspec validate
--strict` (форма) и чтение кода (содержание). Это идёт в границы покрытия
ревью, а не замалчивается. Отдельно: клавиатуры Telegram не покрыты тестами
вовсе, поэтому описанная в требовании раскладка по этой поверхности держится
только на чтении `render.go`.
- **[Требование выросло в объёме]** → Выросло сознательно: раньше оно было
короче своей же предметной области. Раскладка клавиатур в него не заехала
(D2), поэтому косметическая правка UI его не тронет.
- **[Часть найденного ревью не закрыта этим изменением]** → Отдано урожаем, а не
замолчано (полный список с оракулами — в `review/triage.md`): гард окна после
`add` подтверждает отсутствие торрента, но не данных, и сносит
пред-существующие файлы пользователя (кандидат `critical`, воспроизведён
тестом); `Undo`/`Delete` работают только с последним батчем; прежняя
диагностика не дублируется в лог перехода; «per-download блокировка» — на
деле глобальный мьютекс, файловый ввод-вывод идёт под ним; дефолтный список
даёт `SCAN download`, а `cancelled` копятся без ретеншена; та же no-JS-дыра
в пред-существующем требовании «Полное удаление»; гонка «кнопка отрисована —
состояние ушло» не описана ни одним требованием проекта; литералы `error_code`
рассыпаны по трём спекам без сводной таблицы.
- **[Кросс-ссылки в `openspec/specs/` никем не проверяются]** → Шаг `canon`
гейта обходит только `docs/` и `CLAUDE.md`. Первая редакция принесла в спеку
markdown-ссылку, не резолвившуюся ни из change, ни после архивации; она
заменена бэктик-путём — это и есть сложившаяся в `openspec/specs/` конвенция
(markdown-ссылок там нет ни одной). Promote-кандидат отдан урожаем.
## Open Questions
- **Дать ли `Cancel` собственный непустой `error_code`** (напр. `user_cancel`).
Цена — одна строка `worker.go:985` плюс строка в `docs/database.md`; выигрыш —
различение «пользователь отменил активную» / «стоп-кран» / «сверка» в
диагностике восстанавливается полностью, и «известное ограничение» из
требования исчезает. Не делается здесь: выходит за границу задачи (критерий
приёмки A3 — пустой дифф под `internal/`). Пока решения нет, стоит то, что
описано: маркер кодирует поверхность, а не намерение. Рекомендация — завести:
цена несоразмерно мала.
- Стоит ли давать `dismiss` в REST ради равенства транспортов. Сегодня REST
помечен опциональным и отдаёт только `cancel`; вопрос не блокирует изменение.