Files
houseplan-card/docs/specs/419-area-snapshot-roster-guard.md
T
2026-09-02 19:01:49 +03:00

356 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ТЗ #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.