docs: specify safe Area snapshot cleanup

Issue: #419
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-02 19:01:36 +03:00
parent a3fb360ae1
commit 1e479154f7
2 changed files with 357 additions and 0 deletions
@@ -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:<id>` присутствует в текущем полном Device Registry;
2. `entity:<entity_id>` присутствует в текущем полном Entity Registry;
3. для exact `entity:<entity_id>` существует `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<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` матрицей:
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.
+1
View File
@@ -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