specs: в state-reconciliation разведены Cancel и Dismiss по состояниям
- требование «Ручное закрытие загрузки» приведено к коду: два пути закрытия, error_code на каждом, раскладка поверхностей — описательно, а не SHALL - уборка своего торрента после отмены названа исключением по состоянию, а не по команде; убрана ложная гарантия «данных пользователя не касается» - в docs/review.md записан проскочивший дефект гарда окна после add и новый вопрос проходу adversary про асимметрию признака владения
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
## 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`; вопрос не блокирует изменение.
|
||||
Reference in New Issue
Block a user