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

186 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-документами.