Files
houseplan-card/docs/specs/265-import-reference-seam.md
T
2026-08-25 14:47:04 +03:00

34 KiB
Raw Blame History

Issue #265 — единый контракт ссылочного шва импорта

  • Дата: 2026-08-25
  • Тип: refactoring / tech debt · приоритет P1 · класс A
  • Issue: #265
  • Ветка: issue/265-import-seam-contract
  • Статус ТЗ: на независимом ревью
  • Связано: #50, #244, #248, #252, #254, #258, #262

Канонические документы: docs/ARCHITECTURE.md, docs/CONFIG-COMPATIBILITY.md, docs/TESTING.md, docs/specs/050-config-export-import.md, docs/specs/244-orphan-space-references.md, docs/specs/248-optimize-idempotence.md, docs/specs/252-optimize-orphan-layout-report.md, docs/specs/258-wall-key-storage-roundtrip.md, docs/specs/262-readd-child-entity-after-device-delete.md.

1. Сценарий и персона

Администратор House Plan переносит отдельный этаж между своими экземплярами Home Assistant, сохраняет резервную копию импортированного этажа и позднее импортирует её ещё раз. Он ожидает получить ещё одну независимую копию этажа: комнаты, устройства, подписи и связи должны остаться на своих местах, а окно импорта — честно показать все исправления и проблемы до нажатия «Добавить».

Пользователю не нужно понимать внутренние идентификаторы, поколения импорта или устройство хранилища. Если безопасно восстановить связь нельзя, House Plan сохраняет данные, сообщает о проблеме и предлагает запустить «Оптимизировать планы», а не выбирает цель наугад.

Импорт и его подробности относятся к административному desktop-редактированию. В View, kiosk и обычном просмотре ничего нового не появляется. На touch- устройствах диалог остаётся доступен, но применяется общая политика проекта: полноценная работа редакторов гарантируется на desktop, touch-редактирование поддерживается по остаточному принципу.

2. Что человек увидит до и после

До: несколько циклов «экспортировать импортированный этаж → импортировать снова» постепенно усложняют служебные имена. Некоторые сохранённые связи со старой копией этажа не восстанавливаются. Предпросмотр может описать не совсем тот набор новых объектов, который фактически будет создан после подтверждения.

После: каждый повторный импорт создаёт обычную независимую копию без наращивания служебного «хвоста». В предпросмотре отдельными строками показано, сколько существующих связей восстановлено, сколько проблем сохранено без изменений и сколько связей пришлось отбросить по уже действующему правилу переноса. Кнопка «Добавить» применяет именно показанный вариант. Неоднозначные данные не исчезают; для их обслуживания остаётся «Оптимизировать планы».

3. Подтверждённое состояние кода

  1. build_space_merge() в import_export.py строит id через _fresh(prefix, old, used). Stem берётся из текущего old, поэтому import-of-import воспроизводимо создаёт space_space_*.
  2. _repair_target_space_refs() ремонтирует exact-map для marker.space, marker.room_id, vacuum.segment_map, layout[*].s и rl_<room>, но не связывает разные поколения одного происхождения.
  3. create_preview(), prepare_apply() и revalidate_candidate() повторно вызывают merge. Используемый _fresh() основан на случайном hash, поэтому preview не является точным снимком Apply.
  4. scripts/model-invariants.mjs проверяет не всю ссылочную матрицу. В нём нет, среди прочего, marker.room_id, vacuum.segment_map, rooms[].open_to, внутренних marker:* controls/value badge и partition opening host.
  5. walls[*].key и open_spans — геометрические carrier identities, а не plan-id ссылки. Они должны проверяться отдельными инвариантами и не должны участвовать в id-remap.

4. Унаследованные продуктовые решения

Эта задача не переоткрывает уже принятые решения.

  1. Неразрешимую ссылку нельзя угадывать или использовать как основание для удаления пользовательского объекта. Она сохраняется и попадает в отчёт. Optimize вправе снять только доказанно мёртвое размещение по правилам #244 и #252.
  2. removed: true marker tombstone хранится без срока до явного повторного добавления. Можно удалить только доказанно бесполезную layout-позицию tombstone; сам record импорт не собирает.
  3. Общий отчёт обслуживания остаётся внутри «Оптимизировать планы». Новый глобальный инспектор, фоновый GC и новый modal не создаются.
  4. Space import по-прежнему означает «добавить независимую копию». Он не превращается в update/replace существующего пространства.
  5. Full restore, одношаговый Undo и crash-safe paired commit из #50 не меняются.

