docs: spec #316 — migration auto-resolves opening-host conflicts

Issue: #316
User-Visible: no
This commit is contained in:
Codex
2026-08-26 21:54:19 +03:00
parent f3fe86371c
commit 5b7d37e911
@@ -0,0 +1,109 @@
# Issue #316 — миграция v9 сама разрешает конфликт «проём ↔ нулевая стена»
Статус: ревизия 1. Родитель: #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 Проём без носителя
Если кандидатов с `cm > 0` нет вовсе: берётся ближайший по расстоянию сегмент
пространства, удовлетворяющий угловому критерию и вместимости, без ограничения
допуска расстояния. Если и таких нет (в пространстве нет ни одного пригодного
сегмента) — проём сохраняется в данных **без `host`** и становится инертным:
не создаёт тоннель/вырез/тело, не участвует в физике, рендерится по своим
`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).** Два коллинеарных совпадающих кандидата (контурный +
независимый, кейс #313) — 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. Риски
- Изменение зонирования cm:0 (3.1) сдвигает границы атомов относительно
beta.3 — на документах, уже мигрировавших на beta.3, правило 3.1 не
сработает (span'ы уже удалены, атомы уже cm:0): такие проёмы попадут в 3.2/3.3.
Принято: beta.3 прожила меньше суток, владелец предупреждён.
- «Инертный» проём (3.3) — новое состояние данных; риск ограничен рендером по
x/y и пропуском в физике; picker уже умеет переставлять проёмы.
## 6. Откат
Реверт ветки; миграция снова кидает `opening-host` (поведение beta.3). Данные,
уже мигрировавшие с авто-разрешением, остаются валидными v9-документами.