Волна 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
25 KiB
ТЗ #419 — Безопасная уборка Area-снапшота при пустом или усечённом HA-реестре
- Issue: https://github.com/Matysh/houseplan-card/issues/419
- Приоритет: P2, bug
- Маршрут: standard; persistent lifecycle metadata и destructive cleanup
- Связанные контракты: #126 (переезд HA Area), #403 (отказ записи), #406 (уборка осиротевших записей)
- Решения владельца: defaults Q1–Q3 приняты 2026-09-02
Сценарий
Хозяин дома уже расставил устройства на плане. Во время запуска, перезапуска или обновления Home Assistant карточка получает успешный, но переходный пустой либо усечённый ответ Device/Entity Registry. Позже устройство снова появляется и меняет HA Area. House Plan должен помнить последнюю принятую Area и корректно отличить переезд от первого обнаружения устройства.
Поверхности: полная карточка и houseplan-space-card; ошибка возникает в общем
registry/runtime-контуре до выбора текущего пространства и не зависит от View,
редактора, desktop или touch.
Что человек увидит до и после
До: после временно неполного HA-реестра будущий переезд устройства между Area может потерять автоматический перенос и повести себя как первичное размещение.
После: временно пустой или единичный усечённый ответ ничего не забывает; House Plan переносит устройство после настоящей смены Area так же, как до сбоя.
Проблема
resolveDeviceAreaRelocations() получает devices из buildDevices() и
считает этот массив доказательством жизни всех записей
settings.marker_area_snapshot. Но это presentation-проекция, а не реестр:
- auto-device без Area, без сопоставленной комнаты либо скрытый legacy-фильтром
законно отсутствует в
devices, оставаясь в HA; - точная entity может жить как runtime state без строки Entity Registry;
- успешные registry WebSocket-вызовы гарантируют распознаваемые массивы, но не защищают от переходного пустого или усечённого кадра;
- при
devices: [],authoritative: trueтекущий sweep ставитremoveSnapshot: trueвсем прежним записям и следующая запись конфига стирает provenance целиком.
Тем самым реализация пункта (г) #406 нарушает исходный контракт #126: отсутствие в active/display roster не равно доказанному удалению binding.
Скоуп
В скоупе:
- отделить registry-liveness от отфильтрованной
DevItem[]-проекции; - учитывать соответствующий полный registry namespace, точный живой HA state и сохранённые живые markers как положительные доказательства существования;
- не выполнять orphan cleanup по полностью пустому namespace;
- подтверждать отсутствие binding двумя различными успешными непустыми authoritative registry revisions;
- после первого отсутствия один раз запросить контрольное перечитывание реестров без цикла повторных запросов;
- хранить кандидатов первого отсутствия только в runtime карточки;
- сохранить действующие правила переезда, backfill, rebind, explicit placement, tombstone/delete и отказов записи из #126/#403/#406;
- unit, browser smoke и mutation-защита нового lifecycle-контракта;
- синхронизировать техническую документацию и release notes.
Не-скоуп
- изменение UI, сообщений, настроек, Area mapping или алгоритма выбора комнаты;
- изменение формата
marker_area_snapshot, лимита 20 000 или backend-схемы; - восстановление provenance, уже стёртого выпущенной версией;
- общий аудит полноты HA Registry API и эвристика процентного размера roster;
- уборка
known_devices,new_device_ids, layout либо marker tombstones; - изменение limited/unverified-поведения HA bindings;
- изменение самого контракта автоматического переезда #126.
Контракт поведения
1. Положительное доказательство жизни
Orphan-sweep для entry { id, binding, area } не рассматривает binding как
исчезнувший, если выполняется хотя бы одно условие:
device:<id>присутствует в текущем полном Device Registry;entity:<entity_id>присутствует в текущем полном Entity Registry;- для exact
entity:<entity_id>существуетhass.states[entity_id], включаяunavailable/unknown: это доказательство существования, а не активности; - в сохранённом plan roster есть живой, не
removedmarker с тем же binding либо с тем же snapshot id.
Это множество не зависит от Area mapping, текущего пространства, пользовательской
фильтрации, showAll, визуального hidden-state или возможности отрисовать
DevItem.
Положительное доказательство в любой последующей revision немедленно снимает runtime-кандидат отсутствия. Оно не превращает HA-disabled binding в active и не меняет его presentation status.
2. Пустой registry namespace
Для device:* оценивается только Device Registry, для entity:* — Entity
Registry плюс exact live state.
Если соответствующий namespace пуст, а в marker_area_snapshot есть entries
этого вида, кадр считается непригодным для destructive cleanup:
- entries сохраняются независимо от runtime-кандидатов;
- пустой кадр не является первым или вторым подтверждением отсутствия;
- он не запускает бесконечные registry reloads;
- следующий непустой authoritative snapshot продолжает обычную проверку.
Легальный случай «пользователь действительно удалил вообще все устройства» оставляет bounded служебные entries. Это принятая fail-safe цена: потеря provenance опаснее фонового мусора под существующим лимитом.
3. Два подтверждения отсутствия
Если namespace непуст, но binding не имеет ни одного положительного доказательства жизни:
- первое отсутствие в authoritative revision сохраняет entry и записывает
runtime-кандидат
{ binding, firstRevision }; - карточка инициирует не более одного контрольного refresh на fingerprint набора кандидатов/registry revision;
- повторная обработка той же revision не считается вторым подтверждением;
- отсутствие в следующей отличающейся успешной непустой authoritative
revision разрешает
removeSnapshot: true; - появление binding между проходами снимает кандидат без записи конфига;
- limited/error frame не подтверждает и не удаляет данные.
Кандидат относится к exact binding. Rebind того же marker id не наследует подтверждение старого binding.
4. Runtime lifecycle
Кандидаты отсутствия не сохраняются в config, Local Storage или backend и не переносятся между reload/remount. После новой карточки первое отсутствие снова только ставит runtime-кандидат. Все экземпляры карточки используют общую authoritative registry revision, но каждый держит собственную fail-safe координацию записи; существующая revision/config-защита остаётся источником истины при конкурентных клиентах.
5. Действующие явные cleanup-пути
Двойное подтверждение применяется только к автоматическому orphan-sweep по отсутствию в HA roster. Оно не задерживает:
- явное удаление marker и
removeMarkerAreaSnapshots(); - rebind marker, при котором old binding заменяется новым baseline;
- переход marker в explicit/ineligible placement, где старая registry-following provenance больше не применима;
- нормальное продвижение Area provenance после успешного layout delete.
UX
Новых элементов, уведомлений и настроек нет. Исправление намеренно тихое: переходный registry frame не должен требовать действий от пользователя. При реальном удалении устройства возможна задержка служебной уборки до контрольного registry refresh; на видимый план это не влияет.
Desktop, touch, View, kiosk, редакторы и static card получают одинаковый итог: это общий data lifecycle до input/render-веток.
Модель данных и миграция
Persisted-модель не меняется:
settings.marker_area_snapshot?: Record<string, {
binding: `device:${string}` | `entity:${string}`;
area: string;
}>;
Новых compatibility-полей и backend-миграции нет. Runtime-кандидаты имеют ограничение существующим числом entries снапшота и исчезают при remount. Старый frontend продолжает читать тот же config; новый frontend безопаснее решает, когда удалить entry. Full import/export и space-only import сохраняют контракты #126 без изменений.
i18n
Новых строк и изменений словарей нет: UI не меняется.
Затрагиваемые файлы и модули
Ожидаемый минимальный набор:
src/device-area-relocation.ts— чистое решение liveness/cleanup и его результаты;src/houseplan-card.ts— authoritative registry evidence, runtime-кандидаты, bounded confirmation refresh и повторная резолюция перед записью;- при необходимости
src/ha-binding-status.ts— только read-only проекция полного registry roster/revision, без смены binding status; test/device-area-relocation.test.mjs— матрица resolver/coordinator;demo/smoke_area_relocation.mjs— wiring через production bundle;scripts/mutation-gate.mjs— отрицательные доказательства;docs/CONFIG-COMPATIBILITY.md, при необходимостиdocs/FILTERING.mdиdocs/TESTING.md— нормативный lifecycle и тестовая матрица;docs/CHANGELOG.md,docs/CHANGELOG.ru.md— пользовательский bug fix;docs/images/screenshots.json— только канонический source fingerprint, если обязательный docs run подтвердит отсутствие пиксельных изменений.
Ревью может сузить техническую раскладку без изменения контракта.
Производительность
На обычных registry rebuild без пропавших snapshot bindings дополнительных сетевых вызовов нет. Проверка остаётся линейной по bounded snapshot и текущим registry/marker sets.
Первое квалифицированное отсутствие допускает одну дополнительную пару registry WebSocket-запросов. Дедупликация по revision и fingerprint кандидатов обязана исключить запрос на state tick, повторный render, повторную обработку той же revision и бесконечный цикл при пустом namespace. Нового performance-профиля не требуется; smoke доказывает число вызовов.
Touch, accessibility и security
Touch/pointer/keyboard/focus/ARIA не меняются. Новых входных данных, HTML, URL, команд, прав или раскрытия HA metadata нет. Security-review сверх обычного code review не требуется.
Критерии приёмки
- AC1. Пустой derived
devicesне удаляет entry, если exact binding есть в соответствующем полном HA Registry, даже когда устройство отфильтровано или его Area не сопоставлена комнате. Доказательство: unit + browser smoke. - AC2. Полностью пустой Device Registry не удаляет ни одну
device:*entry, а полностью пустой Entity Registry не удаляетentity:*entries; повторный пустой snapshot тоже не становится подтверждением. Доказательство: unit + browser smoke. - AC3. Exact live
hass.states[entity_id]сохраняетentity:*entry без registry row;unavailableиunknownтоже сохраняют её. Доказательство: parameterized unit. - AC4. Живой сохранённый marker сохраняет matching binding/id независимо от
presentation filtering;
removed: trueне считается живым. Доказательство: unit. - AC5. Первое отсутствие binding в непустой authoritative revision не меняет persisted snapshot и создаёт runtime-кандидат; повтор той же revision не удаляет entry. Доказательство: unit + browser smoke.
- AC6. Второе отсутствие того же exact binding в отличающейся успешной непустой authoritative revision разрешает удалить только matching entries; живые соседние entries остаются. Доказательство: unit + browser smoke.
- AC7. Возврат binding между двумя проходами снимает кандидат и не вызывает config write; последующее отсутствие снова считается первым. Доказательство: unit + browser smoke.
- AC8. Limited/error revision ничего не подтверждает и не удаляет; reload/remount не наследует первый кандидат. Доказательство: unit.
- AC9. После первого квалифицированного отсутствия выполняется ровно один confirmation refresh; state ticks, повтор той же revision, пустой namespace и неизменный кандидат не создают reload-loop. Доказательство: browser smoke с точным счётчиком registry WS-вызовов.
- AC10. Явное удаление/rebind/explicit-ineligible cleanup и успешный Area
relocation сохраняют прежнее однопроходное поведение; новая защита их не
задерживает. Доказательство: существующие и дополнительные unit cases плюс
весь
demo/smoke_area_relocation.mjs. - AC11. При ошибке записи config подтверждённая cleanup decision остаётся
повторяемой, а неподтверждённая никогда не попадает в write batch.
Доказательство: browser smoke на отказ
config/setи следующий rebuild. - AC12. Мутанты «вернуть derived roster как authority», «считать первое
отсутствие достаточным» и «разрешить cleanup пустого namespace» краснеют на
целевых тестах/смоке. Доказательство:
node scripts/mutation-gate.mjs --check. - AC13. Typecheck, полный unit suite и build зелёные; bundle-копии
синхронны, новых
anyнет. Доказательство: локальные гейты процесса. - AC14. Технические документы и оба changelog описывают новый fail-safe
контракт; визуал не меняется. Для изменения
src/**канонический Docs screenshots run на task branch принят, всеimageSha256остаются прежними, меняется только source fingerprint при необходимости. Доказательство:check-docs, CI run и diff manifest.
План автотестов
Unit
Расширить test/device-area-relocation.test.mjs матрицей:
- derived roster пуст, raw device существует;
- device/entity namespace пуст один и несколько проходов;
- entity без registry row со states
on,unavailable,unknown; - живой/removed marker;
- первое отсутствие, повтор той же revision, второе отсутствие в новой;
- восстановление между проходами и повторный первый кандидат;
- independent device/entity namespaces;
- новый runtime coordinator после remount;
- explicit placement, rebind и normal relocation без задержки.
Browser smoke
Расширить demo/smoke_area_relocation.mjs production-bundle сценариями:
- переходный усечённый frame → нет config write → один refresh → binding вернулся → snapshot прежний;
- два непустых distinct revisions без binding → удалена только orphan entry;
- два пустых frames → snapshot не меняется, refresh-loop отсутствует;
- confirmed cleanup + отказ
config/set→ решение повторяется безопасно; - существующая матрица #126/#403/#406 остаётся зелёной.
Mutation
Добавить три точечных мутанта из AC12, каждый со штатным witness. Полная smoke- матрица, golden и performance остаются предрелизными; целевой area smoke входит в цикл реализации.
Риски и смягчение
- Два переходных усечённых ответа подряд. Они всё ещё могут выглядеть как подтверждённое удаление. Пустой namespace защищён безусловно; для непустого roster принята владельцем граница двух distinct revisions и контрольный refresh. Более сильная гарантия потребовала бы backend tombstones HA.
- Reload-loop. Confirmation refresh может сам породить новую revision. Смягчение: один запрос на fingerprint первого кандидата, второй проход либо удаляет, либо сбрасывает; пустые namespaces запрос не размножают (AC9).
- Уборка настоящего orphan задерживается или не выполняется при полностью пустом HA. Это принятая fail-safe деградация; map bounded 20 000 entries.
- Presentation и registry снова смешаются. API чистого resolver явно
разделяет
devicesдля relocation и binding-evidence для sweep; мутант AC12 защищает шов. - Два клиента подтверждают в разное время. Удаление идемпотентно, config revision/conflict path остаётся authoritative; runtime-кандидат не становится вторым persisted источником.
Откат
Feature flag не нужен: UI и schema не меняются. Откат — revert frontend- коммита. Persisted config останется совместимым в обе стороны; записи, сохранённые новым guard, старый frontend сможет прочитать. Уже удалённые старой версией entries автоматически не восстанавливаются.
Release-артефакты
docs/CHANGELOG.mdиdocs/CHANGELOG.ru.md: одна user-visible запись со ссылкой на #419;- актуализация нормативного lifecycle в
docs/CONFIG-COMPATIBILITY.mdи, если затронута формулировка фильтрации/проверок,docs/FILTERING.md/docs/TESTING.md; - golden не меняется; обязательный canonical Docs screenshots run подтверждает
отсутствие визуальной дельты для
src/**; - новых performance/security/release assets нет;
- issue остаётся открытым до пакетного закрытия при выпуске беты.
Принято предположительно, поменять свободно при ревью
- Runtime-кандидаты удобно хранить по exact binding и первой qualifying registry revision; persisted candidate map не вводится.
- Resolver получает отдельно presentation devices и read-only registry/state/ marker evidence либо заранее построенные sets; конкретная сигнатура не часть продукта.
- Empty guard применяется раздельно к Device и Entity Registry, чтобы пустой
Entity Registry не блокировал уборку доказанно исчезнувшего
device:*, и наоборот. - Confirmation refresh использует существующий shared registry cache и его debounce; отдельная подписка и новый таймер не создаются.
- Положительное evidence снимает candidate до построения write batch; решение об intentional cleanup собственного marker id остаётся выше orphan-sweep.