5. Цели и границы

Входит

  • канонизация lineage для всех переименовываемых plan-id;
  • безопасный target-remap между поколениями;
  • единая типизированная матрица внутренних ссылок;
  • неизменяемый preview/apply candidate;
  • структурированный, ограниченный по размеру отчёт;
  • расширение независимых model invariants;
  • regression-контракты связанных задач.

Не входит

  • изменение schema/model version и записываемое поле provenance;
  • объединение импортируемого пространства с существующим;
  • поиск lineage по имени, геометрии, HA Area или похожести содержимого;
  • удаление marker records, tombstone, комнат, пространств либо файлов;
  • remap внешних HA entity/device/area ids;
  • remap walls[*].key, open_spans и прочих геометрических carriers;
  • автоматический Optimize после импорта;
  • изменение full-import semantics или новый слот Undo.

6. Канонический lineage id

6.1 Формат

Для namespace P новый id имеет вид:

P_<canonical-root>_<8 lowercase hex>

Где P — один из известных импортных namespace:

space, room, marker, partition, opening, decor, draft, column

canonical-root вычисляется только синтаксически: пока значение строго соответствует P_<stem>_<8 lowercase hex>, снимается один внешний слой того же P. Разбор ограничен 16 слоями. После этого root проходит существующую санитизацию и ограничение длины; пустой root заменяется безопасным namespace- специфичным stem.

Примеры:

Вход Namespace Root
f1 space f1
space_f1_a1b2c3d4 space f1
space_space_f1_a1b2c3d4_deadbeef space f1
room_kitchen_ab12 room room_kitchen_ab12
marker_room_x_deadbeef room marker_room_x_deadbeef

Нельзя снимать слой другого namespace, hash иной длины/регистра или похожий пользовательский suffix. Это не криптографическое доказательство происхождения, а только стабильная signature для уже известного формата импортных id.

6.2 Равенство lineage

Два id одного namespace принадлежат одному lineage, если их канонические roots равны. Равенство lineage разрешает remap только при одновременном выполнении всех условий:

  1. исходная ссылка мертва в текущей target-модели;
  2. среди живых кандидатов нужного типа существует ровно один совместимый lineage;
  3. тип владельца и поля разрешает такую цель по матрице §8;
  4. exact live id отсутствует;
  5. нет второго живого кандидата с тем же root.

При неоднозначности ссылка сохраняется буквально и отражается в preservedUnresolved; выбор по порядку массива запрещён.

6.3 Один алгоритм для Python и TypeScript

Backend import и frontend Optimize используют одинаковый conformance fixture с валидными, вложенными, ложнопохожими, слишком глубокими и unicode-случаями. Python и TypeScript могут иметь отдельные реализации, но CI требует одинаковый результат fixture. Это предотвращает расхождение импортного ремонта и Optimize.

7. Неизменяемый кандидат preview/apply

7.1 SpaceMergeCandidate

Первый успешный preview один раз строит серверный кандидат и сохраняет под одноразовым token:

  • нормализованные config и layout, готовые к записи;
  • полный id_map и lineage index;
  • структурированный report;
  • import policy/duplicate decisions;
  • source digest, canonical candidate digest;
  • ожидаемые config/layout revisions;
  • сведения о требуемом подтверждении detach/потерь по явной политике.

Digest вычисляется по canonical JSON candidate, а не только по исходному файлу. Token имеет существующие TTL, owner binding и общие count/size limits preview- хранилища. Candidate не возвращается клиенту целиком и не принимается обратно от клиента.

7.2 Apply

Apply под write_lock повторно проверяет token, owner, TTL, candidate digest, revision и обязательные подтверждения. После этого paired commit записывает именно сохранённые config и layout; второй вызов _fresh() или merge запрещён.

Если revisions изменились, Apply возвращает существующий конфликт. Revalidate строит новый candidate, новый digest/report и новые expected revisions. Любое ранее данное подтверждение относится только к старому candidate и сбрасывается. Пользователь снова видит новый preview до Apply.

Существующий Undo snapshot, attachment-detach, missing-plan preflight, permissions и recovery contract #50 сохраняются.

8. Матрица внутренних ссылок

Матрица является нормативной. Код может быть разделён по владельцам, но новые plan-id поля нельзя добавить без обновления матрицы и invariant tests.

