mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
@@ -0,0 +1,284 @@
|
||||
# ТЗ #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. Цели
|
||||
|
||||
1. Закрыть отсутствие `expected_rev` как обход optimistic locking для любого
|
||||
уже сохранённого конфига.
|
||||
2. Сохранить стабильный `conflict`-контракт и существующую обработку актуальной
|
||||
карточкой.
|
||||
3. Оставить безопасный bootstrap пустого store без ревизии для установки или
|
||||
совместимого административного клиента.
|
||||
4. Защитить единственную frontend-точку записи исполняемым inventory guard.
|
||||
5. Не менять 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-ов и атомарность
|
||||
|
||||
1. Действующие permission/runtime и `MAX_CONFIG_BYTES` guard-ы сохраняют
|
||||
приоритет и текущее поведение.
|
||||
2. Проверка наличия/совпадения ревизии выполняется внутри `rt.write_lock`
|
||||
непосредственно после загрузки store и до CPU-валидации кандидата.
|
||||
3. При ненулевой текущей ревизии отсутствие поля эквивалентно stale CAS и
|
||||
возвращает `connection.send_error(..., "conflict", ...)`.
|
||||
4. Текст ошибки однозначно сообщает, что клиент не передал ожидаемую ревизию и
|
||||
должен перечитать конфиг; текущая ревизия может быть показана, поскольку
|
||||
endpoint доступен только администратору.
|
||||
5. Отказ происходит до любых изменений `msg["config"]`, записи backup/store,
|
||||
сброса optimizer snapshot, junction cache, plan collection и события
|
||||
`houseplan_config_updated`.
|
||||
6. Совпадающий с хранимым payload без `expected_rev` при `current_rev > 0` тоже
|
||||
получает `conflict`: canonical no-op не является обходом CAS.
|
||||
7. Для диагностируемости сохраняется 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
|
||||
|
||||
1. На пустом store `config/set` без `expected_rev` успешно создаёт `rev=1`.
|
||||
2. Второй клиент отправляет отличающийся config без `expected_rev`, получает
|
||||
`conflict`; последующий `config/get` возвращает первый config и `rev=1`.
|
||||
3. Отсутствующая ревизия с byte/semantic-equivalent payload при `rev=1` тоже
|
||||
получает `conflict`, без save/event/backup side effect.
|
||||
4. Повтор с явным `expected_rev=1` принимается; stale explicit rev по-прежнему
|
||||
получает `conflict`.
|
||||
5. `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:
|
||||
|
||||
1. Использовать существующий код `conflict`, не вводить новый error code.
|
||||
2. Оставить schema field optional ради bootstrap и доменного error response.
|
||||
3. Считать `rev=0` единственным безопасным legacy compatibility window.
|
||||
4. Расширить существующий coordinate writer inventory вместо создания второго
|
||||
скрипта с дублирующим поиском frontend writers.
|
||||
@@ -158,6 +158,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
|
||||
| [#198](https://github.com/Matysh/houseplan-card/issues/198) Optimize очищает изолированный микро-интервал толщины | [198-optimize-micro-interval.md](198-optimize-micro-interval.md) |
|
||||
| [#103](https://github.com/Matysh/houseplan-card/issues/103) Состояния в Toggle confirmation | [103-toggle-confirmation-state.md](103-toggle-confirmation-state.md) |
|
||||
| [#200](https://github.com/Matysh/houseplan-card/issues/200) Одинаковая геометрия подписи комнаты в View и Plan editor | [200-room-label-parity.md](200-room-label-parity.md) |
|
||||
| [#340](https://github.com/Matysh/houseplan-card/issues/340) Обязательная ревизия повторной записи config/set | [340-config-set-revision.md](340-config-set-revision.md) |
|
||||
|
||||
## P3
|
||||
|
||||
|
||||
Reference in New Issue
Block a user