Files
houseplan-card/docs/specs/316-opening-host-auto-resolution.md
T
Codex 0f36ef55d5 fix: an opening without an in-place carrier migrates unhosted (#316, review r1 H1 + r2 M2)
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
2026-08-26 23:41:35 +03:00

16 KiB
Raw Blame History

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-документами.