CODE-REVIEW-316-r1 H1: the §3.3 degraded pool picked an angle-compatible wall at ANY distance, but the backend geometry-match invariant («wall opening geometry must match its host») requires the host to agree with the opening's own x/y — the migrated document was rejected by CONFIG_SCHEMA and the write wedged again on the schema layer. The pool is removed from both migrations (TS and the Python mirror): without an in-place eligible carrier the opening goes straight to the unhosted degraded state, exactly the alternative the spec's «assumed freely changeable» section reserved; the spec is revision 6. New tests replay the reviewer's reproduction on both sides, and the frontend test is proven able to fail by restoring the pool (executed red). CODE-REVIEW-316-r2 M2: the schema-level host check is shared with #132 partition openings, so its unhosted relaxation is now pinned by a regression test — a stale writer that keeps a partition-hosted opening but silently drops its host is still rejected by validate_partition_opening_hosts. Issue: #316 User-Visible: no
16 KiB
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-документами.