# ТЗ #403 — Переезд area не теряет ручную расстановку и не гасит чужую историю - Issue: https://github.com/Matysh/houseplan-card/issues/403 - Приоритет: P1, bug; полный трек — класс A, две находки на одной поверхности (свежий код #126) - Ревизия: 3 (2026-09-01; техническое уточнение AC7 при реализации) ## Сценарий Хозяин расставил маркеры по плану руками — это единственные данные, которые он вводил сам. В Home Assistant он переименовывает area у лампы. Карточка честно переносит маркер в новую комнату, но запись конфига в этот момент не проходит (конфликт из второй вкладки, отказ бэкенда, обрыв связи). Позиция, которую он ставил, исчезает — насовсем и молча. Ни маркера «посмотри сюда», ни сообщения: лампа просто оказывается в центре новой комнаты, будто её никогда не двигали. Второе, мельче: любой такой переезд — даже успешный и даже одного устройства — гасит кнопки Undo/Redo для **всех** маркеров. ## Что человек увидит до и после **До**: (1) при неудачной записи конфига ручная позиция переехавшего маркера теряется без следа и без метки внимания; (2) переезд любого устройства очищает весь стек Undo позиций, молча. **После**: позиция либо сохраняется, либо устройство помечено как требующее внимания; история остаётся у тех маркеров, которых переезд не касался. ## Проблема и контракты по пунктам ### (1) C2 — отказ записи конфига теряет позицию Порядок в `src/houseplan-card.ts:5160-5250`: сначала для каждого переезжающего устройства выполняется `_persistDevicePlacement(id, null)` (удаление сохранённой точки), затем одной записью сохраняется новый снапшот и список внимания. Ветка отказа есть у **удаления** (`:5183`): `applyDevicePlacement(before)` возвращает позицию локально. У отказа **записи конфига** (`:5223-5248`) её нет: восстанавливаются `marker_area_snapshot` и `new_device_ids`, а layout остаётся удалённым. **Воспроизведено исполнением** (браузер, реальная карточка; устройство `device:d_light1` с ручной позицией, снапшот с прежней area, первый `config/set` отказывает): ``` houseplan/layout/delete → успех houseplan/config/set → отказ layoutHasDevice: false ← позиция удалена и не восстановлена snapshotAreaNow: kitchen ← снапшот откачен attention: false ← метки внимания нет ``` Дальше это самовоспроизводится: снапшот откачен на прежнюю area, поэтому следующий authoritative-проход снова решает «relocate», снова удаляет (удалять нечего) и снова пишет конфиг. Позиция не возвращается ни на одном витке. Отдельная ветка того же корня — `conflict` (`:5245`): она перечитывает конфиг с сервера, но про потерянный layout тоже ничего не знает. **Контракт**: удаление сохранённой точки и продвижение провенанса — одна операция с точки зрения пользователя, и её незавершённость не имеет права стоить ему данных. Допустимы два исхода, выбрать при реализации: 1. **Запись первой**: конфиг сохраняется до удаления layout; точка удаляется только после подтверждённой записи. Тогда отказ вообще ничего не меняет. 2. **Восстановление при отказе**: если запись не прошла, позиция восстанавливается и локально, и на сервере (`_persistDevicePlacement(id, before)`), а не только в памяти. Если после всех попыток позиция всё же утрачена (например, восстановление тоже отказало), устройство обязано попасть в `new_device_ids` — обещание §3.4/AC9 ТЗ #126. Молчаливая потеря запрещена в любом исходе. Порядок «delete-first» был выбран в #126 сознательно (`:5142-5146`: «Layout deletion is deliberately completed before provenance advances»), чтобы устаревшая точка не выиграла между отрисовкой реестра и подтверждением сервера. Реализация обязана сохранить это свойство: исход (1) допустим только если гонка «старая точка побеждает» остаётся невозможной, иначе берётся (2). ### (2) M1 — переезд одного устройства гасит историю всех `src/houseplan-card.ts:5062-5065`: ```ts this._areaRelocationIds = new Set(areaRelocations.relocateIds); if (this._areaRelocationIds.size) { this._cancelDeviceDrag(); this._devicePositionHistory.clear(); ``` Стек очищается целиком при непустом наборе переезжающих — независимо от того, чьи записи в нём лежат, и молча, тогда как у соседнего класса событий уведомление есть (`history.device_stale`). Повторяется на каждом authoritative-rebuild, пока набор непуст: при read-only клиенте или после неудачного удаления — на каждом тике. **Воспроизведено** тем же прогоном: переезжало одно устройство, `canUndo` после него `false`. **Контракт**: из истории удаляются записи переехавших устройств; записи остальных остаются. Если по какой-то причине приходится очищать больше, пользователь получает то же уведомление, что и в остальных случаях устаревания истории. ## Скоуп / не-скоуп **В скоупе**: `_syncAreaRelocations` и обработка отказов в `src/houseplan-card.ts`, сужение очистки истории позиций, смоки и мутанты. **Не в скоупе**: сам резолвер `src/device-area-relocation.ts` (его решения верны — проверено прогоном), критерии переезда и формат снапшота (#126), механика истории Undo (#74/#397), гигиена снапшотов исчезнувших устройств (#406 «г»). ## UX Видимого оформления не меняем. Меняется поведение при отказе: маркер остаётся там, где его поставил хозяин, либо получает уже существующую метку внимания. ## Модель данных и миграция Формат `marker_area_snapshot`, `new_device_ids` и layout не меняется. Миграции нет: правится только порядок операций и обработка отказа. ## i18n Новых строк нет, если выбран путь с существующей меткой внимания. Если решение потребует отдельного уведомления о потере позиции — ключ добавляется во все четыре словаря. ## Touch, View и kiosk Touch-контракт не меняется. Правка не затрагивает drag/tap-жесты, pointer- обработчики, DOM или рендер; история редактора устройств сохраняет действующий best-effort контракт на touch из `docs/TOUCH-SUPPORT.md` и раздела 6 `docs/USER-GUIDE.ru.md`. В View и kiosk результат AC1/AC2 наблюдаем — маркер сохраняет корректную позицию либо получает существующую отметку внимания, — но новых действий или жестов не появляется. Гарантированный touch-first контракт View/kiosk не ослабляется: на сенсорном клиенте итоговое положение и отметка должны быть теми же, что на desktop. ## Производительность Влияния на производительность и действующие бюджеты нет. Исправление не добавляет циклов, подписок, постоянных вычислений или сетевых вызовов сверх уже выполняемых `_syncAreaRelocations` / `_writeConfig`; выборочная очистка работает по существующему стеку не более чем из 50 команд. Нового performance-профиля не требуется. ## Критерии приёмки - **AC1**. Отказ `config/set` во время переезда не оставляет устройство без сохранённой позиции: либо позиция на месте (локально и на сервере), либо устройство попало в `new_device_ids`. Доказательство: браузерный смок с одним отказом `config/set`, проверяющий оба состояния явно. - **AC2**. Успешный переезд по-прежнему работает: точка удалена, снапшот продвинут, устройство помечено как требующее внимания. Доказательство: тот же смок, ветка без отказа. - **AC3**. Свойство delete-first сохранено: устаревшая сохранённая точка не может выиграть между отрисовкой реестра и подтверждением сервера. Доказательство: смок с задержанным ответом сервера — маркер не возвращается в прежнюю комнату. - **AC4**. Ветка `conflict` (правка из второй вкладки) подчиняется AC1. Доказательство: смок с ошибкой `code: 'conflict'`. - **AC5**. Переезд одного устройства не гасит историю другого: после перетаскивания маркера A и переезда маркера B кнопка Undo для A активна. Доказательство: смок. - **AC6**. Записи переехавшего устройства из истории удалены — Undo не вернёт его в комнату, которой у него больше нет. Доказательство: тот же смок. - **AC7**. Существующее успешное поведение #126 не сломано: ручная перестановка после переезда переживает три rebuild-тика, delete-first echo не воскрешает точку. Доказательство: `demo/smoke_area_relocation.mjs` остаётся зелёным; единственное прежнее утверждение `failedConfigRetryable`, которое прямо требовало состояние исходного бага (`layout` уже удалён после отказа `config/set`), заменяется witness нового AC1 — позиция восстановлена, а повтор остаётся возможен. Остальные утверждения смока не меняются. ## План автотестов **Browser smoke** (новый файл — существующие смоки #126 держат свои фикстуры): 1. Переезд с одним отказом `config/set` → позиция на месте ИЛИ устройство в `new_device_ids`; молчаливая потеря краснеет (AC1). 2. Переезд без отказа → точка удалена, снапшот продвинут, метка внимания поставлена (AC2). 3. Задержанный ответ сервера → старая точка не выигрывает (AC3). 4. Отказ с `code: 'conflict'` → поведение AC1 (AC4). 5. Драг маркера A, переезд маркера B → `canUndo` для A остаётся `true` (AC5); записи B из истории удалены (AC6). **Мутанты** (`scripts/mutation-gate.mjs`): - `area-relocation-loses-position-on-refusal`: убрать восстановление/пометку → смок AC1 красный. - `area-relocation-clears-whole-history`: вернуть `clear()` на весь стек → смок AC5 красный. ## Риски - **Смена порядка операций ломает delete-first.** Исход (1) переставляет запись перед удалением, а именно этого #126 избегал. Смягчение: AC3 проверяет исходное свойство напрямую; если оно не удерживается — берётся исход (2), где порядок не меняется вовсе. - **Восстановление на сервере тоже может отказать.** Тогда потеря неизбежна. Смягчение: этот случай обязан оставлять метку внимания — то есть AC1 формулируется как «позиция ИЛИ метка», а не «позиция всегда». - **Сужение очистки истории оставит записи, ссылающиеся на исчезнувшую комнату.** Смягчение: AC6 требует удалять записи именно переехавших, а не просто «очищать меньше». ## Откат Обе правки локальны: обработка отказа в одном методе и фильтр в одной строке очистки истории. Формат данных не затрагивается. ## Release-артефакты - `docs/CHANGELOG.md` / `docs/CHANGELOG.ru.md`: пункт о том, что расстановка маркеров переживает неудачную запись при смене area (User-Visible: yes). - Скриншоты не меняются.