Владелец / поле Цель Incoming copy Target dead-ref repair
spaces[].id space новый id —
rooms[].id room новый id —
rooms[].open_to[] room remap внутри candidate; внешнюю сохранить/отчёт exact/unique lineage
drafts[].id draft новый id —
partitions[].id partition новый id —
columns[].id column новый id —
openings[].id opening новый id —
openings[].host.id при partition-host partition remap; неразрешимое сохранить/отчёт exact/unique lineage
decor[].id decor новый id —
markers[].id marker новый id или duplicate-policy —
markers[].space space remap exact/unique lineage
markers[].room_id room remap exact/unique lineage
markers[].vacuum.segment_map.* room remap exact/unique lineage
markers[].controls[] со значением marker:<id> marker remap или drop по explicit link policy exact/unique lineage
derived-marker value_badge.ref типа marker marker remap или drop по explicit link policy exact/unique lineage
layout key <marker-id> marker remap exact/unique lineage
layout key rl_<room-id> room remap exact/unique lineage
layout position.s space remap exact/unique lineage

exact/unique lineage во второй колонке означает не только совпадение типа, но и сохранение локальной области владения. Ссылка на room или partition переписывается только если её владелец после того же remap находится в том же пространстве, что и новая цель. В частности, rooms[].open_to[] и openings[].host.id существующего независимого пространства нельзя направлять в новую импортированную копию: такая доказанно связанная, но пространственно несовместимая ссылка сохраняется без изменений и попадает в preservedUnresolved.

Следующие значения сохраняются буквально и не входят в plan-id lineage:

  • HA entity/device/area ids, включая entity controls, lg_<entity> и grp_<area>;
  • walls[*].key, wall interval coordinates, open_spans endpoints;
  • URL, filenames, icon ids, пользовательский текст и CSS-safe цвета.

До реализации перечень сверяется с актуальной validation schema и serializers. Обнаруженное plan-id поле добавляется в эту таблицу и тесты; молчаливое исключение запрещено.

9. Порядок remap и конфликты

  1. Валидировать и нормализовать source без мутации target.
  2. Построить index target по точному id, типу и canonical lineage.
  3. Зарезервировать новые ids для всех импортируемых владельцев.
  4. Применить duplicate marker policy #50.
  5. Переписать incoming refs по точному id_map.
  6. Переписать только мёртвые target refs: сначала exact old→new, затем единственный совместимый lineage.
  7. Обработать layout после marker duplicate policy.
  8. Провести invariant pass и сформировать report.
  9. Только после успешного pass создать preview token.

Живая target-ссылка всегда побеждает lineage и не переписывается. Target marker и tombstone records не переименовываются и не удаляются.

При layout collision destination record побеждает. Source record удаляется только когда доказано, что это тот же remapped owner; иначе оба состояния не сливаются молча, конфликт сохраняется в отчёте. Virtual copy, из которой duplicate-policy сняла свойства источника света, не может автоматически стать целью marker:* light link.

10. Структурированный отчёт

Backend возвращает стабильный report с агрегатами и ограниченными примерами:

remapped.incoming.<category>
remapped.target.<category>
collisions.<category>
preservedUnresolved.<category>
droppedIncomingLinks.<category>
boundedLineages

Для совместимости сохраняются существующие итоговые counters, включая repaired_target_refs и dropped_marker_links; они вычисляются из нового report, а не отдельной логикой. Примеры ограничены общим лимитом, сортируются детерминированно и не включают секреты или полные payload.

Import preview показывает:

  • количество восстановленных target-ссылок;
  • количество сохранённых неразрешимых ссылок с формулировкой «сохранены без изменений; после импорта запустите “Оптимизировать планы”»;
  • количество отброшенных incoming links по уже подтверждаемой explicit policy;
  • раскрываемые Details с ограниченным списком категорий/ids.

Ноль новых результатов не добавляет визуальный шум. Report preview и ответ Apply имеют одинаковый candidate digest и одинаковые counters.

Обязательные RU/EN i18n-ключи этой поверхности:

  • существующие backup.repaired_target_refs и backup.dropped_marker_links сохраняются;
  • backup.preserved_unresolved_refs — количество сохранённых неразрешимых ссылок;
  • backup.preserved_unresolved_hint — пояснение про сохранение данных и «Оптимизировать планы»;
  • backup.import_details — действие раскрытия подробностей;
  • backup.import_detail.incoming_remapped, backup.import_detail.target_repaired, backup.import_detail.collisions, backup.import_detail.dropped_links и backup.import_detail.bounded_lineages — подписи категорий.

Тексты реализуются синхронно в src/i18n/en.json и src/i18n/ru.json; raw key или техническое английское имя категории в пользовательский UI не попадает.

