mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Волна 5 эпика #674, перенос ТЗ (класс C). Из 240 файлов `docs/specs/` в `legacy/specs/` уехали 219: на них не ссылается ни один живой файл (код, тесты, скрипты, workflow, документы вне архива и ревью). Остались 21 ТЗ — на которые ссылаются код, ADR, ISOMETRIC, SUN, RADAR, LIGHT (`docs/specs/067`), DECOR-EDITOR, support-relay, и те, на которые ссылаются они сами; README каталога объясняет, где искать остальное. Открытых issue с файлом ТЗ среди перенесённых нет. Относительные ссылки перенесённых файлов переписаны (`../X` → `../../docs/X`, соседние оставшиеся ТЗ → `../../docs/specs/…`) — все 26 резолвятся. Попутно: битая ссылка в `089-isometric-view-stage1.md:8` на удалённый `089-isometric-view.md` — теперь команда `git show` по истории. Строка в `legacy/README.md`. Issue: #682 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
366 lines
23 KiB
Markdown
366 lines
23 KiB
Markdown
# Issue #314 — атомарная запись v8 drafts и независимой геометрии
|
||
|
||
Статус документа: согласовано, реализация завершена и передана в code review.
|
||
Issue: [#314](https://github.com/Matysh/houseplan-card/issues/314)
|
||
|
||
Предшественник: [#282](https://github.com/Matysh/houseplan-card/issues/282)
|
||
|
||
Целевая ветка: `issue/314-v8-draft-write-regression`
|
||
|
||
## 1. Сценарий и пользовательский результат
|
||
|
||
Пользователь рисует комнату в редакторе Плана на конфигурации model v8. Каждый
|
||
завершённый отрезок сначала сохраняется как crash-safe `room_draft`, а после
|
||
замыкания превращается в комнату.
|
||
|
||
После исправления:
|
||
|
||
1. промежуточные сегменты сохраняются без сообщения об устаревшей карточке;
|
||
2. закрытая комната остаётся после получения серверной ревизии и полного reload;
|
||
3. не появляется ошибка `v8 draft wall segments require ids`;
|
||
4. отказ сервера немедленно возвращает план к последней принятой геометрии —
|
||
отвергнутая цепочка не может позже превратиться в отдельную перегородку;
|
||
5. уже существующие перегородки и черновики не удаляются автоматически.
|
||
|
||
Новых кнопок, настроек и режимов нет. Исправление возвращает обещанное текущее
|
||
поведение редактора.
|
||
|
||
## 2. Подтверждённые причины
|
||
|
||
### 2.1 Writer теряет обязательные ID
|
||
|
||
`commitWallSegmentModel()` выдаёт каждому новому draft-сегменту стабильный ID.
|
||
Затем `_writeConfig()` вызывает `_dropLegacySegments()`, где
|
||
`room_drafts[].segments[]` пересобираются как `{ cm }`. Обязательный для model v8
|
||
`id` исчезает непосредственно перед `houseplan/config/set`; backend отклоняет
|
||
payload с `v8 draft wall segments require ids`.
|
||
|
||
Тот же identity-дефект присутствует в `_undoPoint()`: уцелевшие сегменты
|
||
пересобираются из массива толщин и получают новые ID на следующем barrier.
|
||
|
||
### 2.2 Stale-client guard смешивает разные типы геометрии
|
||
|
||
`validate_wall_model_transition()` сравнивает общую legacy-проекцию, куда входят
|
||
rooms, compatibility walls, open spans, drafts, partitions и openings. Если
|
||
проекция изменилась, а contour `wall_segments` нет, актуальный v8-клиент
|
||
объявляется устаревшим.
|
||
|
||
Это верно только для геометрии, представленной contour-каталогом. Незамкнутый
|
||
draft, независимая partition и положение/тип opening имеют собственную identity
|
||
и законно меняются без изменения `wall_segments`.
|
||
|
||
### 2.3 Отклонённая оптимистичная геометрия остаётся локально
|
||
|
||
`_commitPhysicalGeometry()` хранит исходный `SpaceGeometryState` в
|
||
`_pendingPhysicalWrites`, но catch `wall_model_client_outdated` и общий catch
|
||
показывают только toast. Локальный `_serverCfg` остаётся впереди сервера.
|
||
|
||
Позднее серверная ревизия стирает временную комнату. До этого `_finishWallChain()`
|
||
может материализовать активный draft как `partitions`, поэтому последующее
|
||
действие способно закрепить мусорную стену.
|
||
|
||
## 3. Данные отчёта и граница восстановления
|
||
|
||
Экспорт владельца создан card/integration `1.68.0-beta.1`, `model_version: 8`.
|
||
В нём 13 комнат, 44 contour-сегмента, 24 partitions, один room draft и один open
|
||
span. Комнаты `bug2`, показанной на скриншоте, в принятой сервером конфигурации
|
||
нет. Инварианты находят один `hidden_obstacles / unusable_draft`.
|
||
|
||
Экспорт доказывает расхождение transient UI и server state, но не позволяет
|
||
надёжно определить, какие из 24 partitions намеренные. Поэтому #314 предотвращает
|
||
новый мусор и восстанавливает атомарность записи, но не удаляет уже сохранённые
|
||
объекты. Recovery существующих неоднозначных стен потребует отдельного действия
|
||
с preview и отдельного issue.
|
||
|
||
Сырые данные владельца нельзя добавлять в репозиторий. Для тестовой fixture
|
||
используется минимизированная синтетическая геометрия без названий и содержимого
|
||
реального плана.
|
||
|
||
## 4. Нормативный контракт ID
|
||
|
||
1. В model v8 каждый `room_drafts[].segments[]` на всех persisted/write
|
||
поверхностях имеет непустой уникальный `id` длиной не более 64 символов.
|
||
2. Sanitation перед записью не создаёт, не удаляет и не меняет identity-поля
|
||
валидного v8-кандидата. Identity создаёт единый wall-model barrier до writer.
|
||
3. При удалении повторной соседней точки удаляется относящийся к нулевому ребру
|
||
segment. Следующее ненулевое ребро сохраняет segment и ID того же исходного
|
||
индекса; ID нельзя сдвигать на соседнее ребро.
|
||
4. Undo последней точки удаляет только последний segment. Все оставшиеся
|
||
segments сохраняют ID и `cm` byte-for-byte.
|
||
5. Новый ID получает только новый segment. Повторное сохранение, sanitation,
|
||
Undo/Redo и reload не переименовывают уцелевшую identity.
|
||
6. Некорректный v8 ID не чинится молча writer-санитайзером. Кандидат должен
|
||
fail-closed пройти существующий model/schema barrier до отправки.
|
||
|
||
Persisted schema и `model_version` не меняются; миграции данных нет.
|
||
|
||
## 5. Нормативный stale-client contract
|
||
|
||
Backend разделяет две проекции.
|
||
|
||
### 5.1 Catalog-coupled contour projection
|
||
|
||
К ней относятся только legacy-поля, изменение которых требует согласованного
|
||
изменения contour `wall_segments`:
|
||
|
||
- room geometry (`id`, `poly`/legacy rect, `open_to`);
|
||
- compatibility `walls`;
|
||
- `open_spans`, пока они остаются compatibility-проекцией contour wall role.
|
||
|
||
Если stored и submitted model >= 8, эта проекция изменилась, а полный каталог
|
||
`wall_segments` byte-equivalent предыдущему, backend сохраняет текущий named
|
||
отказ `wall_model_client_outdated`.
|
||
|
||
### 5.2 Self-identifying independent projection
|
||
|
||
К ней относятся:
|
||
|
||
- `room_drafts` с собственными segment IDs;
|
||
- `partitions` с собственными IDs;
|
||
- `wall_columns` с собственными IDs;
|
||
- `openings` с обязательным v8 `host` (`wall` либо `partition`).
|
||
|
||
Изменение только этих объектов не требует изменения contour-каталога и не может
|
||
само по себе вызвать `wall_model_client_outdated`. Полная `CONFIG_SCHEMA` и
|
||
семантические валидаторы по-прежнему обязаны проверить IDs, host, положение,
|
||
пересечения и лимиты. Невалидный current-v8 payload получает существующий
|
||
schema/semantic error, а не принимается ради устранения ложного toast.
|
||
|
||
### 5.3 Настоящий старый writer
|
||
|
||
- `old_model >= 8`, `new_model < 8`: безопасный byte-equivalent legacy round-trip
|
||
по-прежнему гидратируется; изменение catalog-coupled contour geometry
|
||
отклоняется named error.
|
||
- Старый writer может менять self-identifying объект только если после
|
||
восстановления доступных прежних identity-полей кандидат проходит полную v8
|
||
schema. Например, новый draft segment без ID всё равно fail-closed.
|
||
- Current-v8 room/contour edit с неизменным каталогом по-прежнему отклоняется.
|
||
|
||
## 6. Атомарное восстановление после отказа записи
|
||
|
||
### 6.1 Что считается pending geometry transaction
|
||
|
||
Для каждого space `_pendingPhysicalWrites` хранит earliest `before` snapshot и
|
||
fingerprint последней локальной geometry. Несколько быстрых команд до ответа
|
||
сервера являются одной pending-транзакцией относительно последней принятой
|
||
сервером ревизии.
|
||
|
||
### 6.2 Успех
|
||
|
||
После успешного `config/set` очищается только pending entry, fingerprint которого
|
||
соответствует отправленному кандидату. Более новая локальная команда остаётся
|
||
pending и записывается следующим элементом сериализованной очереди.
|
||
|
||
### 6.3 Отказ
|
||
|
||
Если `config/set` отклонён до увеличения ревизии и для payload были pending
|
||
physical writes:
|
||
|
||
1. синхронно восстановить earliest `before` для всех затронутых space;
|
||
2. удалить их pending entries;
|
||
3. очистить активный geometry gesture и весь geometry command stack, чтобы Undo
|
||
или смена инструмента не могли воскресить отклонённый draft;
|
||
4. инвалидацировать geometry/model/render caches и запросить render;
|
||
5. сохранить исходный пользовательский toast (`wall_model_client_outdated`,
|
||
conflict либо `cfg_save_failed`);
|
||
6. для conflict дополнительно принять authoritative config существующим forced
|
||
reload; для остальных ошибок сделать best-effort forced reload после
|
||
синхронного rollback. Если reload недоступен, локальный earliest snapshot
|
||
остаётся безопасной последней известной базой.
|
||
|
||
Откатывается вся pending geometry после earliest snapshot, включая более новую
|
||
команду, созданную пока предыдущий request был in-flight: сервер не принял её
|
||
базу, продолжать поверх неё небезопасно.
|
||
|
||
Если rejected write не содержал pending physical geometry, #314 не меняет
|
||
существующую семантику других настроек/диалогов.
|
||
|
||
## 7. Скоуп
|
||
|
||
- сохранение v8 draft segment IDs в sanitation и Undo;
|
||
- корректная граница backend stale-client guard;
|
||
- атомарный rollback/reload rejected physical writes;
|
||
- тесты frontend, backend и browser/fake-WS;
|
||
- документация контракта и changelog RU+EN.
|
||
|
||
## 8. Не-скоуп
|
||
|
||
- автоматическое удаление существующих partitions/drafts;
|
||
- изменение UI, wall visuals, модели толщины либо editor tool flow;
|
||
- новый repair-диалог;
|
||
- model v9, integer lattice и ADR Stages 2–4;
|
||
- продолжение заблокированной #306;
|
||
- изменение server storage transaction или revision protocol.
|
||
|
||
## 9. Затронутые модули
|
||
|
||
- `src/houseplan-card.ts`: sanitation, Undo и rejected-write recovery;
|
||
- при необходимости новый малый pure helper в `src/` для тестируемой sanitation;
|
||
- `custom_components/houseplan/validation.py`: catalog-coupled projection;
|
||
- `test/**`: ID lineage и очередь/rollback;
|
||
- `tests_backend/test_wall_segment_model.py`: transition matrix;
|
||
- `demo/**`: реальный промежуточный `config/set` комнаты и отказ writer;
|
||
- `docs/CONFIG-COMPATIBILITY.md`, `docs/ARCHITECTURE.md`, `docs/TESTING.md` —
|
||
обновить ровно затронутый контракт;
|
||
- `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`.
|
||
|
||
Новых i18n-ключей нет: существующие toast сохраняются.
|
||
|
||
## 10. Критерии приёмки
|
||
|
||
### AC1. Draft IDs переживают writer sanitation
|
||
|
||
Model-v8 draft с минимум тремя segments после outbound sanitation сохраняет все
|
||
ID и соответствующие `cm`. Fixture с повторной соседней точкой удаляет только
|
||
нулевое ребро и не сдвигает ID следующих рёбер. Кандидат проходит backend v8
|
||
schema.
|
||
|
||
**Доказательство:** frontend unit + backend schema fixture.
|
||
|
||
### AC2. Undo сохраняет lineage
|
||
|
||
После Undo последней точки draft содержит первые `N-1` segments с теми же ID и
|
||
толщиной; Redo/новый клик создаёт identity только для нового ребра.
|
||
|
||
**Доказательство:** frontend unit либо DOM smoke с проверкой полного payload.
|
||
|
||
### AC3. Валидные independent writes не считаются stale
|
||
|
||
На stored model v8 при byte-equivalent `wall_segments` backend принимает отдельно:
|
||
|
||
1. append/update валидного draft segment с ID;
|
||
2. add/move/resize partition;
|
||
3. add/move/type change opening с согласованным v8 host;
|
||
4. add/move/resize column.
|
||
|
||
Каждый кандидат затем проходит `CONFIG_SCHEMA`.
|
||
|
||
**Доказательство:** parametrized backend tests.
|
||
|
||
### AC4. Настоящий stale contour writer остаётся закрыт
|
||
|
||
Current-v8 и downgraded writer, меняющие room/compatibility contour без
|
||
согласованного `wall_segments`, получают `wall_model_client_outdated`.
|
||
Невалидные draft IDs и opening hosts отклоняются schema.
|
||
|
||
**Доказательство:** backend negative tests, включая сохранение существующих
|
||
проверок #282.
|
||
|
||
### AC5. Комната сохраняется end-to-end
|
||
|
||
Browser smoke на model v8 рисует не менее трёх рёбер. После каждого завершённого
|
||
ребра fake WS пропускает полный payload через эквивалент backend-v8 validation;
|
||
после замыкания комната присутствует в принятой конфигурации и после simulated
|
||
reload. Не создаются лишние `partitions` и orphan `room_drafts`, нет обоих toast
|
||
из отчёта. Тест обязан падать на `dev` до исправления.
|
||
|
||
**Доказательство:** browser/fake-WS smoke.
|
||
|
||
### AC6. Rejected write не оставляет transient geometry
|
||
|
||
Fake WS отклоняет промежуточный draft write. До следующего пользовательского
|
||
действия локальный space возвращён к earliest snapshot, pending map и geometry
|
||
history очищены. Смена инструмента не создаёт partition; failed authoritative
|
||
reload не возвращает отвергнутый draft.
|
||
|
||
**Доказательство:** browser smoke + unit сериализованной очереди/rollback.
|
||
|
||
### AC7. Успешная очередь не теряет более новую команду
|
||
|
||
При F1 in-flight и новой локальной F2 успешный ответ F1 очищает только F1;
|
||
F2 остаётся pending и следующим write сохраняется. При отказе F1 обе команды
|
||
откатываются до общей earliest server-accepted базы.
|
||
|
||
**Доказательство:** deterministic writer-queue unit/smoke.
|
||
|
||
### AC8. Данные владельца не чистятся молча
|
||
|
||
Минимизированная синтетическая fixture с intentional partitions и unusable draft
|
||
после обычного чтения/записи не теряет существующие объекты автоматически. Новая
|
||
комната не создаёт дополнительных hidden obstacles.
|
||
|
||
**Доказательство:** model-invariant unit + review кода.
|
||
|
||
Исполняемое доказательство находится в `demo/smoke_v8_draft_write.mjs`: оно
|
||
загружает обезличенную `demo/fixtures/v8-draft-regression.mjs` с формой отчёта
|
||
13 rooms / 44 wall segments / 24 partitions / 1 unusable draft, закрывает новую
|
||
комнату реальным editor path и запускает CLI `model-invariants.mjs` на снимках
|
||
до и после. Единственная заранее известная находка обязана сохраниться без
|
||
новых violations.
|
||
|
||
### AC9. Совместимость, View и touch
|
||
|
||
Persisted schema остаётся v8; v7 safe round-trip и v8 read path не меняют
|
||
визуальный результат. View/kiosk/touch rendering не получает новых контролов,
|
||
listeners или ветвей поведения. Plan editor остаётся desktop-first.
|
||
|
||
**Доказательство:** backend compatibility tests + существующие smoke/golden;
|
||
review кода для отсутствия UI-дельты.
|
||
|
||
### AC10. Документация и локальный гейт
|
||
|
||
Обновлены RU+EN changelog, compatibility/architecture/testing contract. Проходят
|
||
`npm run typecheck`, релевантные unit/backend/smoke, `npm run build` и
|
||
`node scripts/check-docs.mjs --external`.
|
||
|
||
**Доказательство:** команды локального гейта и CI.
|
||
|
||
## 11. Производительность, accessibility, touch и security
|
||
|
||
- Sanitation и transition validation остаются O(total geometry), без нового
|
||
прохода render-time и без изменения budget.
|
||
- UI/фокус/keyboard/a11y не меняются.
|
||
- View и kiosk на touch не меняются; редактор desktop-first согласно
|
||
`docs/TOUCH-SUPPORT.md`.
|
||
- Новых внешних данных, HTML, URL и прав доступа нет. Backend продолжает
|
||
fail-closed валидировать полный payload.
|
||
|
||
## 12. План реализации
|
||
|
||
1. Сделать сохранение draft identity при sanitation тестируемым и исправить
|
||
`_undoPoint()` lineage.
|
||
2. Разделить backend catalog-coupled и self-identifying projections; добавить
|
||
transition matrix.
|
||
3. Централизовать recovery rejected physical write на earliest snapshots.
|
||
4. Добавить browser smoke успешного room flow и принудительного отказа.
|
||
5. Обновить нормативные документы и changelog.
|
||
|
||
## 13. Риски и меры
|
||
|
||
| Риск | Мера |
|
||
|---|---|
|
||
| Guard станет слишком мягким для старого клиента | catalog-coupled negative tests + полная v8 schema после transition check |
|
||
| ID сместится при удалении duplicate point | indexed fixture A,A,B,B,C с проверкой каждого surviving edge |
|
||
| Rollback сотрёт уже принятую команду | earliest snapshot создаётся только до pending batch; success очищает fingerprint-selectively |
|
||
| Более новая команда останется поверх rejected базы | rejection откатывает весь pending batch и чистит history/gesture |
|
||
| Recovery удалит пользовательскую partition | никакой automatic cleanup persisted objects; rollback только до captured pre-edit state |
|
||
| Forced reload недоступен | synchronous snapshot restore выполняется до best-effort reload |
|
||
|
||
## 14. Откат
|
||
|
||
Изменение не добавляет поля и не мигрирует storage. Frontend и backend должны
|
||
откатываться одним revert-коммитом: отдельно возвращать только guard или только
|
||
writer нельзя, иначе снова появится несовместимый контракт. Уже записанные v8
|
||
конфиги остаются читаемыми обеими версиями. Feature flag не нужен: это P1 fix
|
||
атомарности существующего writer.
|
||
|
||
## 15. Release-артефакты
|
||
|
||
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`: одна короткая пользовательская
|
||
запись со ссылкой на #314;
|
||
- обновления `CONFIG-COMPATIBILITY`, `ARCHITECTURE`, `TESTING` по фактическому
|
||
контракту;
|
||
- screenshots/golden не принимаются: визуал не меняется;
|
||
- performance capture и security artifact не требуются; достаточно обычных
|
||
статических/локальных гейтов и CI.
|
||
|
||
## 16. Принятые предположения для ревью
|
||
|
||
1. `open_spans` остаются catalog-coupled до отдельного изменения модели #306.
|
||
2. Opening-only изменение безопасно без изменения wall catalog только при
|
||
прохождении существующей проверки `host.id`, `host.t`, geometry и fit.
|
||
3. При отказе предпочтительнее потерять весь непринятый pending batch, чем
|
||
продолжить редактирование поверх базы, которой нет на сервере.
|
||
4. Existing ambiguous debris не является достаточным основанием для удаления;
|
||
prevention и recovery разделены намеренно.
|