mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
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
186 lines
16 KiB
Markdown
186 lines
16 KiB
Markdown
# 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-документами.
|