# Issue #252 — понятная и безопасная очистка забытых позиций в Optimize - Дата: 2026-08-23 - Тип: bug + polish · приоритет P2 - Оценка: пользовательская ценность 8/10 · ценность для разработки 7/10 · сложность 5/10 · риск 6/10 - Issue: [#252](https://github.com/Matysh/houseplan-card/issues/252) - Связанные задачи: [#244](https://github.com/Matysh/houseplan-card/issues/244), [#248](https://github.com/Matysh/houseplan-card/issues/248) - Ветка: `issue/252-optimize-orphan-layout-report` - Статус ТЗ: на ревью Канонические документы: `docs/SCOPE.md`, `docs/CANVAS.md`, `docs/ARCHITECTURE.md`, `docs/CONFIG-COMPATIBILITY.md`, `docs/TOUCH-SUPPORT.md`, `docs/USER-GUIDE.md`, `docs/USER-GUIDE.ru.md`, `docs/TESTING.md`. ## 1. Сценарий и персона Администратор дома несколько раз импортировал или удалял этажи, комнаты и устройства. В layout остались координаты объектов, которых уже нет. Он открывает «Общие настройки → Оптимизировать планы», чтобы обслужить план, а получает список внутренних id и не понимает, исправлена ли проблема и что делать дальше. Сценарий относится к J6 из `docs/SCOPE.md`: старый план должен оставаться правдивым и обслуживаемым. Optimize — desktop-first административная поверхность; View и kiosk только читают уже сохранённый результат. ## 2. Что человек увидит до и после **До:** основной отчёт сообщает о «неразрешённых позициях», «вложенных сопоставлениях» и перечисляет технические id удалённых пространств. Из отчёта неясно, безопасно ли нажимать Apply и останутся ли эти записи после него. **После:** доказанно забытые позиции удаляются в candidate и описываются человеческими категориями, например: «Убрано забытых записей: 37 — подписи комнат: 32, устройства: 3, групповые метки: 2». Если объект существует, но его прежнее пространство удалено, позиция сохраняется и Optimize предлагает отдельно убрать её. Внутренние id доступны только в свёрнутых «Подробностях». После Apply, reload и повторного Optimize уже удалённые записи не возвращаются. ## 3. Подтверждённый диагноз `repairSpaceReferences()` уже исправляет однозначные import-signature ссылки, отвязывает активный marker от отсутствующего пространства и удаляет его непереносимую позицию. Но layout-запись без активного config-marker намеренно сохраняется как opaque user data. Затем она учитывается в `positionsUnresolved`, а `deadSpaceIds` собирает внутренние id пространств. `_renderAlignDialog()` складывает `positionsUnresolved` и `nestedRefsUnresolved` и выводит `gs.optimize_reference_warning`, включая до десяти `deadSpaceIds`. Поэтому наблюдаемый отчёт соответствует коду и является багом продукта, а не только неудачной конфигурацией пользователя. Простое правило «ключ отсутствует в `config.markers` — удалить» некорректно: автоматические HA-устройства и `lg_` могут иметь layout, не имея config-marker. `_devices` тоже не является доказательством отсутствия: фильтры, отсутствующая HA Area и ограниченный registry могут временно убрать живой объект из render snapshot. Удаление допустимо только после классификации владельца по config и полному авторитетному HA registry/state snapshot. Это осознанно **заменяет**, а не дополняет, прежний абсолютный инвариант из `docs/CANVAS.md:494-497`, по которому Optimize вообще не удаляет unattached layout entries, поскольку устройство может быть временно недоступно. То же консервативное обещание было опубликовано в английском changelog для v1.59.0-rc.1. Новое правило не считает временную недоступность удалением: неавторитетный или неполный registry по-прежнему сохраняет запись, а очистка разрешена лишь при доказанном отсутствии владельца. При реализации старое предложение в `CANVAS.md` должно быть переписано новым доказательным контрактом, а не оставлено рядом с ним. ## 4. Цели 1. Автоматически удалять позицию, чей владелец доказанно больше не существует. 2. Не удалять молча позицию живого либо непроверенного объекта. 3. Описывать результат терминами интерфейса, а не storage-модели. 4. Оставлять технические id только для раскрываемой диагностики. 5. Сохранить pure preview, Apply, one-deep Undo и идемпотентность #248. ## 5. Scope ### Входит - классификация orphan layout owners для подписей комнат, устройств и групповых меток; - безопасное автоматическое удаление доказанно отсутствующих владельцев; - отдельное явное действие для позиции живого объекта в удалённом пространстве; - fail-closed поведение при неполном/неавторитетном registry; - структурированный report по пользовательским категориям и диагностике; - понятные RU/EN строки, доступные keyboard/screen reader; - сохранение nested vacuum mappings без автоматического удаления и их человекопонятное описание; - unit, production-bundle smoke, semantic/golden review, документация и оба changelog. ### Не входит - догадки о новом пространстве или автоматический перенос живого объекта; - удаление самого HA-устройства, entity, config-marker, комнаты или пространства; - автоматическая очистка calibration/segment map пылесоса; - очистка неизвестных future layout namespaces; - изменение правил импорта, удаления пространства, device discovery или `known_devices`; - schema/store/model-version migration и backend protocol change; - новый экран диагностики либо переработка всего диалога Optimize; - публикация maintenance-действия для read-only пользователя. ## 6. Термины и классификация владельца ### 6.1 Категории отчёта | Внутренний owner layout | Пользовательская категория | |---|---| | `rl_` | подпись комнаты / room label | | config-marker или HA device id | устройство / device | | `lg_` | групповая метка / group marker | | неизвестный namespace | непроверенная позиция / unverified position | В основном отчёте не используются слова `layout`, `owner`, `nested mapping`, `space id`, `marker id` и сами идентификаторы. Они допустимы в «Подробностях». ### 6.2 Три результата классификации Для каждой позиции с отсутствующим `position.s` результат ровно один: 1. **absent** — владелец доказанно отсутствует; позиция входит в автоматическое удаление candidate; 2. **live-in-missing-space** — владелец существует, но сохранённая позиция относится к удалённому пространству; позиция сохраняется по умолчанию и получает user-facing имя; 3. **unverified** — полноты данных недостаточно или namespace неизвестен; позиция сохраняется, разрушительное действие не предлагается. ### 6.3 Доказательство существования и отсутствия - `rl_`: комната жива, если `roomId` есть в текущих spaces. Комната отсутствует, если полного прохода текущей config достаточно доказать, что её нет. Удалённая room-label tombstone автоматически очищается. - Явный config-marker: активный marker жив; `removed:true` является доказательством завершённого удаления владельца для его старой позиции. - Автоматическое HA-устройство: живо, если id есть в полном device registry; отсутствует только если авторитетный snapshot успешно загружен и id нет ни в device registry, ни среди допустимых entity-backed владельцев. - `lg_`: живо, если entity есть в полном entity registry или актуальном state snapshot; отсутствует только при авторитетном registry и отсутствии в обоих источниках. - неизвестный ключ: всегда `unverified`; его нельзя автоматически удалить лишь потому, что текущая версия не умеет его отрисовать. `HaRegistrySnapshot.authoritative === false`, ошибка доступа или snapshot до первой успешной загрузки никогда не служат доказательством отсутствия. Индексы config/rooms/devices/entities строятся один раз до прохода layout. ## 7. Контракт поведения ### 7.1 Preview без записи Открытие Optimize остаётся pure preview и не пишет config/layout. Сначала сохраняются действующие безопасные remap/detach правила #244, затем классифицируются оставшиеся позиции в удалённых пространствах. Все `absent` позиции удаляются из candidate сразу и попадают в структурированные счётчики: - `orphanRoomLabelsRemoved`; - `orphanDevicePositionsRemoved`; - `orphanGroupPositionsRemoved`. Их сумма входит в `changed`, maintenance count и Apply candidate. Удаляется только соответствующая layout entry; config/HA registry не меняются. Clean config/layout остаются deep-equal и дают нулевые счётчики. ### 7.2 Живой объект в удалённом пространстве Для `live-in-missing-space` основной отчёт показывает количество и до трёх user-facing имён: «Позиции 3 устройств остались от удалённого пространства: Стиральная машина, Датчик движения, …». Имя берётся из config/HA registry/state по действующим fallback-правилам; внутренний id не является отображаемым именем. Рядом находится вторичное действие «Убрать старые позиции». Оно: - не является выбранным по умолчанию; - не пишет storage само по себе; - пересобирает preview с явным opt-in и добавляет только перечисленные позиции live owners в candidate удаления; - меняется на понятное состояние «Старые позиции будут убраны» и допускает отмену opt-in до Apply; - оставляет владельцев живыми и не скрывает их из inbox/discovery: после Apply они просто считаются неразмещёнными. Закрытие/Cancel ничего не меняют. Единственной подтверждающей записью остаётся основная кнопка Apply. Apply и последующий Undo действуют на всю показанную пару, включая opt-in cleanup, атомарно по существующему контракту. ### 7.3 Непроверенные позиции и пылесос `unverified` позиции сохраняются. Основной отчёт говорит: «Не удалось безопасно проверить позиций: N — они оставлены без изменений». При ограниченном доступе к registry дополнительно объясняется, что для безопасной очистки нужен полный доступ администратора. Кнопки удаления для этого блока нет. Неисправленные vacuum segment mappings сохраняются и описываются как «Сопоставления комнат пылесоса требуют проверки: N». Они не смешиваются со счётчиком позиций и не делают вид, что были исправлены. ### 7.4 Основной отчёт и подробности Если удалено хотя бы одно доказанное orphan, показывается одна сводка: > Убрано забытых записей: 37 — подписи комнат: 32, устройства: 3, групповые > метки: 2. Все они принадлежали пространствам, удалённым ранее. Нулевые категории не обязаны перечисляться. Остальные существующие блоки Optimize сохраняются. Report не утверждает «убрано», если preview не содержит удаления. Под предупреждениями доступен нативный раскрываемый блок «Подробности» / “Details”. Он по умолчанию закрыт, управляется с клавиатуры и содержит: - тип причины и bounded list до 10 внутренних owner/space id; - число остальных записей (`и ещё N`); - явную пометку, какие записи будут удалены, сохранены либо не проверены. Технические id не попадают в главный текст, accessible name основной кнопки, toast или changelog. Они не отправляются наружу и берутся только из локального candidate report. ### 7.5 Apply, reload, Undo и идемпотентность Существующие schema validation, geometry preflight, CAS, admin permission, durable `optimize_pending`, one-deep backup и Undo не меняются. Preview exact: backend получает именно показанные config/layout. После успешного Apply и server-event reload повторный Optimize: - не находит удалённые `absent` entries; - не повторяет их счётчики; - при отсутствии других изменений показывает `gs.align_none`; - не пишет новый backup и не меняет revisions. Undo восстанавливает удалённые layout entries вместе с прежней парой. Следующий Optimize после Undo снова честно предлагает ту же очистку. ## 8. Данные, compatibility и миграция Persisted schema, Store version и `PLAN_MODEL_VERSION` не меняются: задача использует существующее право Optimize удалить ключ layout. Новых persisted полей нет. Старые карточки продолжают читать результат как обычный layout. Миграции при загрузке и lazy write нет. Очистка происходит только после явного Apply администратора. Unknown/future keys сохраняются. Существующие exact import-signature remap, marker detach, coordinate canonicalization и #248 storage/reload boundary выполняются в прежнем порядке и остаются идемпотентны. Контекст классификации — runtime-only read model: полнота registry, device/entity roster и user-facing names. Pure optimizer не читает глобальное состояние и не делает сетевые запросы. ## 9. UX, accessibility и touch - RU и EN имеют отдельные строки для removed/live/unverified/vacuum/details и явного действия; pluralisation следует существующему i18n contract. - Сводка и предупреждения доступны screen reader в логичном DOM-порядке; динамическая смена opt-in объявляется через существующий dialog update без захвата фокуса. - «Убрать старые позиции» — настоящий `