11. Инварианты и отказоустойчивость

Перед выдачей token candidate проходит те же backend validators, что Apply, и новый typed reference invariant. scripts/model-invariants.mjs получает независимые проверки:

  • активный marker.space указывает на живое пространство либо корректно отсутствует по контракту #244;
  • marker.room_id, vacuum.segment_map, rooms[].open_to указывают на комнату допустимого пространства;
  • partition opening host указывает на живую partition;
  • marker:* controls и derived marker badge ref указывают на допустимый marker;
  • layout owner/key и position.s согласованы;
  • существующие wall carrier/open-span инварианты продолжают проверяться отдельно и не проходят через lineage remap.

Для legacy unresolved записей применяется действующая fail-closed registry policy: invariant не должен внезапно сделать исторически читаемый config несохраняемым вне явно мигрируемых полей. Новые candidate-generated dangling refs считаются ошибкой и блокируют preview/apply.

Любая ошибка оставляет оба store без изменений. Report не является основанием для удаления. Повторный preview одного source и target семантически детерминирован: различаться может новый случайный suffix, но не root, матрица решений и counts. Один сохранённый candidate полностью детерминирован.

12. Совместимость и миграция

  • schema_version и model_version не меняются;
  • существующие nested ids остаются читаемыми и не переписываются фоново;
  • короткие ids появляются только у новых space-import candidates;
  • full export/import остаётся буквальным;
  • старые preview tokens, созданные до обновления backend, отклоняются обычным invalid/expired preview и требуют повторного preview;
  • Optimize использует lineage только в доказуемом repair path и остаётся идемпотентным после Save + reload.

13. Риски

Риск Последствие Снижение риска / обязательное доказательство
Ложное совпадение lineage пользовательских id связь переносится не к тому объекту strict parser, совпадение namespace/типа, только dead ref, unique candidate; ambiguity fixture
Расхождение Python import и TypeScript Optimize один путь снова создаёт долг другого общий conformance fixture и parity CI
Preview хранит крупный готовый candidate рост памяти backend действующие per-document, global-count и TTL limits; отказ до token при превышении модели
Новая матрица неполна часть ссылок остаётся dangling сверка validation/serializers, registry tests и мутанты каждой категории
Duplicate marker policy меняет допустимость light-link связь ведёт к virtual copy без семантики света duplicate policy выполняется до remap; positive/negative tests skip и virtual
Жёсткий invariant ломает legacy config старый план нельзя импортировать fail-closed registry применяется только к candidate-generated дефектам; legacy unresolved сохраняется и отчитывается
Revalidate незаметно меняет решение пользователь подтверждает не тот import новый digest/report, сброс подтверждений и повторный явный preview
Target-remap повреждает геометрию визуальная регрессия стен/проёмов wall keys/open spans исключены из registry; semantic preservation fixtures

14. Edge cases

  1. Пользовательский id случайно похож на import id — strict parser снимает только полный слой своего namespace с 8 lowercase hex.
  2. Более 16 вложенных слоёв — разбор прекращается, случай фиксируется в boundedLineages, import не зависает.
  3. Два живых кандидата одного lineage — ссылка не меняется.
  4. Exact id жив — ссылка не меняется, даже если lineage указывает на новый id.
  5. Target изменился после preview — Apply конфликтует, revalidate строит и показывает новый candidate.
  6. Source содержит dangling link за пределы экспортированного пространства — применяем явную policy поля: сохранить или drop с отчётом, но не угадывать.
  7. Duplicate HA binding — результат duplicate policy формируется до marker-link/layout remap.
  8. Tombstone участвует в duplicate detection по правилам #262, но не удаляется и не становится целью связи без допустимой семантики.
  9. Layout key collision — destination wins; никакого silent overwrite.
  10. Пустые/unicode/очень длинные roots — текущая sanitization и length limit, затем collision-safe suffix.
  11. walls[*].key текстово похож на id — остаётся неизменным.
  12. Кандидат превышает лимиты памяти/модели — preview отклоняется до token.

15. Acceptance criteria

AC1 — плоский lineage

Импорт исходного пространства, экспорт результата и повторный импорт создают ids с одним namespace-префиксом. Ни один новый id не содержит растущую цепочку space_space_, room_room_ и аналогичную для остальных namespace.

AC2 — безопасный cross-generation repair

Мёртвая target-ссылка на предыдущее поколение переносится к единственному новому совместимому id. Живая, неоднозначная или типово несовместимая ссылка остаётся без изменений и присутствует в report.

