- требование «Ручное закрытие загрузки» приведено к коду: два пути закрытия, error_code на каждом, раскладка поверхностей — описательно, а не SHALL - уборка своего торрента после отмены названа исключением по состоянию, а не по команде; убрана ложная гарантия «данных пользователя не касается» - в docs/review.md записан проскочивший дефект гарда окна после add и новый вопрос проходу adversary про асимметрию признака владения
26 KiB
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.
- Не переписывать требование
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; вопрос не блокирует изменение.