Files
jellybit/openspec/changes/archive/2026-08-06-cancel-dismiss-split-wording/review/triage.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

34 KiB
Raw Blame History

Триаж чекпоинта 2 — change cancel-dismiss-split-wording

Дата: 2026-08-06. Профиль: standard, режим: по графу. База диффа: 01e64d60de7495900d9a4c8f5ce9bf811ee7b7e6.

Сводка

Гейт: зелёный. Кодовые шаги (build/vet/lint/gofmt/test/flaky/race/ diff-coverage/migrations/govulncheck) — SKIP с причиной «кода в диффе нет»; canonOK; gitleaksOK.

Проходы поимённо:

Проход Исход
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, D1D3, D6D8)
review-ops отработал, 3 находки + ответы на обязательные вопросы

Состав запущенного сверен с профилем standard — расхождений нет.

Счёт находок: на входе 13 (S1S4, C2, A1, A2, A3adv, A4, A5, O1O3; 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-wordingChange … is valid, exit 0 (прогнано мной). Выполнен, с оговоркой: нормативная фраза «SHALL называть путь закрытия» внутренне противоречит пустому коду Cancel — п. 4 ниже.
  • A2 — сценарий «Закрытие не-терминальной загрузки из веб-UI идёт отменой» (дельта, строки 112–119): исход cancelled, error_code назван (пустой). Оракул: тот же прогон + чтение сценария. Выполнен.
  • A3git 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:520556 (уборка по условию after.State != store.StateCatched, безразлична к команде), internal/httpapi/review.go:384391 (маршрут 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:244268 — штатный тест уборки ставит 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:495556 (гард «подтверждённое отсутствие» проверяет отсутствие торрента, не отсутствие данных)
  • 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 — новый батч), :557572 (Undo — только LatestBatchID), :625641 (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:985991, 10161022 — проверено чтением: Cancel логирует только from/to, Dismissfrom/to/code нового перехода; контракт поля codedocs/conventions/logging.md:2630.
  • 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:452461, internal/tgbot/render.go:4450, 281290 — проверено чтением: уведомление 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:9598 — проверено чтением, комментарий дословно: «без JS гейт — только раскрытие details и явный submit (диалога нет)»; internal/tgbot/render.go:266290 — «Закрыть» в общем ряду кнопок. 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:495509) проверяет отсутствие торрента в qBittorrent, но не отсутствие данных на save_path; qbt.Add подхватывает лежащие там файлы, уборка бьёт torrents/delete с deleteFiles=true. Путь: пользователь снял раздачу, файлы оставив (это предписывает сам download-tracking:169174 как восстановление) → подал тот же торрент → отменил в окне листинг+add (сотни мс — секунды). Инвариант «источник неприкосновенен»: исключение (2) покрывает «собственный торрент», но не пред-существующие данные; UI при этом обещает «файлы и раздача останутся нетронутыми» (download_main.html:100111). Оракул: 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:557572, 625641), каждый Apply создаёт новый (review.go:332): связка «закрыть done → relink» бросает ссылку батча 1 навсегда, а «полное удаление» после этого не освобождает место (инод жив за счёт брошенной ссылки). Оракул: TestDismissRelinkStrandsLibraryLinks, TestStrandedLinkDefeatsDelete — прогнаны, воспроизводят. Провенанс: review-adversary A2.
  • У-3 (minor, наблюдаемость). Лог-строки переходов Cancel/Dismiss не несут прежних error_code/error_msg, хотя d их держит в момент записи (worker.go:985991, 10161022): «почему задача упала» после закрытия не восстановить одной строкой. Кандидат: prev_error_code/prev_error_msg в лог перехода. Оракул: чтение кода + контракт logging.md:2630. Провенанс: 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:463465) несёт ту же формулировку о подтверждающем шаге с той же 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, решения D1D3, 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)); «Типовые ложноположительные» и оба подраздела «Недоступно проверке» — присутствуют и использованы. Здесь пробелов нет.