From 1e479154f7d3dfa235a9b0622b786608e4a184bd Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Wed, 2 Sep 2026 19:01:36 +0300 Subject: [PATCH] docs: specify safe Area snapshot cleanup Issue: #419 User-Visible: no --- docs/specs/419-area-snapshot-roster-guard.md | 356 +++++++++++++++++++ docs/specs/README.md | 1 + 2 files changed, 357 insertions(+) create mode 100644 docs/specs/419-area-snapshot-roster-guard.md diff --git a/docs/specs/419-area-snapshot-roster-guard.md b/docs/specs/419-area-snapshot-roster-guard.md new file mode 100644 index 00000000..b8a1bf5a --- /dev/null +++ b/docs/specs/419-area-snapshot-roster-guard.md @@ -0,0 +1,356 @@ +# ТЗ #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 как +исчезнувший, если выполняется хотя бы одно условие: + +1. `device:` присутствует в текущем полном Device Registry; +2. `entity:` присутствует в текущем полном Entity Registry; +3. для exact `entity:` существует `hass.states[entity_id]`, включая + `unavailable`/`unknown`: это доказательство существования, а не активности; +4. в сохранённом plan roster есть живой, не `removed` marker с тем же 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 не имеет ни одного положительного +доказательства жизни: + +1. первое отсутствие в authoritative revision сохраняет entry и записывает + runtime-кандидат `{ binding, firstRevision }`; +2. карточка инициирует не более одного контрольного refresh на fingerprint + набора кандидатов/registry revision; +3. повторная обработка той же revision не считается вторым подтверждением; +4. отсутствие в следующей отличающейся успешной непустой authoritative + revision разрешает `removeSnapshot: true`; +5. появление binding между проходами снимает кандидат без записи конфига; +6. 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-модель не меняется: + +```ts +settings.marker_area_snapshot?: Record; +``` + +Новых 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` матрицей: + +1. derived roster пуст, raw device существует; +2. device/entity namespace пуст один и несколько проходов; +3. entity без registry row со states `on`, `unavailable`, `unknown`; +4. живой/removed marker; +5. первое отсутствие, повтор той же revision, второе отсутствие в новой; +6. восстановление между проходами и повторный первый кандидат; +7. independent device/entity namespaces; +8. новый runtime coordinator после remount; +9. 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. + diff --git a/docs/specs/README.md b/docs/specs/README.md index 5e9bdfd3..ac8dd193 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -162,6 +162,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным | [#318](https://github.com/Matysh/houseplan-card/issues/318) Активный контроллер без собственных сущностей следует `controls` | [318-empty-controller-roster.md](318-empty-controller-roster.md) | | [#373](https://github.com/Matysh/houseplan-card/issues/373) Плотное кадрирование static card по геометрии дома | [373-space-card-house-fit.md](373-space-card-house-fit.md) | | [#294](https://github.com/Matysh/houseplan-card/issues/294) Esc завершает текущую цепочку стен без удаления геометрии | [294-wall-esc-detach.md](294-wall-esc-detach.md) | +| [#419](https://github.com/Matysh/houseplan-card/issues/419) Безопасная уборка Area-снапшота при пустом или усечённом HA-реестре | [419-area-snapshot-roster-guard.md](419-area-snapshot-roster-guard.md) | ## P3