Волна 5 эпика #674, перенос ТЗ (класс C). Из 240 файлов `docs/specs/` в `legacy/specs/` уехали 219: на них не ссылается ни один живой файл (код, тесты, скрипты, workflow, документы вне архива и ревью). Остались 21 ТЗ — на которые ссылаются код, ADR, ISOMETRIC, SUN, RADAR, LIGHT (`docs/specs/067`), DECOR-EDITOR, support-relay, и те, на которые ссылаются они сами; README каталога объясняет, где искать остальное. Открытых issue с файлом ТЗ среди перенесённых нет. Относительные ссылки перенесённых файлов переписаны (`../X` → `../../docs/X`, соседние оставшиеся ТЗ → `../../docs/specs/…`) — все 26 резолвятся. Попутно: битая ссылка в `089-isometric-view-stage1.md:8` на удалённый `089-isometric-view.md` — теперь команда `git show` по истории. Строка в `legacy/README.md`. Issue: #682 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
26 KiB
ТЗ #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 и показывает ошибку.
Подтверждённая проблема
HouseplanEditorRuntime._saveMarker()меняетcfg.markersдоawait _saveConfigNow(). Обычныйcatchснимаетbusyи показывает toast, но не возвращает_serverCfg, не перестраивает devices и не отделяет отказconfig/setот ошибок последующих layout/file housekeeping.- Backend реально может отклонить доступные из Device editor изменения через
validate_marker_controls,validate_marker_light_entities,validate_marker_value_badgesиvalidate_marker_vacuum_routes. _vacSaveMatrix()мутирует marker in-place, перестраивает devices и вызывает debounced_saveConfig(), после чего callers сразу закрывают_vacCalConfirm/_vacFitи показываютvac.autocal_doneлибоvac.cal_done. Promise результата у UI нет.- CRUD маршрутов в
src/editors/vacuum-maps-section.tsуже используетoptimisticAttempt()/rollbackOptimistic()и остаётся эталоном, а не второй реализацией в рамках #442. - Общий 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() становится асинхронной атомарной операцией либо делегирует
такой операции. Она:
- строит отдельный config candidate;
- материализует минимальный marker в candidate для first-use vacuum, не изменяя previous config;
- записывает matrix в exact route/legacy target через существующий
writeVacuumMatrix(); - фиксирует optimistic attempt, присваивает candidate и перестраивает preview;
- ожидает
_saveConfigNow(); - возвращает success только после server acceptance;
- при 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, остаются безопасным остатком и не удаляются новой клиентской гонкой.