# Issue #316 — миграция v9 сама разрешает конфликт «проём ↔ нулевая стена» Статус: ревизия 6 (spec-ревью r1–r4; код-ревью r1: H1 — §3.3 без дальнего поиска; r2: M2 — защита #132 закреплена тестом). Родитель: #306 (стены нулевой толщины), контекст: #319 (бэкенд-гард — отдельная задача). Решение владельца (чат, 2026-08-26): конфликт проёма при миграции разрешается автоматически, рисование нигде не блокируется; выпуск — v1.68.0-beta.4 вместе с #319. ## 1. Сценарий и пользовательский результат На v1.68.0-beta.3 два клика инструментом «Стены» на пустом плане заканчиваются тостом «Пространство не преобразовано: проём нельзя однозначно привязать к стене. Данные не изменены». Причина воспроизведена исполнением: структурный писатель проходит атомарный барьер `commitWallSegmentModel` на весь конфиг; `hostRoomOpenings` требует для каждого контурного проёма ровно одного носителя с `cm > 0`; проём, который легаси-`open_span` перевёл бы на нулевую стену, кидает `WallSegmentModelError('opening-host')` — и конфликт в любом одном пространстве блокирует структурные записи во всех, включая пустые. После фикса: первая структурная запись (и «Оптимизировать планы») мигрирует документ всегда; конфликтные проёмы разрешаются детерминированно по правилам §3; ни одно пространство не блокирует другое. Пользовательские данные проёмов не удаляются. ## 2. Границы - Правила §3 действуют **только при initial migration** (`model_version < 9` на входе в `commitWallSegmentModel`). Пост-v9 структурные записи сохраняют прежний fail-closed контракт: невозможность разместить проём — ошибка данных, запись отклоняется целиком (гарантии #278/#282 не ослабляются). - §8.2 ТЗ #306 в части «Legacy-конфликт open_span/open_to с проёмом блокирует миграцию всего пространства» заменяется настоящим документом. Остальное («новый проём на нулевой стене запрещён», «перевод участка с проёмом в 0 отклоняется») действует без изменений — это пост-миграционные операции. - Бэкенд-гард stale-client не входит в скоуп (#319). ## 3. Нормативные правила авто-разрешения (в порядке применения) ### 3.1 Проём удерживает свою стену Атом, несущий существующий проём (интервал проёма `[t·L − length/2, t·L + length/2]` пересекает интервал атома на общем носителе, с допуском `GRID_STEP_N * 0.02` по расстоянию и тем же угловым критерием, что в `resolveRoomOpeningHost`), **не переводится в `cm: 0`** легаси-катом (`open_spans`/`open_to`): он сохраняет толщину, которую имел бы без ката. Пункт 8 контракта #306 сужается: «open_spans переводят покрытые атомы в cm:0, кроме атомов, несущих проём». Физический смысл: если внутри «границы» стоит дверь — в этом месте стена была и остаётся; разрыв (нулевые атомы) продолжается по обе стороны от проёма. ### 3.2 Неоднозначный носитель Если после 3.1 у проёма больше одного подходящего кандидата (`eligible` из `resolveRoomOpeningHost`), выбирается детерминированно: текущий `host`, если он среди кандидатов; иначе ближайший по расстоянию от центра проёма до оси; при равенстве — больший `cm`; при равенстве — лексикографически меньший `id`. ### 3.3 Проём без носителя Если подходящих на месте кандидатов (`eligible`) нет — проём сразу сохраняется в данных **без `host`** и становится непривязанным (unhosted). Дальний поиск по углу/вместимости без ограничения расстояния (вариант ревизий 1–4) отклонён код-ревью r1 (H1): host вдали от собственных `x/y` проёма нарушает бэкенд- инвариант «wall opening geometry must match its host» и клинит запись на слое схемы — ровно тот класс отказа, который #316 устраняет. Непривязанный проём: не создаёт тоннель/вырез/тело, не участвует в физике, рендерится по своим `x/y` как прежде. `hostRoomOpenings` при initial migration пропускает такой проём вместо исключения; пост-v9 запись, не меняющая этот проём, обязана его сохранять, а picker/placement позволяет переставить его на стену штатно. ### 3.4 Никаких исключений из миграции по проёмам При initial migration `hostRoomOpenings` не кидает `opening-host` ни при каких данных. Прочие причины (`invalid-room`, `duplicate-id`, `zero-length`) остаются блокирующими — это настоящие поломки данных, а не легаси-конфликт. ## 4. AC - **AC1 (репродукция #316).** Конфиг: пустое пространство A + пространство B с комнатой, `open_span` вдоль стены и дверью на той же стене. Два клика инструментом «Стены» в A создают черновик; тоста «Пространство не преобразовано» нет; запись уходит. Доказательство: смок, повторяющий выполненную репродукцию (до фикса красный). - **AC2 (3.1).** После миграции конфига B дверь остаётся на атоме с исходной толщиной; атомы span'а по обе стороны проёма — `cm: 0`. Доказательство: юнит-тест на `commitWallSegmentModel`. - **AC3 (3.2).** Достижимая внутри `resolveRoomOpeningHost` неоднозначность: центр проёма стоит на стыке двух контурных атомов одной стены на границе смены толщины (оба атома проходят `eligible` — проём короткий и помещается в каждом). Host выбирается по правилу 3.2 и стабилен при повторном прогоне (идемпотентность: второй прогон байт-идентичен). Доказательство: юнит-тест. - **AC4 (3.3).** Проём вдали от всех стен мигрирует в состояние «без host», конфиг проходит `CONFIG_SCHEMA` бэкенда, повторная запись его сохраняет. Доказательство: юнит-тест фронта + бэкенд-тест схемы. - **AC5 (граница §2).** Пост-v9 запись с проёмом, потерявшим носителя, по-прежнему отклоняется `opening-host` (fail-closed не ослаблен). Доказательство: существующий/новый юнит-тест. - **AC6.** Прогон миграции идемпотентен: `commit(commit(x)) == commit(x)` байтово на фикстурах AC1–AC4. Доказательство: юнит-тест. ## 5. Затронутые поверхности и артефакты ### 5.1 Файлы - `src/wall-segment-model.ts` — `buildAtoms` (правило 3.1: легаси-кат не зануляет атом, несущий проём), `hostRoomOpenings`/`resolveRoomOpeningHost` (3.2/3.3 при `initialMigration`), пропуск непривязанных проёмов. - `src/houseplan-card.ts` — без новых веток: barrier остаётся; проверить, что непривязанный проём не ломает существующие писатели. - `src/space-render.ts` — подтверждено ревью r1: рендер по `x/y` уже работает для проёмов без host; правок не ожидается (если потребуются — в скоупе). - `scripts/config-field-registry.mjs`, запись `spaces[].openings[].host=wall`: `default` и `migration` больше не могут звучать как «required for contour openings» / «materialize only when exactly one carrier is proven» — формулировки обновляются на «materialized by migration §3 of #316; a contour opening may persist unhosted after degraded migration», в том же коммите, что и код (правило 11). - `docs/CONFIG-COMPATIBILITY.md` — новый подраздел о непривязанном контурном проёме как валидном v9-состоянии после деградированной миграции (§3.3): не участвует в телах/вырезах, рендерится по `x/y`, переставляется picker'ом. Устаревает абзац раздела «Canonical zero-thickness walls — model v9 (#306)» («…verifies that no opening would acquire a zero host… A conflict rejects the complete candidate») — именно его fail-closed описание заменяют правила §3.1–3.4; он переписывается в том же коммите. Раздел «Independent-wall opening host (#132)» (партиционные проёмы) не затрагивается. - `tests_backend` — кейс схемы: v9-документ с контурным проёмом без host принимается на чтение и запись (AC4). Разделяемая схема-проверка host при этом смягчается для обоих видов проёмов; защита #132 от молчаливого сброса ПАРТИЦИОННОГО host остаётся на семантическом слое (`validate_partition_opening_hosts` → `PartitionOpeningHostError`) и закрепляется регрессионным тестом (код-ревью r2, M2). - `demo/smoke_*` — новый смок репродукции AC1. ### 5.2 i18n Без изменений: ключи `toast.zero_wall_migration_blocked` и `wall_model.reason.opening-host` остаются для пост-v9 fail-closed пути (§2); новых строк не появляется. ### 5.3 Release-артефакты - CHANGELOG RU+EN — в реализационном коммите (`User-Visible: yes`). - Golden: правило 3.1 меняет видимое зонирование `cm:0` (атом под проёмом остаётся телесным) — добавляется сцена «span поверх стены с дверью» в golden-матрицу. Комбинация span+проём в существующих сценах ЕСТЬ: `coincident-partition-virtual-dark` строит `open_spans` поверх позиции проёма с удалённым host. Ожидание «без изменений» держится не на природе кладки (после очистки host проём обрабатывался бы миграцией как контурный — тем же путём, что правило 3.1), а на границе §2: сцена рендерится в `mode: 'view'` без структурной записи, `commitWallSegmentModel` из чистого рендера не вызывается — правила §3 в ней не выполняются вовсе. Арбитр — `golden:verify`: неизменность подтверждается прогоном, а любое расхождение проходит канонический путь приёмки с визуальным ревью, не молчаливую переснятие. - USER-GUIDE — без правок: пользовательского UI-контракта не меняется, поведение «рисование не блокируется» — восстановление обещанного. ### 5.4 Производительность и touch Perf: правило 3.1 добавляет к атомизации один проход по проёмам пространства (O(atoms × openings) на миграцию, единожды на документ) — вне горячего пути рендера; performance-smoke не затрагивается. Touch-контракта правки не касаются (миграция — не жест). ## 6. Принято предположительно (поменять свободно) - Порядок tie-break в 3.2: текущий host → расстояние → больший `cm` → меньший `id`. - Семантика деградации 3.3: принята альтернатива «сразу непривязанный без поиска» (код-ревью r1, H1 — дальний host противоречит инварианту схемы host↔x/y). - Имя состояния 3.3 в документации: «непривязанный (unhosted)». ## 7. Риски - Изменение зонирования cm:0 (3.1) сдвигает границы атомов относительно beta.3 — на документах, уже мигрировавших на beta.3, правило 3.1 не сработает (span'ы уже удалены, атомы уже cm:0): такие проёмы попадут в 3.2/3.3. Принято: beta.3 прожила меньше суток, владелец предупреждён. - Непривязанный проём (3.3) — новое состояние данных; риск ограничен рендером по x/y и пропуском в физике; picker уже умеет переставлять проёмы. ## 8. Откат Реверт ветки; миграция снова кидает `opening-host` (поведение beta.3). Данные, уже мигрировавшие с авто-разрешением, остаются валидными v9-документами.