AC3 — полная матрица

Для каждой строки §8 есть positive и unresolved/conflict test. Geometry carrier fixtures доказывают byte/semantic preservation wall keys и open spans.

AC4 — точный preview

Preview и Apply используют один candidate digest. Apply не вызывает генерацию новых ids/merge. Revalidate меняет token/digest, сбрасывает подтверждение и возвращает новый report.

AC5 — lossless unresolved/tombstone

Unresolved refs, active marker records и tombstone records не удаляются. Изменения layout разрешены только действующими доказанными правилами #244/#252.

AC6 — invariant gate

Candidate-generated dangling refs блокируют preview. Shared lineage fixture даёт одинаковый результат Python/TypeScript. Независимый model-invariants script ловит мутант каждой новой категории.

AC7 — идемпотентность

После успешного import, Save/reload и Optimize второй Optimize не предлагает повторный repair/cleanup по тем же данным.

AC8 — регрессии

Проходят targeted regression tests #50/#244/#248/#252/#258/#262: permissions, revision conflict, candidate tamper, missing plan, duplicate marker policy, Undo/recovery и storage round-trip.

AC9 — производительность и безопасность

Index/remap линейны по числу owners + refs; нет полного декартова сравнения. Файл, candidate и examples под существующими лимитами; report не раскрывает секреты. Все import endpoints сохраняют may_write, owner-bound token и TTL.

AC10 — пользовательские артефакты

Обновлены перечисленные в §10 RU/EN i18n, docs/USER-GUIDE.md, docs/USER-GUIDE.ru.md, docs/CHANGELOG.md и docs/CHANGELOG.ru.md. Изменённый import preview покрыт canonical-doc screenshots и golden review.

15.1 Матрица доказательств

AC Обязательное доказательство до S7
AC1 Python unit/backend import-of-import для каждого namespace + shared lineage fixture
AC2 Backend positive/live/ambiguous/type-mismatch tests и regression #244
AC3 Parameterized unit/backend test каждой строки §8 + geometry preservation fixture
AC4 Backend preview/apply/revalidate tests с зафиксированным digest и мутантом повторного _fresh()
AC5 Backend active/tombstone/unresolved fixtures + regressions #252/#262
AC6 Python/TS fixture parity и model-invariants mutant fixture каждой новой категории
AC7 Frontend Optimize unit: first pass, Save/reload, zero-op second pass + regression #248
AC8 Targeted backend/frontend suites #50/#244/#248/#252/#258/#262; permissions/revision/tamper/recovery cases
AC9 Synthetic maximum-size backend test, owner/TTL/limit security tests и code review отсутствия O(n²) lookup
AC10 npm test (включая test/i18n.test.mjs), check-docs, RU/EN guide/changelog diff, targeted browser smoke и reviewed canonical golden import preview

16. План реализации

  1. Добавить shared lineage conformance fixture и чистые helpers Python/TS.
  2. Выделить typed reference registry/matrix и покрыть её unit tests.
  3. Перевести _fresh() на canonical root и построить lineage index target.
  4. Расширить incoming/target remap и structured report.
  5. Сделать preview token владельцем immutable SpaceMergeCandidate; убрать повторный merge из Apply, формализовать revalidate.
  6. Расширить backend candidate gate и model-invariants.mjs.
  7. Добавить UI/i18n report без нового modal.
  8. Обновить canonical docs/changelog и targeted browser/backend tests.

17. Проверки этапа реализации

До S7 обязательны:

  • Python import/export unit/backend tests;
  • frontend unit tests для lineage/reference repair;
  • model-invariants positive/mutant fixtures;
  • TypeScript typecheck;
  • production build;
  • targeted import browser/smoke и golden только при изменении preview UI;
  • git diff --check, docs link/check scripts.

Полный smoke/golden/performance прогон остаётся на пре-релизном цикле по каноническому процессу проекта.

18. Rollback

Кодовый rollback возвращает прежнее построение candidate и exact-map, не требуя миграции сохранённых данных: новые ids валидны для старого reader. Откат не переписывает уже созданные пространства. Пользовательский rollback конкретного Apply выполняется существующим одношаговым Undo #50.

19. Допущения

  • формат импортного suffix остаётся ровно 8 lowercase hex;
  • новые plan-id namespace добавляются только вместе с обновлением registry, conformance fixture и invariants;
  • lineage — консервативная подсказка для ремонта мёртвых ссылок, не идентичность пользовательского объекта и не основание для destructive cleanup.