# #491 — Незавершённая пара Optimize/Undo переживает следующую запись Issue: [#491](https://github.com/Matysh/houseplan-card/issues/491). Ветка: `issue/491-optimize-undo-pair-recovery`; база аналитики: `dev@c9a0690c`. Редакция: 2026-09-09. Полный трек, P1/bug. Канонический статус — метка issue, не заголовок этого документа. ## 1. Сценарий Администратор дома подтверждает **«Оптимизировать планы»** либо серверную отмену этой операции. Между независимыми записями конфигурации и расположения случается временная ошибка диска. Не перезапуская Home Assistant, пользователь или другая открытая карточка затем сохраняет обычную настройку либо позицию. Это сценарий SCOPE J6: House Plan обязан сохранить согласованную модель дома и не превратить временный сбой одной операции в скрытую потерю плана или расположения устройств. ## 2. Что человек увидит до и после **До:** следующая обычная правка может пройти поверх половины Optimize/Undo и стереть единственный след восстановления; после перезапуска остаётся смешанная пара из разных состояний. **После:** House Plan сначала прозрачно доводит незавершённую пару до записанного целевого состояния или отката, а уже затем применяет следующую правку; если восстановление пока невозможно, новая правка честно отклоняется и recovery остаётся доступен для следующей попытки или перезапуска. ## 3. Проблема и подтверждённая причина Config и layout хранятся в двух HA Store-файлах. `plan/optimize` и `plan/optimize_undo` сначала кладут `optimize_pending` в metadata layout-store, затем сохраняют config и только после этого финальный layout. Setup умеет довести такую пару при следующем запуске. Текущий дефект находится в окне до restart: - `layout/set`, `layout/update` и `layout/delete` намеренно удаляют одновременно `optimize_backup` и `optimize_pending`; - `config/set` после durable config write вызывает `_discard_optimizer_snapshot`, который удаляет те же два ключа best effort; - сами Optimize/Undo не используют существующий retry → rollback протокол парной записи, которым уже защищены Import и удаление пространства; - поэтому после ошибки между половинами следующая запись способна уничтожить intent, не завершив ни target, ни rollback. Backend-код этого пути не менялся между аудиторским срезом `ea6061e9` и базой этой спецификации. Смежная #87 сохраняет metadata при geometry repair, но не разрешает конкуренцию следующего writer с незавершённой парой. ## 4. Область изменения В scope: 1. единый crash-resumable commit-протокол для Optimize, Optimize Undo и уже существующих парных операций; 2. write fence перед всеми командами, которые могут изменить config и/или layout: обнаружить валидный `optimize_pending`, свести записанную пару, перечитать оба store и только затем продолжить текущую команду; 3. поведение при fail-before-write и fail-after-durable-write каждой половины; 4. поведение следующей записи без restart и setup recovery после restart; 5. HA-harness fault-injection тесты на настоящих Store и WS handlers; 6. актуализация канонического storage-контракта и пользовательского описания отказоустойчивости. Не в scope: - изменение геометрии, отчёта или UI Optimize; - новый формат config/layout, новая ревизия схемы или миграция существующих данных; - переписывание HA Store или буквальная транзакция между двумя файлами; - изменение one-deep семантики: успешный обычный edit после полностью завершённого Optimize по-прежнему делает Undo недоступным; - восстановление исторически потерянного `optimize_pending`, которого уже нет; - изменение Import, удаления пространства или geometry repair сверх перевода на общий безопасный примитив без смены их контракта; - новое пользовательское окно, кнопка или настройка. ## 5. Термины и инварианты - **before-pair** — согласованные config/layout и metadata до операции; - **target-pair** — канонические config/layout и их точные целевые revisions; - **pending intent** — durable `optimize_pending`, содержащий target либо rollback pair и конечную metadata; - **resolved pair** — оба store соответствуют одному pending intent, а сам intent удалён последней layout-записью; - **ordinary writer** — `config/set`, `layout/set`, `layout/update`, `layout/delete` и `geometry/repair`; - **paired writer** — `plan/optimize`, `plan/optimize_undo`, `import/apply` и `space/delete`. Обязательные инварианты: 1. pending становится durable раньше первой видимой половины; 2. pending удаляется только финальной layout-записью resolved pair; 3. ни один следующий writer не читает частичную пару как исходную; 4. событие успеха и успешный WS-ответ появляются только после обеих durable половин и удаления pending; 5. при невозможности resolution новая операция не пишет собственный кандидат, не создаёт собственное событие и не удаляет pending/backup; 6. после resolution текущая команда заново читает revisions и проходит обычный CAS/no-op/validation путь — сохранённые до сбоя значения не используются. ## 6. Контракт парного commit ### 6.1 Подготовка Каждый paired writer строит два явных канонических объекта: - target intent: требуемые config/layout, точные target revisions и metadata, которая должна остаться после успеха; - rollback intent: before-pair с исходными revisions и исходной metadata. Optimize при успешном target оставляет новый `optimize_backup`; Undo при успехе очищает backup и относящийся к заменённому layout `repair_backup`. Rollback возвращает исходную metadata без потерь неизвестных ключей. ### 6.2 Выполнение и ошибки Общий helper выполняет действующий безопасный порядок Import: 1. durable target intent; 2. запись target config; 3. финальная запись target layout с конечной metadata и удалением intent; 4. при исключении — перечитать store и один раз повторить сведение того же target, потому что HA Store может бросить исключение уже после durable write; 5. при повторной неудаче — durable заменить intent на rollback и попытаться свести before-pair; 6. если rollback завершён, вернуть стабильную ошибку операции; 7. если rollback также не завершён, вернуть стабильную ошибку и оставить rollback intent для write fence/setup recovery. Нельзя угадывать по исключению, успела ли запись на диск: решение всегда принимается по перечитанному store и полному записанному intent. Для Optimize/Undo ошибка не должна выходить необработанным исключением из WS handler. Используется существующий канал ошибки сохранения; новый UI-контракт не вводится. ## 7. Write fence до следующей операции Под `write_lock`, до чтения revisions/валидации собственного кандидата, каждый ordinary и paired writer проверяет layout metadata. Если валидного pending нет, поведение не меняется. Если pending есть: 1. helper сводит строго пару, записанную внутри него, включая `final_metadata`; 2. при успехе writer перечитывает config и layout и начинает обычную работу с полученной согласованной пары; 3. CAS-команда со старой revision получает обычный `conflict`, а не молчаливый merge; клиент перечитывает данные штатным путём; 4. point writer без CAS (`layout/update/delete`) применяет только собственную точечную дельту поверх восстановленного layout и не теряет остальные точки; 5. при новой ошибке сведения writer возвращает стабильную ошибку сохранения, ничего своего не записывает и сохраняет pending/backup. No-op проверяется после fence и повторного чтения. Поэтому реальный no-op не создаёт revision и не инвалидирует актуальный one-deep backup; операция, которая лишь выглядела no-op относительно устаревшей половины, оценивается уже от resolved pair. Команды, только читающие stores, в этой задаче не превращаются в writers. Кратковременная частичная картина между исходной ошибкой и write fence/restart допустима; задача закрывает потерю recovery, а не добавляет read-repair. ## 8. Startup recovery Setup использует тот же resolver pending-пары, а не отдельную расходящуюся реализацию. Старые валидные pending без `final_metadata` продолжают обрабатываться по текущему compatibility fallback: сохранить backup для target, очистить его для Undo/rollback согласно сохранённым признакам. Recovery идемпотентен: - target уже записан в обе половины, но intent остался после fail-after-write — повтор удаляет intent без нового смыслового состояния; - записана только config-половина — дописывается layout; - записана только durable intent — записываются обе половины; - сохранён rollback intent — восстанавливается before-pair, а не первоначально запрошенный и уже объявленный неуспешным target. События config/layout update испускаются после успешного setup resolution как сейчас. Неуспех setup оставляет intent и не маскируется очисткой metadata. ## 9. One-deep Undo и совместные записи - Успешный Optimize оставляет `can_optimize_undo=true` только для точных итоговых revisions пары. - Успешный Undo удаляет backup и возвращает `can_optimize_undo=false`. - Следующий настоящий ordinary edit сначала разрешает pending, затем меняет свою часть и инвалидирует уже завершённый backup по прежнему правилу. - Geometry repair остаётся maintenance-операцией и переносит актуальный backup на новую layout revision по контракту #87. - Чужая stale CAS-запись не должна уничтожать ни target, ни before-pair: после resolution она получает `conflict` до своего durable write. - Точечное перемещение/удаление, начатое после ошибки, сохраняет все чужие позиции recovered layout и меняет только названный marker id. ## 10. Модель данных и совместимость Новых persisted-полей нет. Структура `optimize_pending` и `optimize_backup` остаётся читаемой существующими версиями. `final_metadata`, `kind` и `clear_backup`, уже используемые Import/setup, становятся единым внутренним протоколом всех paired writers. Store/model version не повышается. Экспорт, импортируемый JSON и support package не меняют формат. Неизвестные metadata layout-store сохраняются. Откат к старой версии безопасен для полностью resolved pair. Если откат происходит в момент сохранённого pending, старая setup recovery должна понимать его в пределах уже существующей структуры; новых обязательных полей для завершения не добавлять. ## 11. UX и i18n Обычный успешный сценарий визуально не меняется. Прозрачное resolution не показывает отдельного диалога. При устойчивом отказе пользователь остаётся в существующем сценарии ошибки сохранения и может повторить действие или перезапустить Home Assistant. Новых i18n-ключей и новых frontend toast/dialog не требуется. Backend не раскрывает путь к файлам, exception text или содержимое плана. ## 12. Производительность, безопасность и touch В обычном пути добавляется одно чтение layout metadata либо проверка уже загруженного документа под существующим `write_lock`; сетевых запросов и фонового polling нет. Дорогая конвергенция выполняется только при наличии pending. Бюджеты frontend bundle/render не затрагиваются. Права команд не меняются. Pending не позволяет обойти `expected_rev`: проверка разрешений остаётся до write lock, а CAS выполняется после resolution по свежим revisions. Touch/View/Kiosk не получают нового взаимодействия. ## 13. Критерии приёмки **AC1 — общий безопасный commit Optimize.** Для `plan/optimize` fault injection до и после durable записи intent, config и финального layout либо приводит к успешному exact target, либо возвращает контролируемую ошибку с exact rollback; частичной пары без pending не остаётся. Доказательство: HA-harness parameterized backend tests на реальных handlers и Store; мутационный свидетель удаляет retry/rollback-вызов Optimize и краснеет. **AC2 — тот же контракт Undo.** Для `plan/optimize_undo` те же точки отказа дают exact restored pair либо exact pre-Undo pair; успешный Undo очищает backup, неуспешный rollback сохраняет возможность повторного recovery. Доказательство: HA-harness parameterized tests; мутационный свидетель убирает парный commit у Undo и краснеет. **AC3 — следующая config-запись не уничтожает recovery.** После искусственного fail-before/fail-after второй половины следующий `config/set` сначала сводит pending. Старая revision получает `conflict`; повтор с новой revision сохраняет свою несвязанную правку поверх resolved target. При повторном отказе recovery собственный config-кандидат не записан, intent/backup сохранены. Доказательство: HA-harness endpoint test; мутация удаления fence у `config/set` оставляет тест красным. **AC4 — каждый layout writer безопасен.** Та же матрица отдельно проверена для `layout/set`, `layout/update`, `layout/delete` и `geometry/repair`: CAS writers конфликтуют по свежей revision; point writer меняет только названную запись; maintenance сохраняет внешний backup по #87; никто не удаляет unresolved pending. Доказательство: HA-harness parameterized endpoint tests; мутация общего fence либо возврат прямого `remove=(backup,pending)` краснит набор. **AC5 — paired writers не начинают вторую пару поверх первой.** `import/apply`, `space/delete`, повторный Optimize и Undo сначала разрешают предыдущий pending, затем заново проверяют обе revisions. Stale запрос отклоняется без собственного intent; свежий запрос создаёт ровно одну новую пару. Доказательство: HA-harness parameterized tests и чтение кода общего входа. **AC6 — неразрешимый pending блокирует новый write.** Если Store продолжает отказывать во время fence, каждая команда из AC3–AC5 возвращает стабильную ошибку, не испускает событие успеха, не пишет собственный payload и оставляет pending/backup для повторной попытки. Доказательство: HA-harness negative tests с before/after snapshot и event spy; мутационный свидетель превращает отказ fence в продолжение writer и краснеет. **AC7 — restart завершает оставшееся.** После каждой точки отказа AC1/AC2 и после неуспешного fence reload интеграции приводит store к exact target либо exact rollback, очищает pending последним и повторный reload ничего не меняет. Доказательство: HA-harness setup/reload tests на реальном storage fixture. **AC8 — one-deep семантика не изменилась.** Успешный Optimize можно отменить; no-op Save не съедает backup; geometry repair переносит его; настоящий следующий edit инвалидирует; успешный Undo делает повторный Undo недоступным. Доказательство: существующий `test_plan_optimize_pair_and_one_deep_undo_survives_geometry_repair` плюс новые recovery cases, все зелёные. **AC9 — форматы и смежные операции совместимы.** Существующие Import и space-delete retry/rollback тесты зелёные; старый pending без `final_metadata` восстанавливается; неизвестная layout metadata не теряется; версии stores/config и wire payload не меняются. Доказательство: HA-harness compatibility test, существующий import fault suite, ревью diff схем/констант. **AC10 — документация соответствует реализации.** Architecture, Config Compatibility, пользовательское описание хранения/Optimize и Testing фиксируют write fence, retry/rollback и точные команды доказательства. Доказательство: `node scripts/check-docs.mjs` и ревью документации. ## 14. План автотестов 1. Вынести переиспользуемый fault injector, различающий fail-before-write и fail-after-write по store, фазе, revision и наличию pending. 2. На настоящем HA websocket harness параметризовать Optimize и Undo по точкам: intent layout; target config; final target layout; rollback intent; rollback config; final rollback layout. 3. Для каждого остаточного pending прогнать все endpoints AC3–AC5 сначала при восстановившемся Store, затем при продолжающемся отказе. 4. Сравнивать не только revisions, но exact canonical config/layout, metadata, число записей, события и WS result/error. 5. После каждого вида остатка выполнить reload дважды: первый сводит, второй доказывает fixed point. 6. Добавить адресные mutations для fence и включения Optimize/Undo в общий retry/rollback helper. Каждый защитный AC получает таблицу «чем краснеет» в код-ревью по PROCESS §2.7. 7. Выполнить полный backend harness в Linux CI/WSL; native Windows pure subset не объявлять доказательством endpoint-контракта. ## 15. Риски и меры - **Сведение не того состояния.** Pending является единственным authority; helper не строит target заново из текущих половин. - **Stale writer поверх recovery.** После fence обязательны re-read и обычный CAS; нельзя продолжить с локальными переменными до fence. - **Цикл ошибок Store.** Одна попытка resolution на входящий writer; без бесконечных retries и без удержания websocket до restart. - **Потеря неизвестной metadata.** Target/rollback несут exact final metadata, а compatibility fallback сохраняет неизвестные ключи. - **Расхождение setup и runtime.** Один resolver используется обоими путями. - **Ложнозелёные fault-тесты.** Проверять fail-after-durable-write и запускать мутации, а не только подменять helper/predicate. ## 16. Откат Код можно откатить одним issue-коммитом без миграции данных: persisted-формат не меняется. Перед откатом нужно убедиться, что в store нет активного pending, либо дать текущей версии завершить его перезапуском. Откат возвращает прежний риск окна error → next writer и потому не является штатным способом лечения данных. ## 17. Release-артефакты - `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`: исправлена возможная потеря согласованности плана после ошибки Optimize/Undo; - `docs/ARCHITECTURE.md`: единый pair commit/resolver и write fence; - `docs/CONFIG-COMPATIBILITY.md`: persisted/revision/recovery контракт; - `docs/USER-GUIDE.ru.md` и английская пара: краткое поведение при временном отказе сохранения; - `docs/TESTING.md`: fault matrix и ссылки на автоматические доказательства; - golden/docs screenshots не меняются: UI не изменяется; - performance/front-end bundle артефакты не требуются; - exact-SHA Linux Validate с полным backend harness обязателен перед выпуском. ## 18. Принятые технические предположения Эти решения не являются продуктовым выбором и могут быть изменены ревьюером без обращения к владельцу, если AC остаются выполнены: 1. Общий resolver разумно разместить рядом с `async_save_*_state` в backend storage-модуле и вызывать из setup и WS handlers, чтобы не иметь двух реализаций recovery. 2. Существующий `_commit_import_pair` следует обобщить/переименовать, а не копировать его для Optimize и Undo. 3. Stable error может использовать существующий `commit_failed` с безопасным сообщением; отдельный код `recovery_pending` допустим только если не требует нового пользовательского сценария. 4. Read-only `config/get`, `layout/get`, export и support preview не выполняют read-repair в этой задаче; согласованность защищается write fence и setup. 5. Проверка permissions остаётся до fence: неавторизованный вызов не должен инициировать recovery как побочный эффект.