21 KiB
SPEC-REVIEW-529-r1
Issue: #529 — «Тупик «conflicting wall identifiers»: план правится только после ручной чистки, а совет «Optimize plans» не помогает (по #527)» Этап: spec (ревью ТЗ, PROCESS.md §2.4) Трек: полный (заявленный автором критерий: «нет миграции конфига и новых compatibility-полей» — не пройден) Заход: r1 · блокирующих циклов израсходовано 0 из 4
Скоуп
ТЗ живёт в теле issue #529 (единственный комментарий — аналитика S2, без отдельного текста ТЗ вне тела issue). Предмет: три дефекта, связанные с полем room_drafts в конфиге модели стен v10 —
_migrate_room_drafts_to_partitions(custom_components/houseplan/wall_segment_model.py) безусловно роняет коммит на пустом или непустомroom_drafts, если это не первая миграция;- сторож устаревшего клиента
validate_wall_model_transition(custom_components/houseplan/validation.py:175-193) не ловит случай, когда карточка эхом отправляет уже присланный ейmodel_version=10; - экспорт (
import_export._create_export) падает тем же отказом, что и обычная запись.
Контракт К1-К7 и AC1-AC8 переносят защиту #478 с безусловного raise на два места: миграцию (снимает пустой ключ молча, конвертирует непустой) и сторож (сравнивает присутствие room_drafts в заявке против сохранённого конфига, а не номера модели).
Как проверялось
Чтением кода, без исполнения — на этапе ТЗ кода ещё нет, гейты не прогонялись (правка кода не входит в этот этап).
docs/SCOPE.md,PROCESS.md(§1-§10.3),AGENTS.md— контекст процесса и границы продукта.docs/CONFIG-COMPATIBILITY.md, особенно раздел «Ordinary wall chains — model v10 (#478)» — канонический текст защиты, которую ТЗ переписывает.custom_components/houseplan/wall_segment_model.py:647-779—_migrate_room_drafts_to_partitions,commit_wall_segment_model.custom_components/houseplan/validation.py:142-220—validate_wall_model_transition.custom_components/houseplan/validation.py:1893-1960—_config_wall_segment_invariants(частьCONFIG_SCHEMA).custom_components/houseplan/websocket_api.py:1595-1700(ws_config_set) и:1990-2060(plan/optimize) — реальная последовательность вызовов на сервере.custom_components/houseplan/import_export.py:505-515, 670-690, 1815-1865— порядокcommit_wall_segment_modelvsCONFIG_SCHEMAв экспорте/импорте.src/wall-segment-model.ts:238-250, 700-800, 861-940— TS-зеркало,migrateLegacyRoomDraftsToPartitionsInPlace,commitWallSegmentModel.src/plan-optimizer.ts:33, 610-680— клиентский оптимизатор, вызывающий тот же примитив.src/houseplan-editor-runtime.ts:2000-2060— где карточка ловитWallSegmentModelErrorи показывает тост до отправки запроса.tests_backend/test_wall_segment_model.py:300-420,tests_backend/test_validation.py:1790-1860— существующие свидетели, которые АС4/АС7 обещают сохранить зелёными.src/i18n/en.json:743,src/i18n/ru.json:743— подтверждение, что ключtoast.wall_model_client_outdatedуже существует (i18n-раздел ТЗ корректен).test/fixtures/282-wall-identity-parity.json,test/wall-segment-model.test.mjs:48— паритетная фикстура, на которую опирается К6, существует.
Находки
Medium — К4/AC5 описывают механизм самолечения, который не соответствует реальному пути записи config/set
Файл: тело issue #529, разделы «Контракт» (К4) и «AC» (AC5).
Сценарий отказа. К4 гласит: «Если room_drafts есть и в заявке, и в сохранённом конфиге, заявка не отвергается: К1/К2 снимают ключ, и план перестаёт быть запертым». AC5 буквально формулирует свидетеля так: «validate_wall_model_transition не бросает; последующий commit_wall_segment_model снимает ключ».
Это описывает последовательность вызовов, которой в продакшен-коде не существует. Реальный обработчик записи конфига —
# custom_components/houseplan/websocket_api.py:1663-1666 (ws_config_set._normalize)
def _normalize(candidate):
validate_wall_model_transition(candidate, data.get("config"))
return CONFIG_SCHEMA(candidate)
commit_wall_segment_model здесь не вызывается вовсе — ни напрямую, ни через prepare_ordinary_summary_candidate (custom_components/houseplan/validation.py:2134-2149, которая лишь оборачивает summary-panel-инварианты вокруг переданного normalize). После прохождения сторожа кандидат идёт прямо в CONFIG_SCHEMA, а внутри неё — в _config_wall_segment_invariants:
# custom_components/houseplan/validation.py:1906-1907
if model >= 10 and "room_drafts" in space:
raise vol.Invalid("v10 config must not contain room_drafts")
Это третья, независимая точка защиты того же инварианта — отдельная от _migrate_room_drafts_to_partitions (К1/К2) и от validate_wall_model_transition (К3). Она реагирует на голое присутствие ключа безусловно, не спрашивая, первая ли это миграция и что лежит в сохранённом конфиге. Ни в теле ТЗ, ни в «Принятых технических предположениях», ни в списке «Затронутые файлы» эта функция не упомянута — при том что она находится в том же validation.py, который ТЗ уже трогает для К3, и охраняет ровно тот же инвариант («v10 config must not contain room_drafts»), что К1/К2 снимают.
Конкретное расхождение. Пункт 2 «Принятых технических предположений» — «Порядок слоёв при записи сохраняется: валидация переходов идёт до миграции, поэтому К3 срабатывает раньше К2» — описывает несуществующий для config/set порядок: для этого писателя после validate_wall_model_transition идёт не миграция (К2), а CONFIG_SCHEMA, которая содержит собственный, непереписываемый ТЗ запрет.
Почему это не голословно. Проверено: grep commit_wall_segment_model по бэкенду показывает единственные вызовы — в import_export.py (экспорт/импорт, где порядок действительно «миграция → CONFIG_SCHEMA») и в websocket_api.py:2050 (plan/optimize, и то условно — if submitted_model < WALL_SEGMENT_MODEL_VERSION). В ws_config_set вызова нет вообще (проверено чтением полного тела функции, websocket_api.py:1595-1700, и prepare_ordinary_summary_candidate, validation.py:2134-2149).
Смягчающее обстоятельство (почему не High). Основной репортуемый симптом — блокировка при создании/переименовании комнаты — судя по всему устраняется на клиентской стороне: карточка запускает commitWallSegmentModel (TS-зеркало) локально ещё до формирования кандидата config/set (это подтверждает и S2-анализ: «запрос на сервер даже не уходит»). После правки К1/К2 в src/wall-segment-model.ts это локальное преобразование перестанет бросать исключение и снимет room_drafts до отправки на сервер — так что в реальности config/set от актуальной карточки, скорее всего, никогда не увидит room_drafts в кандидате, и сценарий К4/AC5 для config/set может быть попросту недостижим на практике. Но это — рассуждение ревьюера по коду, не то, что утверждает и проверяет ТЗ; АС5 как написан проверяет пару функций, которая никогда вместе не вызывается на сервере, и поэтому не является доказательством того, что «план перестаёт быть запертым» для реального обработчика config/set.
Что не покрыто ни одним AC. Ни один из AC1-AC8 не бьёт по _config_wall_segment_invariants напрямую и ни один не прогоняет validate_wall_model_transition → CONFIG_SCHEMA в такой же последовательности, как это делает ws_config_set — то есть ровно тот путь, которым реально проходит запрос на переименование комнаты. Если реализация случайно оставит эту проверку нетронутой в сценарии, где она всё же достижима (например, будущий или уже существующий писатель, который — в отличие от config/set — передаёт room_drafts в CONFIG_SCHEMA до миграции), ни один свидетель этого не заметит.
Требуется от автора (правка ТЗ, тот же issue, без нового):
- Явно назвать
_config_wall_segment_invariants(validation.py:1906-1907) в контракте — либо распространить К1/К4 на неё (снять безусловный запрет и там), либо явно объяснить и записать, почему её можно не трогать (например: «этот путь недостижим для реальных писателей, потому что X» — с указанием, для каких вызововCONFIG_SCHEMAона всё ещё единственная линия обороны, если такие есть); - Поправить пункт 2 «Принятых предположений», чтобы он описывал реальный порядок вызовов
ws_config_set(validate_wall_model_transition→CONFIG_SCHEMA, без миграции), а не гипотетический; - Либо добавить AC, доказывающий конкретно то, что реально происходит в
ws_config_set/prepare_ordinary_summary_candidateдля сценария К4, либо переформулировать АС5, чтобы он не утверждал вызовcommit_wall_segment_modelкак механизм, работающий в этом писателе.
Серьёзность — Medium, в скоупе задачи (это и есть тот самый compatibility-контракт, ради которого задача идёт полным треком): без High-находок это жёлтый вердикт, правка ТЗ и повторный (второй) заход.
Что проверено и корректно
- Продуктовая рамка (§7.1). Раздел «Сценарий» называет персону (Home admin, редактирующий план —
docs/SCOPE.md), поверхность (редактор плана) и момент («пытается создать комнату, переименовать её или выгрузить бэкап»). «До/После» сформулированы без терминов реализации. - К1/К2 фактически описывают существующий баг корректно. Прочитан
_migrate_room_drafts_to_partitions(wall_segment_model.py:647-706): проверка"room_drafts" not in spaceидёт первой, затем безусловныйraise WallSegmentMigrationError("duplicate-id", ...)приnot initial_migration— до какой-либо проверки длиныdrafts. Значит пустой список ловится тем жеraise, что и непустой, ровно как заявлено в issue. - К3 корректно описывает текущий баг сторожа.
validate_wall_model_transition(validation.py:183-193): условиеold_model >= 10 and new_model < 10 and any("room_drafts" in space ...)требует, чтобы присланныйmodel_versionбыл меньше 10 — а карточка эхом отправляет полученный от сервераmodel_version=10, что и позволяет устаревшему клиенту проскочить мимо этого сторожа, как и сказано в тексте. - AC4 совместим с существующим тестом.
test_stale_v9_room_draft_write_over_v10_is_rejected_before_schema(tests_backend/test_validation.py:327-342) отправляетstale["model_version"] = 9сroom_drafts, аprevious(сохранённый) безroom_drafts— при переходе на условие «в заявке есть, в сохранённом нет» этот тест остаётся зелёным, ровно как обещает АС4. - АС6/К5 корректны для экспорта.
import_export.py:505-515(_create_export):commit_wall_segment_model(raw_config)вызывается доCONFIG_SCHEMA(config)— то есть к моменту схемной валидацииroom_draftsуже снят миграцией. После правки К1/К2 экспорт конфига с пустым или непустымroom_draftsдействительно перестанет падать; отдельная правкаimport_exportне нужна, как и заявлено. - К6/АС3 — паритетная инфраструктура существует.
test/fixtures/282-wall-identity-parity.jsonи обращающийся к нейtest/wall-segment-model.test.mjs:48присутствуют в дереве; TS-зеркало (src/wall-segment-model.ts:744-748, 795) содержит тот же баг (hasOwnProperty→ безусловныйthrowпри!initialMigration), что и Python-версия — правка К1/К2 в обоих зеркалах логически симметрична. - i18n раздел ТЗ точен.
toast.wall_model_client_outdatedуже существует вsrc/i18n/en.json:743иsrc/i18n/ru.json:743— новых ключей действительно не требуется. - Клиентский оптимизатор использует тот же примитив.
src/plan-optimizer.ts:33,648вызываетcommitWallSegmentModelInPlace/migrateLegacyRoomDraftsToPartitionsInPlace— то есть правка К1/К2 в TS-зеркале автоматически чинит и локальный расчёт «Optimize plans» на клиенте, без отдельной правкиplan-optimizer.ts. Это подтверждает разумность К7 (шесть остальных ветокduplicate-idи текст совета — вне скоупа): поведение Optimize для этой ветки меняется «бесплатно», как побочный эффект общей починки функции, а не нуждается в отдельном AC. - Откат, риски, миграция/совместимость, производительность, touch — присутствуют, по существу, не содержат зазоров для конкретно описанного контракта (кроме находки выше).
- Принятые технические предположения сформулированы как «принято, можно оспорить» (пункты 1 и 3 корректны и не нуждаются в правке); зазор только в пункте 2, разобран в находке.
Чего не проверял
- Не запускал
npx tsc --noEmit,npm test,npm run build, backend pytest — этап ТЗ, продуктового кода ещё нет, оценивать нечего. - Не проверял мутанты AC7 (
scripts/mutation-gate.mjs,scripts/backend-test-guard.mjs) — до появления кода мутант не существует; это предмет код-ревью (PROCESS.md §2.7). - Не проверял
_config_wall_segment_invariantsна предмет ДРУГИХ (не связанных сroom_drafts) инвариантов — вне темы issue. - Не трассировал полный клиентский путь
houseplan-editor-runtime.tsдля КАЖДОГО типа правки (только для создания/переименования комнаты, названных в issue) — достаточно для оценки контракта, но не является исчерпывающим аудитом фронтенда. - Не проверял
_prepare_import_document(import_export.py:670-690, выдача токена импорта) на предмет того, ловит ли она уже-конвертированный экспортированный файл — по коду он не будет содержатьroom_draftsпосле починки экспорта, так что сценарий, видимо, не реализуется, но это не входит в АС6 и явно не проверялось глубже.
Вердикт
Жёлтый. Контракт в основном точен и проверяем (К1/К2/К3/К5/К6 подтверждены чтением кода, AC1-AC4, AC6-AC8 сформулированы однозначно и с указанным способом доказательства), но К4/AC5 описывают механизм самолечения через последовательность вызовов, которой не существует в реальном обработчике config/set, и не называют третью, отдельную точку защиты того же инваринта (_config_wall_segment_invariants, validation.py:1906-1907). Это ровно тот зазор, который §7.1 называет «догадкой, выданной за факт»: технически правдоподобно, но не проверено по коду автором и не покрыто ни одним AC.
Материал раунда
- Ветка:
dev, коммитe408b6fb70a9— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
0885d3e45b9242464d2fadc048fb64ce54c65a74git log --all --format='%H %T' | grep 0885d3e45b92 - Тело issue:
097369f3a7fd490edce82469f02c715c2c084752b43ac793900719cd52f5c5b1 - Вердикт конвейера:
yellow· High 0