From dff476ff35fa9a06949f31bb5372786d13b4b287 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:35:05 +0000 Subject: [PATCH] docs: review document for #316 Issue: #316 User-Visible: no --- docs/reviews/CODE-REVIEW-316-r1.md | 348 +++++++++++++++++++++++++++++ 1 file changed, 348 insertions(+) create mode 100644 docs/reviews/CODE-REVIEW-316-r1.md diff --git a/docs/reviews/CODE-REVIEW-316-r1.md b/docs/reviews/CODE-REVIEW-316-r1.md new file mode 100644 index 00000000..c92cbba5 --- /dev/null +++ b/docs/reviews/CODE-REVIEW-316-r1.md @@ -0,0 +1,348 @@ +# CODE-REVIEW-316-r1 — миграция v9 сама разрешает конфликт «проём ↔ нулевая стена» + +- Issue: [#316](https://github.com/Matysh/houseplan-card/issues/316) +- Этап: `code` (PROCESS.md §2.7) +- Диапазон: `origin/dev...HEAD`, `origin/dev` = `f3fe8637` (docs: review document for #319), + `HEAD` = `ee672dcd` (ветка `issue/316-migration-auto-resolve`, ребейз на `dev` выполнен + автором до этого ревью — коммент issue от 2026-08-26T19:12:24Z, конфликт был только + в `CHANGELOG`) +- ТЗ: [`docs/specs/316-opening-host-auto-resolution.md`](../specs/316-opening-host-auto-resolution.md), + ревизия 4, ревью ТЗ зелёное — [`SPEC-REVIEW-316-r4.md`](SPEC-REVIEW-316-r4.md) +- Заход ревью: **r1**, блокирующих циклов код-ревью израсходовано **0/4** +- Вердикт: **красный** + +## Скоуп ревью + +Первый заход код-ревью (ревью ТЗ бюджет не тратит, §10.4/§227; первая попытка +запуска код-ревью не состоялась из-за конфликта ребейза с `dev` и цикл не +израсходовала — это не первый *заход* по номеру документа, но первый +фактический разбор кода). Полный разбор, дельты по §2.10 нет. + +Продуктовый код (класс A): `src/wall-segment-model.ts` (+111/−7), +`custom_components/houseplan/wall_segment_model.py` (Python-зеркало миграции, ++96/−9), `custom_components/houseplan/validation.py` (+4/−1, разрешение +`host: null` в `_config_wall_segment_invariants`). + +Гейты/инструменты (класс B): `test/wall-segment-model.test.mjs` (+97/−7, +6 новых тестов), `tests_backend/test_wall_segment_model.py` (+51/−10, 4 новых +теста), `demo/smoke_zero_wall_migration_unblocked.mjs` (новый), +`demo/golden/{harness,matrix}.mjs`, `demo/golden/baselines/baselines-index.json` +(новый эталон `span-over-door-migrated-dark`, matrixVersion 46→47), +`test/golden-matrix.test.mjs` (версия матрицы), `test/fixtures/316-span-over-door-migrated.json` +(новый), `scripts/config-field-registry.mjs` (запись `host=wall` переписана), +`scripts/mutation-gate.mjs` (2 новых мутанта). + +Документация (класс C): `docs/CONFIG-COMPATIBILITY.md` (раздел «Canonical +zero-thickness walls — model v9» переписан под §3.1–3.4), `docs/CHANGELOG.md` + +`docs/CHANGELOG.ru.md` (`User-Visible: yes`, оба в одном коммите). + +Сгенерированное (класс D): `dist/houseplan-card.js`, +`custom_components/houseplan/frontend/houseplan-card.js` (сверены байт-в-байт с +локальной пересборкой), `demo/golden/baselines/span-over-door-migrated-dark.png` +(коммит несёт `Release:`/`Baseline-Reviewed:` — прогон CI 33000824647). + +i18n не тронут (`src/i18n/**` вне диффа) — соответствует ТЗ §5.2 («без изменений»). + +Прочитано до вердикта: `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md`, тело issue +#316 и все 12 комментариев (аналитика, все 4 раунда ревью ТЗ, хендофф +реализации, отчёт о несостоявшемся первом запуске код-ревью из-за конфликта +ребейза, отчёт о ребейзе), ТЗ ревизии 4 целиком, `docs/CONFIG-COMPATIBILITY.md` +(разделы «Canonical zero-thickness walls» и «Independent-wall opening host»), +весь новый/изменённый продуктовый код в TS и Python, все новые тесты +(frontend + backend), новый смок, изменения `config-field-registry.mjs` и +`mutation-gate.mjs`. + +## Как проверялось + +| Гейт | Команда | Результат | +|---|---|---| +| Типы | `npx tsc --noEmit` | green | +| Unit (frontend) | `npm test` | **1351/1352** green, 1 skipped (не относится к диффу — существующий known-skip) | +| Сборка | `npm run build` | green | +| Синхронность бандла | `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | идентичны байт-в-байт | +| Документация (обязателен: diff трогает `src/**`) | `node scripts/check-docs.mjs` | green — «Documentation checks passed (7 files, 10 external links)» | +| Инварианты модели (диф трогает `host`/атомизацию) | `npm run invariants -- --config <фикстура AC1>` и `--config <мой репро §H1>` | оба «Инварианты выполнены» — эта проверка **не видит** находку H1 (см. ниже, она проверяет разрешимость ссылок и наличие записи толщины, не геометрическое согласие host↔x/y) | +| Backend pytest | `python -m pytest tests_backend -q` | **недоступно в этой среде** — `homeassistant` не установлен (нет `.venv-backend`, чистый контейнер ревью, не cloud-агент). Компенсировано прямым исполнением: модули `validation.py`/`wall_segment_model.py` загружены в изоляции от `custom_components/houseplan/__init__.py` (который требует HA) через `importlib` с застабленным пакетом — это и вскрыло находку H1 (ниже, точный скрипт воспроизведения приложен) | +| Смоки — по выборке `smoke-select.mjs` (прямое совпадение) | `node demo/smoke_edit_walk.mjs`, `smoke_glow.mjs`, `smoke_junction_holes.mjs` | все **OK** | +| Смоки — зарегистрированная связь | `smoke_real_plan_masonry.mjs`, `smoke_resize_pointer_real_plan.mjs`, `smoke_resize_wall_thickness.mjs` | все **OK** (связь подтверждена, регрессий нет) | +| Смок AC1 (репродукция #316) | `node demo/smoke_zero_wall_migration_unblocked.mjs` | **OK** — но см. H1: этот смок мокает `callWS` и никогда не пропускает мигрированный кандидат через реальную бэкенд-схему, поэтому находку не ловит | +| Golden | `npm run bundle:sync && npm run golden:verify` (полный матрикс, 130+ сцен, включая `span-over-door-migrated-dark` и `coincident-partition-virtual-dark`) | **все `passed`**, exit 0 | +| single-source-numbers | `node --test test/single-source-numbers.test.mjs` | green (3/3), не по существу диффа, но диф трогает видимую геометрию — прогнан для очистки | +| Дисциплина «тест умеет падать» | вручную применены оба мутанта `mutation-gate.mjs` (§3.1: `span-cut-erases-the-door-wall`, §3.4: `migration-throws-over-an-opening-again`) на `src/wall-segment-model.ts`, пересобран `test-build`, целевой тест перезапущен | **оба красные** — тесты AC2/AC1 реально способны упасть (полный вывод в разделе «Проверка AC») | + +**Одно число — один источник.** В диффе нет новой пользовательски видимой +величины (числа, подписи) — только геометрия (позиции атомов/host). Проверка +не применима буквально к «числу», но её геометрический аналог — согласие +`opening.host` с фактической позицией `opening.x/y` — и есть предмет находки +H1: после миграции у проёма фактически **два источника позиции**, которые +могут разойтись (сохранённые `x/y` и позиция, которую подразумевает +`host.id + host.t`), и этот разлад не смягчается рендером (он честно рисует по +`x/y`), а ломается ниже по цепочке — на записи. + +## Находки + +### H1 (High, в скоупе) — деградированный host (§3.3) может завести данные, которые бэкенд тут же отклоняет: миграция снова блокирует запись + +**Смысл.** `migrateRoomOpeningHost` (`src/wall-segment-model.ts:679-708`, зеркало +`_host_openings`/`degraded` в `custom_components/houseplan/wall_segment_model.py:580-612`) +для деградированного случая (§3.3) выбирает *любую* стену пространства, +подходящую по углу (`wallAngleMatches`, допуск 8°) и вместимости (`length`), +**без ограничения по расстоянию** — буквально по тексту ТЗ §3.3. Дальше host +материализуется как `{kind:'wall', id, t: clamp(projectT(centre, host.a, host.b))}` +— `t` берётся из ортогональной проекции центра проёма на прямую хоста, а не из +факта, что проём на ней лежит. + +Но `opening.x/y` при этом **не меняются** — миграция их не трогает нигде. + +Если после этого документ проходит через `CONFIG_SCHEMA` (а он проходит: +`config/set` и `plan/optimize` в `websocket_api.py` валидируют кандидат **после** +`commit_wall_segment_model`, строки 1653/1661-1662 и 1325 применительно к уже +мигрированному фронтендом кандидату), срабатывает существующая, не тронутая +этим диффом проверка `_config_wall_segment_invariants` +(`custom_components/houseplan/validation.py:1849-1853`): она пересчитывает +`x, y` из `host.a/b + t` и требует `abs(x - opening.x) ≤ 2e-8`. Для +деградированного host (по построению *не* близкого к проёму — вся суть §3.3 в +том, что рядом ничего нет) это расхождение равно перпендикулярному расстоянию +от проёма до выбранной стены — на практике многие сантиметры в нормализованных +координатах, на много порядков больше допуска. Проверка падает с +`wall opening geometry must match its host`, `CONFIG_SCHEMA` кидает +`vol.Invalid`, и `websocket_api.py` (оба пути, `config/set:1339-1341` и +`plan/optimize:1687-1688`) отвечает клиенту `invalid_format` — **запись +отклонена целиком**. + +Итог: для этого класса входных данных миграция не выполняет своё собственное +нормативное правило §3.4 («initial migration никогда не кидает opening-host») +по факту — она действительно не кидает `WallSegmentModelError('opening-host')` +из `hostRoomOpenings`, но провоцирует эквивалентный по последствиям отказ +чуть ниже по конвейеру, тем же классом ошибки («пространство не +преобразовано»), который и есть предмет #316. Часть, ради которой заведён +issue, — «рисование нигде не блокируется» (тело ТЗ, §1) — не выполняется для +этого случая. + +**Заодно замечание по замыслу.** Присвоенный деградированный `host` не даёт +никакой функциональной пользы: рендер контурных host'ов всегда идёт по +собственным `x/y` проёма (`space-render.ts:285`, +`plan-geometry-preflight.ts:224/276`, `houseplan-card.ts:9282`), а +физика/вырезы (`physical-geometry.ts`) режут тело по геометрическому +пересечению, не по `host`. Единственные потребители `host.id` для +`kind:'wall'` — это (а) сравнение линии владения при протяжке ID-наследования +(`openingHostCounts`, `wall-segment-model.ts:296-303`) и (б) блокировка +инструмента «Толщина» при занулении стены, которая якобы несёт проём +(`houseplan-card.ts:11753-11758`, не тронут этим диффом). Для (б) деградированный +host активно вреден: он запрещает занулить чужую, физически не связанную с +проёмом стену, и одновременно снимает позиционную защиту (`if (opening.host) return false;` +на той же строке 11756) с настоящей стены под проёмом, у которой `host` не установлен. +То есть даже если бы бэкенд-валидацию поправили, деградированный host остаётся +данными без пользы и с прямым вредом для UX инструмента «Толщина» — +это довод в пользу того, что §3.3 в этой части должен вести сразу к +непривязанному состоянию (без host), а не к «дальней» привязке; ТЗ прямо +допускает такую альтернативу («альтернатива «сразу непривязанный без поиска» +тоже согласуется с решением владельца», §6 ТЗ). + +**Почему это не поймали шлюзы.** Ни один прогнанный гейт не проверяет +согласие `host`↔`x/y` после миграции: +- `npm test` не вызывает `CONFIG_SCHEMA` вообще (чистый TS, backend не участвует); +- `tests_backend/test_wall_segment_model.py::test_unhosted_contour_opening_is_a_valid_degraded_v9_state` + — единственный тест, который пропускает мигрированный кандидат через + `CONFIG_SCHEMA` (строка добавлена этим диффом), но его фикстура (`angle: 90` + против единственной стены `angle: 0`) не даёт **ни одного** кандидата даже в + деградированном пуле — угол не совпадает вовсе, поэтому проём уходит в + истинно непривязанное состояние (`host` отсутствует) и находка не + воспроизводится; +- `demo/smoke_zero_wall_migration_unblocked.mjs` мокает `card.hass.callWS` + безусловным `{ ok: true }` — реальная бэкенд-схема не участвует; +- `npm run invariants` (`scripts/model-invariants.mjs`) проверяет + разрешимость ссылок и наличие записи толщины по грид-ключу (#254/#258/#259), + но не геометрическое согласие `host.t`↔`x/y` — проверено запуском на моём + воспроизведении (см. ниже), инварианты «выполнены», находку не видят; +- golden/визуальные гейты не видят разницы, т.к. рендер идёт по `x/y` + независимо от `host` — визуально деградированный и истинно непривязанный + случай неотличимы. + +**Воспроизведение (проверено исполнением, дважды — TS через реальный `dist`-путь +модуля и Python-зеркало через прямую загрузку модулей в обход `custom_components/houseplan/__init__.py`, +результат идентичен):** + +```js +// TS, тот же код, что использует фронтенд (test-build/wall-segment-model.js) +const config = { + spaces: [{ + id: 'floor', title: 'Floor', cell_cm: 5, view_box: [0,0,1,1], + rooms: [{ id: 'room', poly: [[0,0],[1,0],[1,1],[0,1]] }], + walls: [{ key: '', a: [0,0], b: [1,0], cm: 15 }], + openings: [{ id: 'orphan', type: 'door', x: 0.5, y: 0.5, angle: 0, length: 0.1 }], + }], + markers: [], settings: {}, +}; +commitWallSegmentModel(config).config.spaces[0].openings[0]; +// → { id:'orphan', type:'door', x:0.5, y:0.5, angle:0, length:0.1, +// host:{ kind:'wall', id:'wall-6jl52lapgi6hgvc2qe6r', t:0.5 } } +// opening.x/y (0.5, 0.5) остаются исходными; host ссылается на стену y=0 — +// перпендикулярное расстояние 0.5, т.е. половина плана. +``` + +```python +# Python-зеркало, идентичный вход +migrated, _ = commit_wall_segment_model(src) +# migrated["spaces"][0]["openings"][0]["host"] == +# {"kind": "wall", "id": "wall-6jl52lapgi6hgvc2qe6r", "t": 0.5} +CONFIG_SCHEMA(copy.deepcopy(migrated)) +# → voluptuous.error.MultipleInvalid: wall opening geometry must match its host +``` + +Команды и полный стек трассировки воспроизведены локально в рамках этого +ревью (см. «Как проверялось» — раздел backend pytest недоступен, но модули +загружены напрямую тем же интерпретатором и той же версией `voluptuous`, что +использует `tests_backend`). + +**Требуется:** до возврата на правку — не мой мандат чинить, но по существу: +деградированный кандидат в §3.3 обязан либо (а) материализовывать `host` только +когда реконструированные `x/y` совпадают с сохранёнными в пределах допуска, +который реально примет `_config_wall_segment_invariants` (т.е. фактически +эквивалентно текущему `eligible()`, а не отдельному безлимитному пулу), либо +(б) уходить сразу в истинно непривязанное состояние без `host`, как это уже +происходит, когда угол не совпадает вовсе. Второй вариант дешевле и совпадает +с уже одобренной в ТЗ §6 альтернативой. + +### M1 (Medium, в скоупе) — CHANGELOG заявляет то, чего код не гарантирует + +`docs/CHANGELOG.md`/`.ru.md`: «Drawing works again everywhere: converting a +plan to the new wall model no longer stops on a legacy border/opening +conflict» / «Рисование снова работает везде… больше не останавливается». При +находке H1 это утверждение ложно для деградированного случая — превращение +всё ещё останавливается, просто с другим кодом ошибки на другом уровне. Формулировка +чинится в этом же коммите вместе с H1 (её объём и формулировка зависят от +выбранного решения H1), отдельного issue не заводится (Medium в скоупе, #202). + +## Проверка AC + +- **AC1 (репродукция #316).** Доказательство — смок + `demo/smoke_zero_wall_migration_unblocked.mjs`, воспроизводящий ровно + сценарий issue (span+door в одном пространстве, пустое — в другом). Прогнан: + **OK**. Мутант `migration-throws-over-an-opening-again` делает его красным + при откате исправления — проверено вручную (раздел «Как проверялось»). + AC доказан для **этого конкретного** сценария (§3.1, не §3.3) — см. H1 про + общий контракт «никогда не блокирует». +- **AC2 (§3.1).** Доказательство — юнит-тест + `an opening keeps its wall through a legacy span cut`. Прогнан: **OK**; + мутант `span-cut-erases-the-door-wall` делает его красным при откате — + проверено вручную, тест реально падает (`AssertionError: the atom under the + door keeps its real thickness`). AC доказан. +- **AC3 (§3.2).** Доказательство — юнит-тест + `an ambiguous carrier is resolved deterministically at a thickness boundary`. + Фикстура — стык двух контурных атомов одной стены на границе смены толщины, + проём центрирован ровно на стыке (перпендикулярное расстояние 0 к обоим + кандидатам) — реально достижима внутри `resolveRoomOpeningHost`, тай-брейк + «больший cm» проверяется явно. AC доказан. Тай-брейк «текущий host» и + «меньший id» кодом реализован (`pick()`, TS и Python идентичны построчно), + но отдельными тестами не покрыт — не блокирует (не входит в AC3 буквально), + отмечаю как Low-полноту тестового покрытия, не требую правки. +- **AC4 (§3.3).** Доказательство — юнит-тест фронта + (`an opening with no usable carrier migrates unhosted...`) + бэкенд-тест + схемы (`test_unhosted_contour_opening_is_a_valid_degraded_v9_state`). Оба + зелёные, но оба тестируют только **истинно пустой** пул кандидатов (угол + вообще не совпадает ни с одной стеной) — не тестируют деградированный пул с + найденным, но геометрически несвязанным кандидатом, который и оказался + сломан (H1). Формально буква AC4 («проём вдали от всех стен мигрирует в + состояние «без host»») для протестированного случая выполняется; для + непротестированного соседнего случая, нормативно предписанного тем же §3.3, + — нет. **AC не доказан полностью.** +- **AC5 (граница §2).** Доказательство — юнит-тест + `a post-v9 write that LOST its carrier keeps the fail-closed refusal` + (frontend) + `test_post_v9_write_that_lost_its_carrier_keeps_the_refusal` + (backend). Оба зелёные, оба явно проверяют `WallSegmentModelError`/ + `WallSegmentMigrationError('opening-host')` при `initialMigration=false`. + AC доказан. +- **AC6 (идемпотентность).** Доказательство — `commit(commit(x)) === commit(x)` + байтово, проверено в каждом новом тесте (§3.1/§3.2/§3.3/фикстура) и отдельно + тестом на зафиксированной фикстуре + `316-span-over-door-migrated.json`. Проверено запуском (`node --test`), + AC доказан для протестированных путей. Не проверяет: идемпотентность + сохраняется и для деградированного host из H1 (сам факт того, что + `commit(x)` неконсистентен с `CONFIG_SCHEMA`, делает вопрос об идемпотентности + вторичным — до записи дело не доходит). + +## Что проверено и корректно + +- §3.1 (легаси-кат щадит атом с проёмом) реализован идентично в TS и Python: + один и тот же критерий (`GRID_STEP_N*0.02` по расстоянию, тот же угловой + критерий), фильтр «проём реально стоит на легаси-кате» + (`GRID_STEP_N*0.04`) не даёт постороннему проёму перекраивать каталог — + проверено чтением и тестом на фикстуре с «дальним» проёмом (из хендоффа + автора о фиксе после ребейза). +- §3.2 тай-брейк (текущий host → расстояние → cm → id) реализован одинаково в + `pick()` TS и Python, порядок сравнений совпадает построчно. +- §3.4 (initial migration никогда не throw из `hostRoomOpenings`) — верно + локально для этой функции; связка с H1 показывает, что гарантия не + переживает следующий слой (`CONFIG_SCHEMA`), но именно эта функция сама по + себе корректна. +- `scripts/config-field-registry.mjs`, `docs/CONFIG-COMPATIBILITY.md` + (раздел «Canonical zero-thickness walls — model v9») правлены в том же + коммите, что и код (правило 11), формулировки соответствуют §3.1–3.4 ТЗ. + Раздел «Independent-wall opening host (#132)» не тронут — верно, партиционные + проёмы вне скоупа (`hostRoomOpenings` явно пропускает `kind==='partition'`, + не изменено этим диффом). +- Golden: новая сцена `span-over-door-migrated-dark` добавлена с корректным + `Release:`/`Baseline-Reviewed:` в отдельном D-коммите; полный прогон + `golden:verify` (130+ сцен, включая эту и `coincident-partition-virtual-dark`) + — все `passed`, подтверждает заявление ТЗ §5.3 «без изменений» для + партиционной сцены. +- Трейлеры коммитов корректны: `Issue: #316` на обоих (реализация и + D-коммит), `User-Visible: yes` на реализации с правками в обоих CHANGELOG в + том же коммите, `User-Visible: no` + `Release:`/`Baseline-Reviewed:` на + golden-коммите. `pre-push`/`process-gate` не могли это поймать сами — + подтверждено чтением коммитов, не только заявлением автора. +- i18n действительно не тронут (`git diff --stat` по `src/i18n/**` пуст), + соответствует ТЗ §5.2. +- Ребейз на `dev` (после несостоявшегося первого запуска ревью) не принёс + нового кода — единственный конфликт был в `CHANGELOG.md`/`.ru.md` с уже + слитым #319; проверено чтением коммита `19:12:24Z` и тем, что + `wall-segment-model.ts`/`.py` не менялись между `df893c1c` (отвергнутый + первый HEAD) и `ee672dcd` за вычетом уточнения edge-breaks, которое автор сам + описал и которое покрыто существующими тестами на «посторонний проём не + перекраивает каталог». + +## Чего не проверял + +- **`python -m pytest tests_backend -q` целиком** — недоступен в этой среде + (нет `homeassistant`, нет `.venv-backend`). Компенсация: прямой запуск + `validation.py`/`wall_segment_model.py` в обход пакета — тем же способом + подтверждены оба заявленных автором числа тестов существованием функций и + найдена H1; полный набор (18/19 по заявлению автора) не прогнан этим + ревью буквально, доверия к зелёному CI (`Baseline-Reviewed` ссылка на прогон + 33000824647/33001442307) достаточно для остальных backend-тестов, кроме + специфического сценария H1, которого в наборе нет. +- **Полный набор `demo/smoke_*.mjs` (192 файла)** — не прогнан целиком, только + выборка `smoke-select.mjs` (прямое совпадение + зарегистрированная связь) и + AC1-смок. Диф не задевает подсистемы, для которых нужен полный прогон + (партиции, изометрия, устройства); полный прогон — обязанность предрелизного + гейта (PROCESS.md §8). +- **`performance_smoke`** — не запускал; ни AC, ни диф не называют влияние на + перф (ТЗ §5.4 явно: миграция — единственный проход по проёмам, вне горячего + пути рендера), и диф не трогает рендер-цикл. +- **`npm run invariants` на реальном экспорте с продакшен-плана** — прогнан + только на синтетических фикстурах (AC1 golden-фикстура и моё воспроизведение + H1); ни то ни другое не эквивалент «реального большого плана», но + `smoke_real_plan_masonry.mjs` (косвенно, через связку с `atomicPolyForRoom`) + прогнан и зелёный. +- **Тай-брейк «текущий host» и «меньший id» в §3.2** — реализация проверена + чтением (идентична TS/Python), отдельным юнит-тестом не покрыта; не поднимаю + до Medium, т.к. это не AC3 буквально и код прямолинеен (простая сортировка). + +## Резюме + +High-находка H1 показывает, что нормативное правило §3.4 («initial migration +никогда не блокирует запись из-за проёма») не выполняется целиком: для +деградированного случая §3.3 миграция создаёт геометрически несогласованный +`host`, который существующая (не тронутая этим диффом) бэкенд-валидация +`_config_wall_segment_invariants` отклоняет — воспроизведено исполнением и в +TS, и в Python. Ни один из прогнанных гейтов (unit, смоки, golden, +model-invariants) этот путь не покрывает; единственный тест, который **мог** +бы его поймать (`test_unhosted_contour_opening_is_a_valid_degraded_v9_state`), +случайно обходит деградированный пул стороной из-за несовпадения угла в своей +фикстуре. Возврат автору для исправления материализации host в §3.3 (или +замены на прямой переход к непривязанному состоянию) и правки формулировки +CHANGELOG (M1) следом. + +Вердикт: **красный** · заход r1 · блокирующих циклов код-ревью 1/4 · High: 1 · +Medium: 1 → в задаче