# Триаж чекпоинта 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)); «Типовые ложноположительные» и оба
  подраздела «Недоступно проверке» — присутствуют и использованы. Здесь
  пробелов нет.
