Files
houseplan-card/docs/specs/442-marker-write-rollback.md
T
Sergey Matyuninandclaude[bot] 4bd30423f2 docs: specify atomic marker write rollback
Define guarded immutable candidates for semantic marker saves and asynchronous retry-safe vacuum calibration UX.

Issue: #442
User-Visible: no
2026-09-03 21:42:57 +00:00

379 lines
26 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.
# ТЗ #442 — Атомарный откат отклонённых записей маркера
- Issue: https://github.com/Matysh/houseplan-card/issues/442
- Приоритет: P2, `bug`
- Статус ТЗ: готово к ревью
- Маршрут: full; меняются основной Save устройства и сохранение калибровки
робота, включая отказный UX и конкурентный контракт
- Связанные контракты: #439 (guarded optimistic rollback общих настроек),
#441 (атомарные CRUD-операции маршрутов карт), #162 (маршрутная
калибровка робота), #314 (отдельный откат физической геометрии)
## Сценарий
Home admin редактирует устройство либо калибрует карту робота. Backend
отклоняет `config/set` из-за semantic validation, конфликта или транспортной
ошибки. Карточка должна сразу снова показывать последнее подтверждённое
сервером состояние, но не терять введённый в открытом UI draft: пользователь
исправляет значение или повторяет сохранение без перезагрузки карточки и без
повторной ручной подгонки.
## Что человек увидит до и после
**До:** после отказа основной Device editor сообщает об ошибке, но локальный
marker остаётся изменённым и может попасть в View либо в следующую запись.
Калибровка закрывается и показывает success до ответа сервера; при отказе на
экране остаётся фантомная матрица, а ручную подгонку приходится повторять.
**После:** непринятый marker config автоматически возвращается к последнему
подтверждённому состоянию. Device editor остаётся открыт с введёнными полями.
Auto/manual calibration остаётся открытой и занятой до ответа: при успехе она
закрывается и только затем сообщает об успехе, при отказе сохраняет рассчитанный
draft для Retry и показывает ошибку.
## Подтверждённая проблема
1. `HouseplanEditorRuntime._saveMarker()` меняет `cfg.markers` до
`await _saveConfigNow()`. Обычный `catch` снимает `busy` и показывает toast,
но не возвращает `_serverCfg`, не перестраивает devices и не отделяет отказ
`config/set` от ошибок последующих layout/file housekeeping.
2. Backend реально может отклонить доступные из Device editor изменения через
`validate_marker_controls`, `validate_marker_light_entities`,
`validate_marker_value_badges` и `validate_marker_vacuum_routes`.
3. `_vacSaveMatrix()` мутирует marker in-place, перестраивает devices и вызывает
debounced `_saveConfig()`, после чего callers сразу закрывают
`_vacCalConfirm`/`_vacFit` и показывают `vac.autocal_done` либо
`vac.cal_done`. Promise результата у UI нет.
4. CRUD маршрутов в `src/editors/vacuum-maps-section.ts` уже использует
`optimisticAttempt()`/`rollbackOptimistic()` и остаётся эталоном, а не второй
реализацией в рамках #442.
5. Общий writer уже сериализует запросы и отдельно обрабатывает conflict и
physical geometry. #442 не должен подменять эти механизмы глобальным reload
либо откатом несвязанной транзакции.
## Скоуп
В скоупе:
- атомарный immutable candidate для основного Save устройства;
- guarded rollback всего config-кандидата при отказе именно его `config/set`;
- восстановление производных marker/device представлений после отката;
- сохранение draft Device editor и возможность Retry;
- асинхронная запись auto-calibration, принятого high-residual proposal и manual
fit;
- busy-состояние калибровочного UI до ответа сервера;
- success toast и закрытие calibration UI только после подтверждённой записи;
- сохранение рассчитанной матрицы или параметров ручной подгонки при отказе;
- конкурентные случаи conflict reload и более новой локальной ревизии;
- регрессия атомарных маршрутов #441;
- unit, browser smoke и mutation witnesses;
- документация и оба changelog.
## Не-скоуп
- атомаризация всех 18 generic `_saveConfig()` call sites;
- Hide/Show, удаление marker, discovery seeding, обычные `live`, `trail_mode` и
`source` настройки робота, если они не входят в сохраняемый calibration/route
candidate;
- изменение четырёх backend validators, schema либо формата ошибок;
- изменение математики auto-calibration, residual threshold или manual fit;
- изменение маршрутизации карт, route identity либо выбора этажа;
- откат физической геометрии или замена механизма #314;
- транзакционное удаление безвредных файлов-копий после rejected rebind;
- новый глобальный transaction manager для всех editor writes;
- новые тексты ошибок, отдельные модальные предупреждения или дополнительные
подтверждения.
## Контракт поведения
### 1. Граница marker-транзакции
Основной Save строит новый `ServerConfig` как отдельный candidate из текущего
подтверждённого root. До отправки не допускается in-place изменение предыдущего
`cfg.markers`: предыдущий config должен оставаться пригодным для точного
восстановления.
Одна попытка фиксирует:
- глубокую копию предыдущего config;
- предыдущий `_cfgContentFingerprint`;
- `_cfgRev` до отправки;
- candidate и его content fingerprint.
Candidate может быть показан оптимистично, но при отклонении его
`config/set` вызывается guarded rollback. После успешного rollback карточка
сбрасывает marker-derived caches/signature, перестраивает devices и запрашивает
render. Ни одно поле непринятого marker не остаётся в View или следующей записи.
Конфигурационная транзакция заканчивается сразу после успешного
`_saveConfigNow()`. Layout update, очистка старого layout id и файловое
housekeeping выполняются только после durable config acceptance. Ошибка такого
последующего best-effort шага не имеет права откатывать уже принятый сервером
config.
### 2. Guarded rollback и конкуренция
Rollback применяется только если текущие revision и content fingerprint всё
ещё принадлежат не принятому candidate. Сравнение content обязательно и для
того же object identity: более новая in-place правка не может быть затёрта
старым reject.
Таблица решений:
| Событие во время попытки | Итог |
|---|---|
| semantic/schema/transport reject, candidate всё ещё текущий | восстановить previous config и прежний fingerprint |
| `conflict`, `_saveConfigNow()` уже перечитал server truth | не откатывать authoritative reload |
| появилась более новая local revision/content | не откатывать новую правку |
| пользователь закрыл диалог во время запроса | config откатывается по guard; диалог не воскрешается; toast остаётся |
| config принят, затем упал layout/file side effect | принятый config остаётся; сообщается ошибка соответствующего шага |
Сериализация через существующую `_writeChain` и `expected_rev` сохраняется.
Pending debounced write не должен обгонять прямой marker/calibration save; один
и тот же candidate не отправляется повторно скрытым debounce.
### 3. Основной Device editor
При Save UI становится busy и не запускает вторую попытку. После успеха
сохраняется текущий UX: диалог закрывается, devices перестраиваются и
показывается `toast.marker_saved`.
При отказе config-записи:
- `_serverCfg` и отображение возвращаются к accepted state;
- диалог, если он ещё открыт, остаётся открыт и выходит из busy;
- его локальные поля остаются такими, какими их ввёл пользователь;
- Retry строит новый immutable candidate поверх актуального server config;
- success toast не показывается; показывается существующая локализованная
ошибка;
- если диалог закрыт пользователем, он не создаётся заново.
Rebind сохраняет порядок безопасности файлов: copy допустим до config save,
cleanup — только после acceptance. Оставшаяся после reject копия безвредна и
остаётся вне скоупа housekeeping.
### 4. Routes/maps
Добавление, переназначение и удаление `map_routes` продолжает использовать
атомарный `persistRoutes()` из #441. #442 не возвращает эти операции к общему
debounce и не создаёт второй route writer.
Основной Save устройства обязан переносить текущий vacuum block в candidate,
не стирая уже принятую route transaction. Ошибка основного Save откатывает его
полный candidate к состоянию непосредственно перед попыткой, а не к снимку до
последней успешно принятой route-операции.
### 5. Запись калибровки
`_vacSaveMatrix()` становится асинхронной атомарной операцией либо делегирует
такой операции. Она:
1. строит отдельный config candidate;
2. материализует минимальный marker в candidate для first-use vacuum, не
изменяя previous config;
3. записывает matrix в exact route/legacy target через существующий
`writeVacuumMatrix()`;
4. фиксирует optimistic attempt, присваивает candidate и перестраивает preview;
5. ожидает `_saveConfigNow()`;
6. возвращает success только после server acceptance;
7. при reject выполняет guarded rollback, перестраивает devices и возвращает
failure вызывающему UI.
### 6. Calibration UX
Для low-residual auto-calibration Device editor остаётся на экране. На время
записи соответствующие calibration controls busy/disabled; повторный клик не
создаёт второй запрос. `vac.autocal_done` появляется только после acceptance.
При reject диалог остаётся открыт, busy снимается, показывается ошибка; Retry
повторно использует тот же пользовательский вход и снова рассчитывает matrix.
Для high-residual proposal:
- Apply не очищает `_vacCalConfirm` до ответа;
- proposal получает busy state, закрытие, Cancel, Fit и повторный Apply на это
время недоступны;
- success закрывает proposal и показывает `vac.autocal_done`;
- reject оставляет тот же proposal/matrix открытым, снимает busy и позволяет
Retry либо переход в ручную подгонку.
Для manual fit:
- Save не очищает `_vacFit` до ответа;
- overlay получает busy state; drag, rotate/mirror, Save и выход, который мог бы
потерять draft, на время запроса не создают новую попытку;
- success закрывает overlay и показывает `vac.cal_done`;
- reject оставляет exact `FitParams` и route identity, снимает busy, возвращает
accepted config и позволяет Retry без повторной подгонки.
Обычный Cancel до начала записи сохраняет прежнее поведение. Нового
предупреждения при ошибке не добавляется.
## Данные и совместимость
- Persisted schema, marker/vacuum shape и revision protocol не меняются.
- Миграции данных нет.
- Успешные marker, route и calibration payloads должны быть эквивалентны
текущим после canonicalization.
- Legacy calibration и explicit `map_routes` сохраняют контракт #162/#443.
- First-use write сохраняет остальные принятые поля marker: clone-and-patch не
удаляет неизвестные/future поля marker, config или vacuum.
## Touch, клавиатура и доступность
Busy является настоящим disabled-состоянием controls, а не только визуальным
индикатором. Повторные touch/click/Enter не создают дополнительный Save.
Focus остаётся в том же открытом dialog/overlay после reject. Закрытие через
Esc/scrim во время уже начатой попытки не воскрешает UI после ответа и не
мешает config rollback. Новых targets, жестов и строк i18n нет.
## Ошибки и крайние случаи
| Случай | Ожидаемое поведение |
|---|---|
| controls cycle отклонён backend | marker в View прежний, draft dialog сохранён, Retry доступен |
| invalid light/value source отклонён | тот же guarded rollback без частичного marker |
| route calibration отклонена | accepted matrix остаётся в config, новая fit/proposal остаётся UI-draft |
| first first-use vacuum calibration отклонена | synthetic marker не остаётся локально |
| повторный Save во время pending | один `config/set` |
| conflict reload во время reject | server truth выигрывает, старый rollback no-op |
| новая локальная правка поверх candidate | новая content revision выигрывает |
| Esc закрыл Device editor в полёте | config восстановлен, dialog не воскрешён, error toast виден |
| config принят, layout update не удался | config не откатывается и не расходится с сервером |
| route CRUD #441 reject/retry | прежний атомарный UX остаётся зелёным |
## Acceptance criteria и доказательства
### AC1. Основной Save атомарен
Unit/contract test доказывает, что `_saveMarker()` строит отдельный candidate и
при semantic reject восстанавливает весь previous config/fingerprint. Browser
smoke отклоняет `controls` или другой реально валидируемый field, сверяет View,
сохранённый dialog draft, busy=false и успешный Retry.
### AC2. Откат не затирает владельца новой ревизии
Unit покрывает обычный reject, conflict reload, заменённый root и более новую
in-place правку того же candidate. Mutant с unconditional rollback и mutant,
который пропускает fingerprint для same identity, обязаны краснеть.
### AC3. Main Save отделяет durable config от side effects
Test double принимает `config/set`, затем отклоняет layout/file operation.
Принятый marker остаётся локально, старый config не восстанавливается, а
операция не показывает `toast.marker_saved` до завершения обязательной части
успешного пути.
### AC4. Auto-calibration ждёт сервер
Browser smoke держит `config/set` deferred: до resolve отсутствует success,
Device editor/proposal остаётся открыт и busy, повторный Apply не пишет второй
раз. Resolve закрывает нужный UI и даёт ровно один success toast.
### AC5. Reject auto proposal сохраняет Retry
High-residual proposal при reject остаётся с той же matrix/route identity,
accepted config восстановлен, busy снят и второй Apply может успешно записать
ровно одну калибровку.
### AC6. Reject manual fit сохраняет подгонку
Browser smoke фиксирует изменённые `FitParams`, отклоняет Save и проверяет:
overlay не закрыт, параметры не изменились, pointer/кнопки снова доступны,
config вернулся к accepted matrix. Retry success закрывает overlay и только
тогда показывает `vac.cal_done`.
### AC7. First-use и route identity сохранены
Тест отклоняет первую калибровку auto-discovered vacuum: minimal marker не
остаётся в `_serverCfg`. Существующие multifloor/first-use tests подтверждают,
что successful retry пишет matrix в exact route и не возвращает legacy path.
### AC8. #441 не регрессировал
`smoke_vacuum_route_draft` продолжает доказывать atomic add/reassign/delete,
reject/retry и отсутствие пустого route draft в persisted config. Основной
marker Save не стирает только что принятую route transaction.
### AC9. Общие гейты
Зелёные typecheck, unit, build и синхрон трёх bundle trees. Для diff в `src/**`
обязателен `check-docs`; `smoke-select` определяет дополнительные browser
scenarios. Полный Linux HA harness остаётся каноническим CI. Изменение не
трогает геометрию, поэтому `model-invariants` неприменим. Visible busy/reject UX
проверяется smoke; постоянный golden добавляется только при наличии подходящей
canonical surface.
## План тестирования
- расширить `test/serialized-write-queue.test.mjs` проверкой same-identity
content change;
- выделить pure candidate/rollback helpers для marker и calibration там, где
это уменьшает stateful browser setup;
- добавить browser smoke rejected marker Save + preserved draft + Retry;
- расширить vacuum smoke deferred/rejected auto proposal и manual fit;
- сохранить зелёными `smoke_vacuum_route_draft`, `smoke_vacuum_firstuse`,
`smoke_vacuum_multifloor`, `smoke_vacuum` и `smoke_dialog_zombie`;
- добавить mutation-gate anchors для отсутствующего marker/calibration
rollback, раннего success/close и same-identity fingerprint guard;
- выполнить `typecheck`, `npm test`, `build`, `bundle:sync`, `check-docs`,
выбранные smokes и обязательный CI на exact SHA.
## Карта реализации
- `src/serialized-write-queue.ts` — строгий guarded rollback при изменённом
content того же object identity;
- `src/houseplan-editor-runtime.ts` — immutable marker/calibration candidates,
async persistence, rollback/rebuild и delayed success/close;
- `src/houseplan-card.ts` — типы busy-state, render/disabled contract и async
delegates;
- при необходимости небольшой отдельный pure-модуль marker candidate, чтобы не
раздувать editor runtime;
- `test/`, `demo/smoke_*.mjs`, `scripts/mutation-gate.mjs` — witnesses;
- `docs/CONFIG-COMPATIBILITY.md`, `docs/VACUUM.md`, оба changelog и screenshot
fingerprint — release artifacts.
## Риски и rollback
- Главный риск — откатить authoritative conflict reload или более новую правку;
закрывается revision + unconditional content-fingerprint guard.
- Второй риск — принять config на сервере, затем ошибочно вернуть локально
previous из-за layout/file failure; закрывается явной durable boundary.
- Третий риск — потерять first-use marker либо route identity при clone; его
закрывают first-use и multifloor witnesses.
- Четвёртый риск — закрыть calibration UI до реального ответа и потерять draft;
закрывается deferred/reject/retry smoke.
- Rollback реализации — revert frontend/tests/docs/bundle. Data migration нет;
принятые marker/calibration payloads совместимы с предыдущей версией.
## Release-артефакты
- В том же user-visible implementation commit обновляются
`docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` со ссылкой на #442.
- `docs/CONFIG-COMPATIBILITY.md` фиксирует: semantic reject marker write не
остаётся локальной конфигурацией, conflict/newer revision выигрывают.
- `docs/VACUUM.md` фиксирует, что success калибровки означает подтверждённую
запись, а reject сохраняет auto/manual draft для Retry.
- Любой `src/**` diff требует канонической `Docs screenshots` съёмки и приёмки
fingerprint по процессу. Если существующий кадр реально меняется, его diff
просматривается; несвязанные локальные baseline differences не принимаются.
- Новая постоянная golden surface не требуется, если calibration pending/reject
UI отсутствует в текущей canonical matrix; light/dark и keyboard/touch
состояние подтверждается browser smoke.
- Нового performance или security artifact не требуется: payload size,
вычислительная геометрия и trust boundary не меняются.
## Принятые предположения
- Default Q1: в #442 входят только UI-пути, которые могут изменить поля четырёх
marker semantic validators; generic marker/config writes вне этого множества
не атомаризируются.
- Default Q2: calibration UI остаётся открытым и busy до ответа; reject
сохраняет рассчитанный draft для Retry, success закрывает UI после acceptance.
- Любой reject semantic marker attempt откатывает только эту попытку; conflict
reload и более новая revision/content имеют приоритет.
- Draft основного Device editor живёт отдельно от `_serverCfg` и не теряется при
rollback.
- Backend/schema/i18n, успешный View и физическая геометрия не меняются.
- Файлы, скопированные перед rejected rebind, остаются безопасным остатком и не
удаляются новой клиентской гонкой.