# ТЗ #126 — HA Area переносит marker в новую комнату - **Issue:** https://github.com/Matysh/houseplan-card/issues/126 - **Статус документа:** принято на `dev@4683a493cca25a4a527f2b96d3d11723aa9da74c`, реализуется - **Приоритет:** P1 - **Тип:** bug, полный трек - **Пользовательское изменение:** да ## 1. Продуктовый сценарий Персона — Home admin; поверхность — View, Devices editor и hosted Static; момент — после изменения Area устройства или standalone entity средствами Home Assistant. Администратор меняет Area в HA. Если принадлежность marker комнате не переопределена в House Plan, объект должен автоматически оказаться в автосетке комнаты, связанной с новой Area, и получить существующую красную метку «требует внимания». До: сохранённая ручная позиция удерживает marker в прежней комнате, если старая и новая комнаты находятся в одном пространстве. После: authoritative HA Area перебивает сохранённую раскладку, и все View-поверхности показывают marker в новой комнате. Задача закрывает J6 из `docs/SCOPE.md`: план не должен продолжать показывать устройство там, где его больше нет. ## 2. Подтверждённая причина на текущем dev `buildDevices()` уже пересчитывает `DevItem.area` и `DevItem.space` из registry. Однако `_livePos()` принимает сохранённую запись layout, пока совпадает только `saved.s === device.space`. Комната/Area в layout не записана. Поэтому: - между разными spaces сохранённая позиция уже перестаёт применяться; - между комнатами одного space старая позиция продолжает побеждать новую `_defaultPositions()`; - `_syncNewDevices()` сравнивает только identity устройства, поэтому Area transition не добавляет `new_device_ids`. Hosted Static повторяет дефект через `space-geometry.ts::markerPos()`. ## 3. Решения владельца 1. Сохранённая после drag позиция — раскладка, а не принадлежность; смена HA Area её перебивает. 2. Явные `marker.area` и `marker.room_id` всегда побеждают registry Area. 3. Новое место выбирает обычная автосетка комнаты, не строгий центр. 4. Используется существующая красная метка `new_device_ids`, без нового вида badge или уведомления. 5. Охвачены direct device и entity markers, если effective room получена из HA registry. Автоматические composite light groups не переносятся по Area одного участника. ## 4. Выпущенные соседние контракты ### #29 — каталог и lifecycle устройств - `settings.new_device_ids` остаётся persisted источником красной метки; - переехавший marker остаётся в категории «На плане» и получает признак «Новое»; - открытие каталога и Find не подтверждают метку, открытие Device editor — подтверждает существующим путём; - hide/remove/HA-disabled semantics не меняются. ### #74 — position-only Undo/Redo - registry relocation не создаёт history command; - pending relocation отменяет активный drag этого marker; - position history очищается, чтобы Undo не воскресил принятую старую позицию; - authoritative layout event другого клиента продолжает очищать history существующим механизмом. ### #317 — effective room placement Явное назначение определяется действующим правилом: - непустой `marker.area` — explicit Area target; - `marker.area == null` вместе с непустыми `marker.space` и `marker.room_id` — explicit local-room target; - иначе direct device/entity marker следует registry Area. Тот же смысл применяется к визуальному размещению и room-climate membership. Нельзя вводить вторую несовместимую трактовку `area/space/room_id`. ## 5. Scope - детерминированное обнаружение authoritative direct registry Area transition; - немедленное runtime-игнорирование stale saved position во всех View; - удаление stale layout первым клиентом с write permission; - placement через существующую общую автосетку target room; - existing red `new_device_ids` attention и обычный acknowledgement; - live registry refresh, cold start, reload и multi-client config/layout events; - безопасный one-time backfill старых configs без provenance; - device/entity binding, explicit override и composite-group exclusions; - frontend/backend schema, import/export compatibility, unit/backend/smoke/docs. ## 6. Не входит в задачу - управление HA Area из House Plan; - перенос Area самой комнаты или изменение room↔Area binding; - новый badge, toast, history, notification либо отдельная категория каталога; - Undo/Redo внешнего registry change; - автоперенос automatic/composite light groups; - восстановление старой ручной позиции после переезда; - изменение `marker.area`, `marker.room_id` или layout при временно неавторитетном registry snapshot; - lifecycle устройств без Area либо с Area, не привязанной ровно к одной комнате плана — это действующий scope #29. ## 7. Единый pure resolver Вводится provider-neutral pure-модуль (рабочее имя `src/device-area-relocation.ts`). Он не импортирует Lit, не вызывает HA и не пишет stores. На вход получает: - полный список текущих `DevItem`; - current model всех spaces/rooms; - layout snapshot; - persisted Area provenance; - признак authoritative registry snapshot. На выходе: ```ts type MarkerAreaProvenance = { binding: `device:${string}` | `entity:${string}`; area: string; }; type AreaRelocationDecision = { markerId: string; binding: MarkerAreaProvenance['binding']; currentArea: string; targetSpaceId: string; targetRoomId: string; kind: 'baseline' | 'relocate' | 'defer'; reason: | 'unchanged' | 'new-without-layout' | 'backfill-same-room' | 'backfill-stale-room' | 'backfill-cross-space' | 'backfill-ambiguous' | 'area-changed' | 'target-unresolved' | 'registry-unverified'; }; ``` Exact названия можно изменить без изменения semantics. Один и тот же resolver используют interactive card, hosted Static, persistence coordinator и tests. ## 8. Eligibility и источник Area Eligible: 1. markerless auto device с `bindingKind:'device'`; 2. saved direct `device:` marker без explicit placement; 3. saved direct `entity:` marker без explicit placement, включая standalone entity и entity с parent device; 4. HA-disabled saved direct marker, если полный authoritative registry всё ещё содержит exact binding и Area: при реактивации он должен появиться правильно. Ineligible: - virtual marker; - marker с explicit placement по §4; - markerless auto `entity:` light group; - composite/fold/group, чья Area выведена не из exact direct binding; - removed marker; - orphaned/unverified binding; - binding без непустой direct registry Area. Direct entity Area = `entity.area_id || parentDevice.area_id`. Direct device Area = `device.area_id`. Friendly name, icon, model, current coordinates и id prefix не являются доказательством принадлежности. `bindingKind: 'entity'` сам по себе недостаточен: такой же kind сейчас имеют автоматические light groups. Direct entity marker подтверждается наличием сохранённого `marker` с exact `binding === entity:${bindingRef}`. Markerless `lg_*`/composite item исключается независимо от Area участника; prefix служит только defensive check, не основным discriminator. Area должна разрешаться **ровно в одну** room текущего model. Дубликат Area у нескольких комнат считается ambiguous: решение `defer`, position и provenance не меняются. ## 9. Persisted provenance Config settings получает optional internal map: ```ts settings.marker_area_snapshot?: Record; ``` Ключ — runtime marker id: HA device id у auto device либо `marker.id` у saved marker. Exact binding хранится обязательно. Без него rebind того же marker id к другому устройству ошибочно выглядел бы как HA Area transition. Контракт поля: - это bounded lifecycle metadata, не пользовательская настройка; - вводится самостоятельный backend limit `MAX_MARKER_AREA_SNAPSHOT = MAX_KNOWN_DEVICES = 20_000` entries; дополнительно действует общий `MAX_CONFIG_BYTES = 2 MiB`; - entries имеют только bounded strings `binding` и `area`; - запись с другим binding считается новым baseline/backfill, не relocation; - explicit/ineligible marker очищает собственную старую entry; - отсутствующий в runtime marker не очищается только из-за active-only roster: authoritative full registry/marker lifecycle решает, существует ли binding; - deleted/rebound/nonexistent exact binding очищается действующей lifecycle hygiene; - layout v2 не расширяется: provenance принадлежности не дублируется в координатах. ## 10. Runtime-проекция до persistence После каждого authoritative registry rebuild resolver вычисляет множество `relocate`. - `_livePos()` interactive card и `markerPos()` hosted Static игнорируют saved layout для этих ids и используют existing default position target room; - interactive effective attention = persisted `new_device_ids` плюс pending relocate ids. Это не второй persisted источник, а проекция ещё не подтверждённого перехода; - read-only клиент показывает правдивое новое положение, но не вызывает config/layout writes и не получает дополнительных прав; - первый клиент с `_canEdit === true` запускает persistence coordinator; - ordinary HA state ticks и повторный идентичный registry fingerprint не создают новую работу. Таким образом медленный backend или отсутствие admin-card не заставляют View показывать marker в заведомо старой комнате. ## 11. Обработка подтверждённой смены Area Для entry с тем же binding: 1. `previous.area === currentArea` → `baseline/unchanged`, no-op; 2. current Area не разрешается ровно в одну room → `defer`, snapshot не продвигается; 3. Area изменилась → `relocate` независимо от сохранённой drag position; 4. отменить active drag marker и очистить position history; 5. удалить saved layout через `houseplan/layout/delete`; 6. после успешного либо уже идемпотентного delete добавить id в `new_device_ids` и записать provenance `{binding,currentArea}`; 7. отправить одну сериализованную config mutation для batch текущего rebuild. Порядок fail-safe: layout delete раньше продвижения provenance. Если config write после delete падает, старый snapshot остаётся на сервере; следующий authoritative pass повторит idempotent delete и attention write. Нельзя признать Area обработанной, оставив серверную старую позицию. Пока persist выполняется, in-flight ids дедуплицируются. Toggle space, hover, render и второй идентичный registry callback не создают parallel delete/config loop. ## 12. Initial backfill старых configs Если entry отсутствует либо binding в ней отличается: - saved layout отсутствует → записать baseline без attention; - `saved.s` указывает другое пространство, чем target room → удалить stale position и добавить attention; - saved point строго и однозначно лежит внутри target room → сохранить position и записать baseline; - saved point строго и однозначно лежит внутри другой room с другой непустой HA Area → считать существующим stale case, удалить position, автопоставить в target room и добавить attention; - source room без HA Area, point вне rooms, на стене/границе нескольких rooms либо при invalid geometry → сохранить position и записать baseline; историю без доказательства не угадывать. Point-in-room использует canonical final room polygons в plan coordinates и отдельно отсекает boundary. Decor/device bounds и wall body не участвуют. Backfill выполняется одним bounded pass и одним config write. Повторный reload видит provenance и не превращает inference в постоянный auto-layout. ## 13. Автосетка и attention Удаление saved position возвращает marker в тот же `defaultPositions()` path, который уже используется для новых устройств и cross-space Area placement. Несколько marker, переехавших одновременно, раскладываются общим детерминированным pass и не накладываются в центре. `new_device_ids` переиспользуется без смены UI/формата: - badge, каталог, фильтрация и acknowledgement остаются как в #29; - duplicate id не добавляются; - acknowledgement не возвращает старую position и не меняет provenance; - external relocation не попадает в history #74; - explicit hidden marker сохраняет user-hidden состояние; relocation не превращается в Show; - HA-disabled marker остаётся service ghost/hidden по действующему контракту, но после реактивации использует уже новую комнату. ## 14. Multi-client, revisions и races - Клиенты вычисляют одинаковую runtime-проекцию из общего config/registry. - Первый writer сериализованно удаляет layout и обновляет config; остальные принимают обычные revision events. - Concurrent drag отменяется при local transition. Старый remote drag write не должен воскресить position после принятого delete: существующие layout revision/resync и pending delete authority остаются обязательными. - Если explicit override сохранён до relocation commit, свежий config rebuild делает marker ineligible и отменяет auto mutation. - Rebind того же marker id не является Area transition из-за binding в provenance; применяется backfill нового binding. - Временный limited/unverified registry не создаёт отрицательного вывода, cleanup, move или write. - Ошибка layout delete оставляет provenance старым и состояние retryable без timer loop; runtime View по-прежнему может показывать безопасную новую default position. ## 15. UX, touch и accessibility Новых controls, dialogs и строк нет. Marker визуально появляется в target room с существующей красной меткой. Focus не переносится, dialog не открывается. Если Device dialog этого marker открыт в момент authoritative transition, его не требуется принудительно закрывать или перехватывать focus. При Save dialog обязан заново разрешить binding по актуальному registry: несвязанные изменения сохраняются, но stale room draft не может вернуть marker в прежнюю комнату или создать explicit override. Registry-following placement остаётся authoritative. View и kiosk на touch обязаны показать то же положение, что desktop View. Devices editor остаётся desktop-first, но не может отображать старую позицию после authoritative transition. ## 16. Model, import/export и compatibility - Добавляется только optional `settings.marker_area_snapshot`. - Model/Store version не повышается. - Backend schema валидирует структуру, lengths и общий config budget. - Config field регистрируется в `scripts/config-field-registry.mjs` и `docs/CONFIG-COMPATIBILITY.md`. - Same-source full backup сохраняет snapshot вместе с lifecycle metadata. - Full import из другого source/HA удаляет `marker_area_snapshot` вместе с `known_devices/new_device_ids`: чужие HA ids/Areas не являются provenance нового дома и должны пройти backfill. - Space-only import не переносит глобальный snapshot из документа. - Старый frontend игнорирует unknown setting; новый читает отсутствие поля как backfill, не как пустую историю смен Area. - Backend schema отклоняет новую запись/import с невалидной map или entry. Defensive frontend, если такие данные всё же попали из старого/внешнего источника в обход schema, игнорирует только повреждённую entry и применяет к её marker безопасный backfill; остальные entries продолжают работать. ## 17. i18n Новых строк нет. Используются существующие badge, catalog labels и save-error toast. Если реализация добавляет «Устройство переехало», отдельный toast или новый badge, это выходит за принятый scope. ## 18. Acceptance criteria 1. **AC1 — same-space relocation.** Direct-registry device с saved layout после Area A→B в том же space игнорирует и удаляет saved position, попадает в автосетку room B и получает existing red badge. Доказательство: unit + production-bundle smoke. 2. **AC2 — cross-space parity.** Тот же outcome действует между spaces; старая layout entry удаляется, а не остаётся orphan. Доказательство: unit + smoke. 3. **AC3 — immediate/read-only/static truth.** До persistence interactive read-only View и hosted Static игнорируют stale position; writes выполняет только writer. Доказательство: pure unit + hosted/full render smoke. 4. **AC4 — explicit wins.** `marker.room_id`/`marker.area` сохраняют room и saved position при HA Area change. Доказательство: authority matrix. 5. **AC5 — binding scope.** Auto device и direct device/entity markers переносятся; composite light group и virtual marker — нет. HA-disabled saved marker корректно появляется после re-enable. Доказательство: unit matrix. 6. **AC6 — safe backfill.** Cross-space и однозначная другая HA room исправляются; same-room/outside/boundary/area-less/ambiguous сохраняются. Доказательство: unit + cold-start smoke. 7. **AC7 — attention lifecycle.** Existing `new_device_ids` badge ставится один раз, отражается каталогом и очищается обычным acknowledgement без нового state kind. Доказательство: unit + browser smoke. 8. **AC8 — rebind provenance.** Тот же marker id с другим exact binding не считается Area transition. Доказательство: unit. 9. **AC9 — fail-safe writes.** Snapshot не продвигается при failed layout delete; failed config write повторяется после следующего authoritative pass; concurrent revision не воскрешает stale position. Доказательство: frontend/backend failure tests. 10. **AC10 — no Undo.** Relocation не создаёт command, отменяет active drag и очищает stale position stack. Доказательство: history unit/source contract. 11. **AC11 — bounded/no-loop.** No-change registry rebuild даёт zero writes; simultaneous transitions batch config mutation; in-flight work deduped. Доказательство: counter/performance unit. 12. **AC12 — compatibility.** Backend round-trip, same/cross-source import и config-field audit выполняют §16. Доказательство: backend tests + registry audit. ## 19. Обязательные тесты ### Unit - placement authority: auto device/direct device/direct entity/explicit local room/explicit Area/composite/virtual/removed/disabled/unverified; - previous=same/different/missing/binding-changed; - same-space/cross-space/unresolved/duplicate Area targets; - backfill point: target/other HA room/area-less/outside/boundary/ambiguous; - deterministic multi-marker default grid; - effective pending attention, dedupe/acknowledge; - no history command, active drag cancellation, no write on identical authoritative fingerprint; - mutation/failure ordering and cleanup bounds. ### Backend - optional structured map validation, string/entry/config limits; - config round-trip and unknown siblings; - same-source preserve, cross-source strip, space-only import isolation; - layout delete idempotence/revision event and conflict path. ### Browser smoke Новый targeted smoke `demo/smoke_area_relocation.mjs` либо эквивалент обязан проверить production bundle: - live Area A→B одного space после drag; - cross-space; - standalone entity marker; - explicit room override и composite exclusion; - cold-start stale backfill; - existing red badge/acknowledgement; - hosted Static и read-only projection; - no config/layout call при unchanged rebuild. Golden baseline не меняется: новый визуальный язык не вводится. Полный golden и performance остаются pre-beta gates. Performance budget: resolver запускается только на authoritative registry/model rebuild, имеет линейную сложность по markers + rooms, не вызывается из render и при unchanged input не создаёт layout/config writes. ## 20. Затронутые поверхности - новый pure resolver `src/device-area-relocation.ts`; - registry/device rebuild, effective attention и persistence coordinator в `src/houseplan-card.ts`; - shared `markerPos()`/static render в `src/space-geometry.ts` и `src/space-render.ts`; - `ServerConfig` types, backend validation и import/export; - config-field registry, unit/backend/browser fixtures; - `docs/ARCHITECTURE.md`, `docs/FILTERING.md`, `docs/CONFIG-COMPATIBILITY.md`, `docs/USER-GUIDE.md`, `docs/USER-GUIDE.ru.md`, `docs/TESTING.md`, оба changelog. ## 21. Риски и откат | Риск | Мера | |---|---| | Rebind принят за Area change | provenance хранит exact binding | | Старый manual layout ошибочно принят за stale | backfill только при cross-space либо одной другой HA room | | Read-only продолжает показывать старую room | shared runtime projection игнорирует stale layout до persistence | | Config признал transition до layout | delete-first ordering | | Undo/remote drag воскресил position | cancel/clear history + pending delete/revision authority | | Composite group прыгает по Area участника | direct-binding eligibility | | Registry временно пуст | authoritative snapshot guard | | Несколько clients пишут одно и то же | idempotent delete, serialized config, revision reload, in-flight dedupe | Откат прекращает вычислять runtime relocations и читать/писать snapshot map, возвращая saved-layout priority. Optional metadata можно оставить ignored; destructive rollback и model downgrade не нужны. ## 22. Release-артефакты Implementation commit имеет `User-Visible: yes` и одновременно обновляет: - `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`; - EN/RU user guides — registry Area, explicit House Plan room и red badge; - architecture/filtering/config-compatibility/testing docs; - targeted smoke contract и exact local gate evidence в issue. ## 23. Принятые технические предположения - provenance — bounded structured map в global settings, не layout metadata; - самостоятельный предел snapshot — 20 000 entries плюс общий wire/config limit 2 MiB; - exact binding входит в entry и защищает rebind; - direct entity отличается от composite group по exact persisted marker binding, а не только по `bindingKind` или префиксу id; - pending relocation является derived runtime projection, а не вторым store; - writer batches decisions одного authoritative rebuild; - layout delete предшествует provenance commit; - cross-source import удаляет lifecycle provenance; - old-layout repair использует только строгий однозначный point-in-room; - точные helper/type names можно изменить на review без изменения semantics.