21 KiB
ТЗ #340 — обязательная ревизия для повторной записи config/set
- Issue: https://github.com/Matysh/houseplan-card/issues/340
- Тип: баг / технический долг
- Приоритет: P3
- Трек: полный — меняется публичный WebSocket- и compatibility-контракт, риск потери данных выше 3
- Связанные задачи: #220 — штатная запись порядка пространств с ревизией; #224 — stale CAS не становится no-op
1. Сценарий
Владелец или администратор редактирует один общий план в двух вкладках, на двух устройствах либо оставляет надолго открытую вкладку со старой копией. Один клиент уже сохранил изменение. Второй клиент, который не передал ревизию прочитанного конфига, пытается записать свою полную копию поверх новой.
Этот же контракт относится к стороннему WebSocket-клиенту администратора и к
очень старому закэшированному bundle карточки. Обычный актуальный frontend уже
передаёт expected_rev через единственную транспортную точку записи.
2. Что человек увидит до и после
До изменения устаревшая вкладка может молча стереть более новую работу; после изменения сервер отвергнет такую запись как конфликт, а сохранённый план останется без изменений.
В актуальной карточке сохраняется существующее поведение конфликта: сообщение о том, что конфиг изменён в другом окне, загрузка актуального состояния и просьба повторить действие. Новый экран, диалог или настройка не появляются.
3. Проблема и подтверждённое состояние
ws_config_set принимает expected_rev как optional. Под write_lock он
сравнивает переданное значение с текущей ревизией, но при отсутствии поля над
ненулевой ревизией только пишет warning и продолжает полную замену конфига.
Это fail-open обход уже существующего optimistic-locking контракта.
Подтверждено на origin/dev перед началом задачи:
- актуальный frontend содержит ровно один
houseplan/config/setи передаёт в нёмexpected_rev: this._cfgRev; - параметр и обработка
conflictсуществуют с v1.4.4; - проверка ревизии атомарна, потому что выполняется внутри
write_lock; - стабильный публичный код ошибки называется
conflict, а неconfig_rev_conflict; docs/ARCHITECTURE.mdиdocs/TESTING.mdвсё ещё описывают отсутствие ревизии как разрешённый warning-only путь.
Сервер не получает достоверную версию клиента или иной выданный при чтении write token. Поэтому запись без ревизии нельзя одновременно разрешить «легаси-клиенту» и безопасно отличить от stale writer: это один и тот же запрос. Версионное окно, сохраняющее запись поверх ненулевой ревизии, сохраняло бы исходный дефект.
4. Цели
- Закрыть отсутствие
expected_revкак обход optimistic locking для любого уже сохранённого конфига. - Сохранить стабильный
conflict-контракт и существующую обработку актуальной карточкой. - Оставить безопасный bootstrap пустого store без ревизии для установки или совместимого административного клиента.
- Защитить единственную frontend-точку записи исполняемым inventory guard.
- Не менять stored config, его model/store version и пользовательский UI.
5. Не-цели
- не менять
layout/set,layout/update, import/optimize и их собственные CAS; - не вводить
client_version, epoch, сессию редактирования или merge конфига; - не пытаться автоматически повторить потерянное пользовательское действие;
- не добавлять серверную телеметрию, rate limiter или новую настройку;
- не менять разрешения: записывать конфиг по-прежнему может только администратор;
- не исправлять сторонние клиенты, которые никогда не читали или не сохраняли ревизию.
6. Контракт поведения
6.1 Матрица config/set
| Текущая ревизия store | expected_rev |
Результат |
|---|---|---|
0 |
отсутствует | bootstrap принимается; после успешной записи ревизия становится 1 |
0 |
0 |
запись принимается по обычному CAS |
N > 0 |
отсутствует | conflict; config/rev/backup/event и файловая уборка не меняются |
N |
N |
обычная валидация; успешная запись или canonical no-op |
N |
любое другое число | conflict; сохранённое состояние не меняется |
Bootstrap-исключение безопасно и при двух одновременных клиентах: первый запрос
коммитит rev=1 под write_lock, а второй после получения того же lock уже
попадает в строку N > 0 и отклоняется.
6.2 Порядок guard-ов и атомарность
- Действующие permission/runtime и
MAX_CONFIG_BYTESguard-ы сохраняют приоритет и текущее поведение. - Проверка наличия/совпадения ревизии выполняется внутри
rt.write_lockнепосредственно после загрузки store и до CPU-валидации кандидата. - При ненулевой текущей ревизии отсутствие поля эквивалентно stale CAS и
возвращает
connection.send_error(..., "conflict", ...). - Текст ошибки однозначно сообщает, что клиент не передал ожидаемую ревизию и должен перечитать конфиг; текущая ревизия может быть показана, поскольку endpoint доступен только администратору.
- Отказ происходит до любых изменений
msg["config"], записи backup/store, сброса optimizer snapshot, junction cache, plan collection и событияhouseplan_config_updated. - Совпадающий с хранимым payload без
expected_revприcurrent_rev > 0тоже получаетconflict: canonical no-op не является обходом CAS. - Для диагностируемости сохраняется warning с текущей ревизией и причиной отказа, но в нём нет конфига, user data или токенов.
Voluptuous-поле остаётся vol.Optional("expected_rev"), чтобы bootstrap
оставался валиден и чтобы отсутствие ревизии возвращало стабильный доменный
код conflict, а не общий schema/format error. На уровне семантики повторной
записи поле обязательно.
6.3 Клиент и конфликт
Актуальный frontend не получает нового пути: _sendConfigCandidate() как и
сейчас передаёт _cfgRev, обновляет его только после успеха, а существующий
обработчик conflict показывает toast.conflict и перечитывает серверный
конфиг. Новый i18n-ключ не требуется.
Исполняемый inventory в scripts/coordinate-write-barrier-guard.mjs должен не
только доказывать единственность и canonicalization config/set, но и требовать
expected_rev в том же outbound message. Появление новой прямой точки записи
без CAS должно ломать unit gate.
6.4 Совместимость старых клиентов
expected_revявляется частью штатного frontend-контракта с v1.4.4;- очень старый или сторонний клиент без поля может один раз инициализировать
пустой store (
rev=0); - такой клиент не может менять уже сохранённый план: он получает
conflictи должен обновиться либо реализоватьconfig/get → config/set(expected_rev); - временное принятие blind write поверх
rev>0запрещено: без дополнительного аутентифицированного маркера возраст клиента неразличим со stale write; - downgrade storage/config не требуется, чтение старым клиентом не меняется.
Это намеренное fail-closed завершение compatibility window, а не миграция документа. Безопасность сохранённой работы важнее поддержки клиента, предшествующего базовому CAS-контракту.
7. UX, i18n и touch
Новых элементов интерфейса нет. В актуальной карточке используются существующие ключи:
- EN:
toast.conflict; - RU:
toast.conflict; - DE и последующие локали используют тот же уже существующий ключ.
Прямой WS-клиент получает код conflict и английский backend message, как и
при несовпавшей явной ревизии.
View/kiosk и touch gestures не меняются. Контракт touch-first усиливается:
открытый планшет не может молча затереть правку с desktop. Редакторы остаются
desktop-first, но safety floor из docs/TOUCH-SUPPORT.md запрещает потерю
данных и соблюдается одинаково на всех устройствах.
8. Модель данных и миграция
- новых persisted-полей и compatibility-полей нет;
revостаётся метаданными store,expected_rev— полем WS-запроса;- model/store/export version не меняются;
- чтение и запуск интеграции не переписывают данные;
- успешный bootstrap и обычная запись используют существующий save path;
- отклонённая запись не создаёт миграцию, backup или новую ревизию.
docs/CONFIG-COMPATIBILITY.md фиксирует bootstrap-only совместимость старого
wire-контракта и отсутствие безопасного версионного окна для blind writes.
9. Производительность, безопасность и наблюдаемость
Положительный путь получает одну дешёвую проверку наличия ключа, уже рядом с существующим сравнением integer. Новый I/O, сериализация или проход по конфигу не добавляются. Отрицательный путь становится дешевле, потому что завершается до executor validation.
Изменение закрывает целостность данных, но не является security-boundary: endpoint остаётся admin-only. Нельзя включать содержимое конфига в warning. Отдельные performance capture, security scan и telemetry artifact не нужны.
10. Затронутые файлы и модули
custom_components/houseplan/websocket_api.py— fail-closed guard;tests_backend/test_ha_websocket.py— bootstrap, missing-rev conflict, неизменность store/rev/event;scripts/coordinate-write-barrier-guard.mjsи существующий unit-тест — обязательныйexpected_revво frontend inventory;docs/ARCHITECTURE.md— WS API и optimistic-locking contract;docs/CONFIG-COMPATIBILITY.md— bootstrap-only legacy compatibility;docs/TESTING.md— заменить warning-only expectation;docs/USER-GUIDE.md,docs/USER-GUIDE.ru.md— несколько клиентов и необходимость актуальной карточки;docs/CHANGELOG.md,docs/CHANGELOG.ru.md— пользовательская запись;docs/specs/README.md— связь issue ↔ ТЗ.
Frontend product code и словари i18n не меняются.
11. План автотестов
11.1 Backend HA websocket
- На пустом store
config/setбезexpected_revуспешно создаётrev=1. - Второй клиент отправляет отличающийся config без
expected_rev, получаетconflict; последующийconfig/getвозвращает первый config иrev=1. - Отсутствующая ревизия с byte/semantic-equivalent payload при
rev=1тоже получаетconflict, без save/event/backup side effect. - Повтор с явным
expected_rev=1принимается; stale explicit rev по-прежнему получаетconflict. caplogподтверждает диагностический warning без сериализованного конфига.
Тест использует две WebSocket-сессии либо эквивалентно доказывает две последовательные записи под общей runtime/store authority. Проверка хранит отличимый marker/space payload, чтобы исключить ложный успех no-op.
11.2 Frontend inventory
test/coordinate-write-barrier-guard.test.mjs проходит для production source и
падает на fixture/source mutation, если единственный config/set больше не
содержит expected_rev рядом с canonical candidate.
11.3 Регрессия
- существующий
test_config_rev_conflictостаётся зелёным; - targeted backend test file проходит в HA harness;
npm test,npm run build,npm run docs:accept— по локальному гейту процесса;- полный backend suite и обычные CI gates — на review/merge pipeline.
12. Критерии приёмки
| AC | Проверяемое условие | Доказательство |
|---|---|---|
| AC1 | Первый config/set без rev на пустом store допустим, но любая следующая запись без rev получает conflict |
backend HA websocket test |
| AC2 | Missing-rev conflict не меняет config, rev, backup и не публикует houseplan_config_updated, включая semantic no-op payload |
backend HA websocket test со spy store/event bus |
| AC3 | Два конкурентных клиента не могут применить две blind writes: после первого коммита второй без rev отклоняется | backend HA websocket test с двумя клиентами/общим runtime |
| AC4 | Точный explicit rev сохраняет текущий success/no-op contract, устаревший explicit rev сохраняет conflict |
существующий и расширенный backend tests |
| AC5 | В production frontend существует ровно один config/set, он canonical и всегда содержит expected_rev |
executable coordinate writer guard + unit negative fixture |
| AC6 | Публичная документация больше нигде не обещает warning-only blind overwrite и описывает bootstrap-only compatibility | npm run docs:accept + ревью кода |
| AC7 | UI, i18n, config schema/model/export version, View/kiosk/touch и положительный write-path не меняются | typecheck/build + ревью кода |
13. Риски и меры
| Риск | Мера |
|---|---|
| Очень старый cached bundle больше не может сохранить существующий план | CAS доступен с v1.4.4; fail-closed error, документация просит обновить карточку; bootstrap остаётся |
| Общий schema error сломает обработчик конфликта | поле остаётся Optional, handler явно возвращает стабильный conflict |
| No-op превратится в обход CAS | revision guard выполняется до canonical/semantic no-op |
| Второй bootstrap успеет записать поверх первого | load/check/save сериализованы одним write_lock; backend test двух клиентов |
| Warning раскроет или размножит пользовательские данные | логировать только факт и integer rev; config/payload запрещены |
| Новый frontend writer забудет CAS | executable inventory guard ломает unit gate |
14. Откат
Кодовый откат — вернуть warning-only ветку для отсутствующего expected_rev.
Миграция назад не нужна: изменение ничего не записывает и не меняет формат.
Такой откат снова открывает подтверждённую потерю данных и допустим только как
аварийный временный шаг с отдельным issue; feature flag не нужен.
15. Release-артефакты
docs/CHANGELOG.mdиdocs/CHANGELOG.ru.md: коротко сообщить, что старая вкладка/клиент больше не может молча перезаписать новый конфиг, ссылка #340;- обновить architecture, compatibility, testing и обе user-guide локали по §10;
- screenshots/golden не нужны: визуал не меняется;
- performance capture не нужен: нет render/runtime cost;
- отдельный security artifact не нужен; backend tests являются доказательством fail-closed целостности;
- commit trailer реализации:
Issue: #340,User-Visible: yes.
16. Принятые предположительно технические решения
Эти решения не требуют продуктового ответа владельца и могут быть изменены ревьюером при сохранении AC:
- Использовать существующий код
conflict, не вводить новый error code. - Оставить schema field optional ради bootstrap и доменного error response.
- Считать
rev=0единственным безопасным legacy compatibility window. - Расширить существующий coordinate writer inventory вместо создания второго скрипта с дублирующим поиском frontend writers.