# Триаж чекпоинта 2 — change `cancel-dismiss-split-wording` Дата: 2026-08-06. Профиль: `standard`, режим: `по графу`. База диффа: `01e64d60de7495900d9a4c8f5ce9bf811ee7b7e6`. ## Сводка **Гейт:** зелёный. Кодовые шаги (build/vet/lint/gofmt/test/flaky/race/ diff-coverage/migrations/govulncheck) — `SKIP` с причиной «кода в диффе нет»; `canon` — `OK`; `gitleaks` — `OK`. **Проходы поимённо:** | Проход | Исход | |---|---| | `review-gate` | зелёный (кодовые шаги SKIP — кода в диффе нет) | | `review-specs` | отработал, 4 находки + 4 строки границ | | `review-code` | отработал, 1 находка + подтверждение A3 + блок «нарушений нет» | | `review-adversary` | отработал, 5 находок (4 воспроизводящих теста), 3 свойства без пути | | `review-reimpl` | **не запускался** — стадия 3, только `deep`; вне профиля `standard` | | `review-architecture` | **не запускался на этом чекпоинте** — стадия 4 (`wide`/`deep`); отработал на чекпоинте 1 в профиле `design`, находки закрыты правками предложения (design.md, D1–D3, D6–D8) | | `review-ops` | отработал, 3 находки + ответы на обязательные вопросы | Состав запущенного сверен с профилем `standard` — расхождений нет. **Счёт находок:** на входе 13 (S1–S4, C2, A1, A2, A3adv, A4, A5, O1–O3; C1 — подтверждение приёмки, не находка). После дедупа — 10 (S1=A3adv, C2=A4, S4=A5). Разложено: 3 блокируют, 4 стоит исправить сейчас, 0 гипотез, 6 в урожай, 1 promote-кандидат. **Состояние change:** файлы staged, но не закоммичены (`HEAD` == база; дифф worktree против базы — 5 файлов, 514 вставок, все под `openspec/changes/cancel-dismiss-split-wording/`). Перед архивацией нужен коммит — это состояние прогона, не находка. **Вердикт по архивации:** change **годится к архивации после инлайн-правок** секций 1–2. Находки, требующей остановки или переработки предложения, нет: все семь пунктов — правки текста дельты, локальные и однозначные, кроме одной развилки (п. 7, выбор регистра нормы о подтверждении). Дефекты в коде, вскрытые тестами adversary, — пред-существующие, изменением не введены и не задеты (критерий A3 требует пустой кодовый дифф); они уходят урожаем. ## Приёмка A1–A3 (доклад приёмщику, не отметка «принято») - **A1** — требование различает `Cancel`/`Dismiss` по состояниям (не-терминальные против «любое кроме `deleted`», конфликт против no-op) и называет `error_code` обоих путей (`user_dismiss` против пустого). Оракул: `openspec validate --strict cancel-dismiss-split-wording` → `Change … is valid`, exit 0 (прогнано мной). **Выполнен**, с оговоркой: нормативная фраза «SHALL называть путь закрытия» внутренне противоречит пустому коду `Cancel` — п. 4 ниже. - **A2** — сценарий «Закрытие не-терминальной загрузки из веб-UI идёт отменой» (дельта, строки 112–119): исход `cancelled`, `error_code` назван (пустой). Оракул: тот же прогон + чтение сценария. **Выполнен.** - **A3** — `git diff --stat <база> -- internal web cmd` — **пусто** (прогнано мной по worktree; полный дифф — 5 файлов только под `openspec/changes/`). Гейт `Dismissable` и danger-zone шаблона не тронуты. **Выполнен.** --- ## Блокирует мердж (архивацию) Все три — корзина **(а): дефект введён текстом этого изменения**. Все три — правки одного-двух абзацев дельты `openspec/changes/cancel-dismiss-split-wording/specs/state-reconciliation/spec.md`. ### 1. Дельта гарантирует «`Dismiss` SHALL NOT вызывать qBittorrent» — гарантия ложна: `Dismiss` из `catched` в окне после `add` даёт `torrents/delete` с `deleteFiles=true` - Файл: дельта, строки 36–47; код `internal/worker/worker.go:520–556` (уборка по условию `after.State != store.StateCatched`, безразлична к команде), `internal/httpapi/review.go:384–391` (маршрут `dismiss` по состоянию не гейтится — путь достижим сегодня прямым POST) - Severity: major, Confidence: high. Корзина: **(а)** — сам механизм уборки пред-существует и нормирован командо-нейтрально в `download-tracking` (строки 216–234); ложна только атрибуция исключения одной команде `Cancel`. - Оракул (прогнан мной): `go test ./tmp/adv/ -run TestDismissInAddWindow -v` → «ADV-3 воспроизведён: закрытие путём Dismiss (error_code="user_dismiss") вызвало torrents/delete deleteFiles=[true]». Плюс `internal/worker/catched_test.go:244–268` — штатный тест уборки ставит `cancelled` напрямую, не через `Cancel`. - Дедуп: S1 (`specs`) + A3adv (`adversary`) — одна причина: исключение в коде привязано к состоянию/окну, а не к команде. - Действие: **инлайн**. Переформулировать исключение по условию, а не по команде: «закрытие **любым путём**, уведшее задачу из `catched` в окне между нашим `add` и записью перехода» — как это уже сформулировано в `download-tracking`. Тем же правом убрать из абзаца атрибуцию «`Cancel` тем же ограничен, с одним исключением» — исключение общее для обоих путей. - **В ту же правку входит C2/A4** (дедуп: одна ссылка, одно предложение): markdown-ссылка `../../../specs/download-tracking/spec.md` (строка 44) не резолвится **ни из позиции дельты** (нормализуется в `openspec/changes/specs/download-tracking/spec.md`, файла нет; верно было бы 4 уровня), **ни после архивации** (из `openspec/specs/state-reconciliation/` нормализуется в `specs/download-tracking/spec.md`; верно — 1 уровень). Единого относительного пути, верного в обеих позициях, не существует. Проверено арифметикой пути (`os.path.normpath` + `exists`, прогнано мной). Фикс: дать ссылку **бэктиком** — `openspec/specs/download-tracking/spec.md` — как во всех соседних кросс-ссылках capability (прецедент: `openspec/specs/state-reconciliation/spec.md:92`); markdown-ссылок в `openspec/specs/` нет ни одной (grep пуст), и `docs.py check` этот каталог не проверяет — битую ссылку не покрасил бы никто. ### 2. Дельта заявляет, что уборка в окне после `add` «данных пользователя не касается» — безусловное утверждение сильнее того, что гарантирует код: пред-существующие файлы под `paths.downloads` уничтожаются - Файл: дельта, строки 45–46; код `internal/worker/worker.go:495–556` (гард «подтверждённое отсутствие» проверяет отсутствие **торрента**, не отсутствие **данных**) - Severity: major (для текста; кодовый дефект — кандидат `critical`, см. урожай У-1), Confidence: high для текстовой части. Корзина: **(а)** для текста / **(в)** для кода — дыра в гарде пред-существует, изменением не задета; этим change введена только ложная гарантия. - Оракул (прогнан мной): `go test ./tmp/adv/ -run TestCancelInAddWindow -v` → «ADV-1 воспроизведён: worker вызвал torrents/delete deleteFiles=[true]; данные …/downloads/Show/e1.mkv существовали под paths.downloads ДО add (гард проверял отсутствие торрента, а не отсутствие данных)». - Почему текст — major, а не перенос `critical` с кода: текст сам данных не уничтожает; но, будучи влит в нормативный дом, он узаконивает дыру — следующий аудит поверит «данных не касается» и закроет расследование. Severity кода на severity текста автоматически не переносится; кодовый дефект едет урожаем со своим весом. - Действие: **инлайн**. Убрать безусловное «и данных пользователя не касается». Right-size замена — отослать к дому нормы без собственной гарантии («нормирована в `download-tracking`»), не расширяя дельту признанием дыры: дыра — предмет задачи У-1, а не этого требования. ### 3. Обещание «библиотечные ссылки без штатной команды снятия **до повторной привязки**» ложно: перепривязка их не снимает никогда — `Undo`/`Delete` работают только с последним батчем - Файл: дельта, строки 62–66 и сценарий 93–101; код `internal/worker/review.go:332` (каждый `Apply` — новый батч), `:557–572` (`Undo` — только `LatestBatchID`), `:625–641` (`Delete` — только `LatestBatchID`) - Severity: major, Confidence: high. Корзина: **(а)** для формулировки / **(в)** для кода (поведение батчей пред-существует, изменением не задето). - Оракул (прогнан мной): `go test ./tmp/adv/ -run 'TestDismissRelink|TestStrandedLink' -v` → «ADV-2: в библиотеке осталась неуправляемая ссылка (батч 1), которую не снимает ни Undo, ни Delete»; «ADV-2b: запись в deleted, раздача снесена с файлами, но в библиотеке осталась неуправляемая ссылка с живыми данными — место не освобождено». - Последствие текста: единственный маршрут к брошенной ссылке — ровно та связка `Dismiss` из `done` → relink, которую дельта нормирует; «до повторной привязки» называет relink лекарством, которым он не является, а после него «полное удаление» перестаёт освобождать место — danger-зона обещает обратное. - Действие: **инлайн**. Заменить «до повторной привязки» честным: «…без штатной команды снятия; последующая перепривязка их **не** снимает — `Undo` и `Delete` работают только с последним батчем раскладки». Расширение `Undo`/`Delete` на все батчи — урожай У-2. ## Стоит исправить сейчас ### 4. Нормативное «`error_code` SHALL называть путь закрытия» нарушено пустым кодом `Cancel` и опровергнуто тем же документом тремя абзацами ниже - Файл: дельта, строка 49; код `internal/worker/worker.go:985` (`SetDownloadState(ctx, id, StateCancelled, "", "")` — проверено чтением) - Severity: major, Confidence: high. Корзина: **(а)** — фраза введена этим изменением. - Оракул: код (пустой код ничего не «называет») + сам документ: строки 56–58 («пустота кода на пути `Cancel` … не закреплена нормативно») и 73–82 («известное ограничение»). `SHALL`, который документ сам объявляет неудовлетворённым, — приглашение следующему аудиту завести ту же задачу. - Действие: **инлайн**. Перевести фразу в согласованный регистр: например, «`error_code` перехода SHALL **различать** пути закрытия, а не унифицировать их» (пустой у `Cancel` против `user_dismiss` у `Dismiss` — различает), либо сузить нормативность до `Dismiss` (его `SHALL user_dismiss` уже стоит строкой ниже). Остальной абзац и «известное ограничение» уже согласованы. ### 5. «Прежняя диагностика остаётся только в логе перехода» — фактически неверно: лог-строки перехода `Cancel`/`Dismiss` прежних `error_code`/`error_msg` не содержат - Файл: дельта, строки 53–58 и сценарий 103–110; код `internal/worker/worker.go:985–991, 1016–1022` — проверено чтением: `Cancel` логирует только `from`/`to`, `Dismiss` — `from`/`to`/`code` **нового** перехода; контракт поля `code` — `docs/conventions/logging.md:26–30`. - Severity: major, Confidence: high. Корзина: **(а)** для формулировки / **(б)** для наблюдаемости (лог и раньше не нёс прежней диагностики — урожай У-3). - Последствие: причина падения восстановима только по более ранней, никак не связанной по смыслу лог-строке, чья доступность зависит от неописанной ретенции логов (`docs/architecture.md` — только «логи в stdout контейнера»). - Действие: **инлайн**. Поправить формулировку: прежние код и текст «в записи не сохраняются; лог-строка перехода их не дублирует — они остаются лишь в более ранних строках лога состояния». Добавление `prev_error_code`/`prev_error_msg` в лог — кодовая правка, запрещённая критерием A3, → урожай У-3. ### 6. Описанная раскладка Telegram врёт в трёх клетках построенной по ней таблицы «состояние × поверхность» - Файл: дельта, строки 30–34; код `internal/tgbot/bot.go:452–461`, `internal/tgbot/render.go:44–50, 281–290` — проверено чтением: уведомление `EventDone` — текст без клавиатуры; `EventTargetMissing`/`EventOrphaned` — только ссылка в веб (`webOnly`, и та `nil` при пустом `web_base_url`); `StateLinking` — клавиатуры нет вовсе (не «отсылает в веб»). «Закрыть» появляется на **карточке** (`renderCard`/refresh), а не в уведомлении. - Severity: minor, Confidence: high. Корзина: **(а)** — перечень введён этим изменением (текст описателен: «сегодня раскладка такова», но неверен). - Последствие: реальный пробел — уведомление о рассинхроне приходит без стоп-крана — текстом замаскирован. - Действие: **инлайн**. Либо уточнить: «Telegram даёт „Закрыть“ на **карточке** загрузки в `failed`/`stuck`/`done`/`orphaned`/`target_missing`; уведомления о `done` и рассинхроне кнопок закрытия не несут», либо снять перечень Telegram целиком, оставив нормативную гарантию только за страницей загрузки (раскладка по поверхностям всё равно объявлена ненормативной). ### 7. Новый `SHALL` об «отдельном подтверждающем шаге» въезжает уже нарушенным: без JS диалога нет, в Telegram нет ни danger-зоны, ни подтверждения - Файл: дельта, строки 68–71; код `web/templates/partials/download_main.html:95–98` — проверено чтением, комментарий дословно: «без JS гейт — только раскрытие details и явный submit (диалога нет)»; `internal/tgbot/render.go:266–290` — «Закрыть» в общем ряду кнопок. `openspec/specs/web-ui/spec.md` нормирует «без JavaScript не ломается». - Severity: minor, Confidence: medium. Корзина: **(а)** — прежняя редакция обязательства не содержала (уточнение adversary), норма введена этим изменением; тот же изъян в пред-существующем требовании «Полное удаление загрузки пользователем» — корзина (в), урожай У-6. - Дедуп: S4 (`specs`) + A5 (`adversary`) — одна причина. - Действие: **развилка** — выбор регистра нормы, а не правка формулировки: 1. **Сузить норму до фактического гейта**: «„Закрыть“ SHALL размещаться в обособленной danger-зоне, скрытой по умолчанию (раскрытие — сознательный шаг); с JS дополнительно SHALL показываться диалог подтверждения» + назвать известным ограничением, что Telegram danger-зоны не даёт. Цена: слабее звучит R7, зато спека не лжёт и код не требуется. 2. **Оставить `SHALL` как цель** и завести урожаем задачу: no-JS-подтверждение (например, промежуточная страница-подтверждение) и danger-гейт в Telegram. Цена: до выполнения задачи нормативный дом содержит невыполненный `SHALL` (ровно то, за что бьёт п. 4). Рекомендация триажа — вариант 1: он согласуется с R12 («спека фиксирует наблюдаемое») и с решением п. 4. ## Гипотезы без доказательства Пусто. Все выжившие находки имеют прогнанный оракул (тест, чтение названных строк кода, арифметика пути, вывод команды). Понижений за отсутствие оракула не было; `critical` кода из A1 не понижен, а отделён от текстовой находки и уехал урожаем со своим весом (У-1) — его единственный непрогнанный шаг назван там честно. Отдельно: совпадение S1/A3adv и S4/A5 в разных проходах учтено как рост приоритета, не как рост Confidence — под всеми проходами одна модель. ## Promote candidates - **Проверка ссылок в `openspec/`** — шаг `canon` (`docs.py check`) обходит только `docs/` и `CLAUDE.md`; битую markdown-ссылку в спеке не красит никто (п. 1/C2 прожил бы до архивации молча). Кандидат: расширить проверку битых ссылок на `openspec/specs/` и `openspec/changes/`, либо записать конвенцией «кросс-ссылки в спеках — только бэктиком» (сегодня это фактическая, но нигде не записанная практика: markdown-ссылок в `openspec/specs/` ноль). Провенанс: `review-code` C2 + `review-adversary` A4. ## Урожай (пред-существующие дефекты — владельцу задач, не в этот change) - **У-1 (кандидат `critical`, Confidence: medium).** Отмена/закрытие в окне после `add` сносит **пред-существующие** файлы пользователя под `paths.downloads`: гард «подтверждённое отсутствие» (`worker.go:495–509`) проверяет отсутствие торрента в qBittorrent, но не отсутствие данных на save_path; `qbt.Add` подхватывает лежащие там файлы, уборка бьёт `torrents/delete` с `deleteFiles=true`. Путь: пользователь снял раздачу, файлы оставив (это предписывает сам `download-tracking:169–174` как восстановление) → подал тот же торрент → отменил в окне листинг+add (сотни мс — секунды). Инвариант «источник неприкосновенен»: исключение (2) покрывает «собственный торрент», но не пред-существующие данные; UI при этом обещает «файлы и раздача останутся нетронутыми» (`download_main.html:100–111`). Оракул: `tmp/adv/adv_test.go`, `TestCancelInAddWindowDeletesPreExistingData` — прогнан, воспроизводит (каталог gitignored — при заведении задачи тест перенести в её материалы). Единственный непрогнанный шаг — семантика боевого qBittorrent `deleteFiles=true` над пред-существующими файлами: в бой ходить запрещено, отсюда medium. Провенанс: `review-adversary` A1, чекпоинт 2 change `cancel-dismiss-split-wording`. - **У-2 (major, Confidence: high).** `Undo`/`Delete` работают только с последним батчем (`review.go:557–572, 625–641`), каждый `Apply` создаёт новый (`review.go:332`): связка «закрыть `done` → relink» бросает ссылку батча 1 навсегда, а «полное удаление» после этого не освобождает место (инод жив за счёт брошенной ссылки). Оракул: `TestDismissRelinkStrandsLibraryLinks`, `TestStrandedLinkDefeatsDelete` — прогнаны, воспроизводят. Провенанс: `review-adversary` A2. - **У-3 (minor, наблюдаемость).** Лог-строки переходов `Cancel`/`Dismiss` не несут прежних `error_code`/`error_msg`, хотя `d` их держит в момент записи (`worker.go:985–991, 1016–1022`): «почему задача упала» после закрытия не восстановить одной строкой. Кандидат: `prev_error_code`/`prev_error_msg` в лог перехода. Оракул: чтение кода + контракт `logging.md:26–30`. Провенанс: `review-ops` O1. - **У-4 (minor).** «Per-download блокировка» из `CLAUDE.md` (инвариант «переходы состояний») и `worker.go:11` — фактически один глобальный `sync.Mutex` (`worker.go:239`), удерживаемый `Apply` на весь файловый ввод-вывод: долгая раскладка блокирует команды по **другим** загрузкам и поллинг. Минимум — поправить формулировку в двух местах; по существу — per-id блокировка либо вынос I/O из-под замка. Оракул: чтение кода (одно поле, не карта). Провенанс: `review-ops` O2. - **У-5 (minor, эффект — гипотеза при сегодняшнем профиле «единицы/день»).** Дефолтный список — `SCAN download` (негативный `state NOT IN` мимо `idx_download_state`, измерено `EXPLAIN QUERY PLAN` на копии схемы), а `cancelled` копятся без ретенции, и требование под ревью расширяет канал их производства. Частичный индекс или позитивный список состояний; ретеншен — задача в беклоге уже есть. Провенанс: `review-ops` O3. - **У-6 (minor).** Пред-существующее требование «Полное удаление загрузки пользователем» (`openspec/specs/state-reconciliation/spec.md:463–465`) несёт ту же формулировку о подтверждающем шаге с той же no-JS-дырой, что п. 7. Судьба зависит от развилки п. 7 — править согласованно. Провенанс: `review-specs` S4 (смягчающее наблюдение). Уже назначено урожаем ранее (не дублирую задачей): R10 из рубрики чекпоинта 1 — «допустимость команды переоценивается на момент исполнения, а не по снимку рендера» (`tasks.md`); туда же примыкает граница `specs` про `/review/{id}`, открытый для любого состояния. ## Границы покрытия **Запускалось:** профиль `standard`, режим «по графу», чекпоинт 2 (после apply). `review-gate` (кодовые шаги SKIP — кода в диффе нет; canon, gitleaks — OK), `review-specs`, `review-code`, `review-adversary`, `review-ops`. **Не запускалось:** `review-reimpl` — стадия 3, только в `deep`; `review-architecture` — стадия 4 (`wide`/`deep`); оба вне профиля `standard`. `review-architecture` отработал на чекпоинте 1 (профиль `design`) вместе с `review-specs` и `review-rubric`; его находки закрыты правками предложения (design.md, решения D1–D3, D6–D8) и на этом чекпоинте не перепроверялись. **Что запущенные проходы не могли проверить в принципе (из charter'ов):** - `specs`: поведение живых qBittorrent/Telegram — клавиатуры Telegram не покрыты тестами вовсе, сверка была чтением кода; плюс его заявленные границы: владение целевым путём после закрытия `done` (ушло в У-2), `/review/{id}` без гейта состояния (примыкает к R10), «активный список» как три разных множества (`store/list.go`, `store/download.go`). - `code`: только записанные конвенции; конвенции о форме OpenSpec-спек в `docs/conventions/` нет — норму задавали `openspec/config.yaml` и `CLAUDE.md`. - `adversary`: семантика **боевого** qBittorrent (`deleteFiles=true` над пред-существующими файлами) — в бой ходить запрещено; это единственный непрогнанный шаг У-1. Свойства без пути: снятие последней копии последовательностью команд не строится; секретов в `error_msg`/логах не найдено; негейченный маршрут `dismiss` в сегодняшнем периметре (`docs/security.md`) — операторское действие, не дефект (совпадает с «Типовым ложноположительным» про отсутствие авторизации — корректно не выведен находкой). - `ops`: `-race` на этом диффе не гонялся (кода нет — SKIP); гонки суждены рассуждением. Ретенция логов stdout нигде не описана — доступность «более ранней лог-строки» из У-3 неизвестна. - триаж (я): нового не ищу по определению — работаю с чужими выводами; пропуск любого прохода — мой пропуск тоже. Тесты `tmp/adv/` прогнаны и воспроизводят, но их фикстуры — фейковый qbt и локальная ФС: они доказывают поведение **нашего** кода, не связки с боем. **Осталось целиком на человеке** (из `docs/review.md`, два списка раздельно): *Не проверит ни один проход:* - история инцидентов на umbar и что уже ломалось в проде; - поведение SQLite под реальным объёмом и профилем нагрузки; - завязка внешних потребителей (Jellyfin, закладки, чужие ссылки) на текущее поведение; - качество распознавания (корпус решено не собирать, REJECTED 2026-08-06); - суждение «этой функциональности не должно существовать». *Перестали проверять сознательно:* - идиоматичность Go — с 2026-08-04, вместе с упразднением прохода `idiom` при переезде на плагин; различение «идиоматично против распространено» не спрашивает никто; пересмотр — задача `quality-review-agents`. **Каких документов/фактов не хватило (поразрядно, с причиной):** - `docs/conventions/` — конвенции о форме OpenSpec-спек нет: правило «ссылки бэктиком, не markdown» пришлось выводить grep'ом по фактике (`review-code`); - `docs/architecture.md` — ретенция логов не описана (только «stdout контейнера»): судьба «прежней диагностики в логе» из У-3 неопределима (`review-ops`); - `docs/review.md`, журнал дефектов — пуст (заведён 2026-07-23): оракулов «такое здесь уже воспроизводилось» не было ни для одной находки, все подтверждения добывались тестами и чтением кода (триаж); - `CLAUDE.md`, инварианты — присутствуют и использованы (ранжирование У-1 по «необратимо» и границе исключения (2)); «Типовые ложноположительные» и оба подраздела «Недоступно проверке» — присутствуют и использованы. Здесь пробелов нет.