docs(spec): define missing decor asset round-trip

Issue: #428
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-03 10:59:34 +03:00
parent bdf53dc482
commit bc090c0e40
2 changed files with 266 additions and 0 deletions
@@ -0,0 +1,265 @@
# ТЗ #428 — round-trip экспорта с отсутствующей картинкой декора
Issue: [#428](https://github.com/Matysh/houseplan-card/issues/428)
Статус документа: ТЗ на ревью.
Источник контракта: [ТЗ #51](051-custom-decor-images.md), раздел
«Import/export и совместимость», AC10 и AC11.
## Сценарий
1. В конфигурации пространства сохранён `decor`-объект `kind: image` с
корректным 64-символьным SHA-256 `asset_id`.
2. Соответствующего blob и metadata sidecar уже нет в
`<config>/houseplan/assets/`.
3. Пользователь экспортирует полный дом, одно пространство либо только
планировку, а затем пытается импортировать полученный JSON.
Сейчас exporter честно записывает для объекта `exists_at_export: false` и
`mime: null`, но importer требует MIME из белого списка для любой строки
`decor_asset`. Поэтому House Plan отклоняет весь собственный экспорт как
`invalid_content`, хотя #51 определяет missing asset как легальное,
восстанавливаемое состояние.
После исправления документ проходит preview, сообщает об отсутствующем файле,
требует действующее явное подтверждение и сохраняет image-объект вместе с его
геометрией как repair-placeholder. Остальной импорт не меняется.
## Что человек увидит до и после
| Состояние | Сейчас | После исправления |
|---|---|---|
| Файл картинки отсутствовал уже при экспорте | Импорт всего JSON завершается ошибкой | Preview открывается, показывает missing content и требует подтверждение |
| Пользователь подтверждает импорт без файла | До подтверждения невозможно дойти | Объект и его геометрия сохраняются; во View не рисуется, в Background editor доступен для замены |
| Файл с тем же exact hash уже есть на целевой системе | Документ всё равно отклоняется из-за `mime: null` | Локальный blob проверяется по SHA-256 и переиспользуется как `available` |
Нового диалога, текста ошибки или элемента управления нет.
## Подтверждённая причина
- `custom_components/houseplan/import_export.py::content_manifest()` получает
MIME из metadata sidecar либо расширения найденного blob. Если оба файла
отсутствуют, результат — `None`; флаг `exists_at_export` при этом равен
`False`.
- `_content_state()` повторно строит ожидаемые ссылки из payload, но затем
безусловно требует у supplied `decor_asset` MIME из множества
`image/png`, `image/jpeg`, `image/webp`, `image/svg+xml`.
- `test_issue_51_missing_decor_asset_stays_as_repairable_geometry` покрывает
только соседний случай: blob был у источника и потому MIME был известен, но
blob отсутствует на target.
## Скоуп
- ограниченно скорректировать валидацию `decor_asset` в `_content_state()`;
- сохранить строгую сверку manifest с image-ссылками, заново выведенными из
payload;
- покрыть настоящий round-trip «export при отсутствующем blob/sidecar → import
preview» и отрицательную матрицу manifest;
- уточнить контракт missing decor asset в `docs/CONFIG-COMPATIBILITY.md`,
`docs/USER-GUIDE.md` и `docs/USER-GUIDE.ru.md`;
- добавить пользовательскую запись в оба changelog.
## Не-скоуп
- встраивание blob/base64 в JSON;
- восстановление, загрузка, перенос или автоматическое удаление asset-файлов;
- угадывание MIME из `asset_id`: content-addressed id не содержит расширение;
- изменение формата export, `EXPORT_FORMAT_VERSION`, config/model schema либо
storage layout;
- изменение UI подтверждения, placeholder, счётчиков preview, delete/replace,
full/space/plan-only projection или политики внешних файлов;
- ослабление проверки любых manifest-строк, кроме строго описанного ниже
missing `decor_asset`.
## Контракт manifest и валидации
### 1. Канонический экспорт
Exporter продолжает выдавать одну extension-neutral строку `decor_asset` на
каждый image record. Поля `asset_id` и `hash` равны canonical lowercase SHA-256,
`exists_at_export` всегда имеет настоящий тип `bool`.
- Если verified source blob существует, `exists_at_export` равно `true`, а
`mime` обязательно входит в поддерживаемый белый список.
- Если verified source blob отсутствует либо не проходит exact hash,
`exists_at_export` равно `false`. `mime` может быть поддерживаемой строкой,
когда её сохранил валидный metadata sidecar, либо JSON `null`, когда MIME
достоверно неизвестен.
Exporter не восстанавливает MIME эвристикой и не добавляет bytes.
### 2. Допустимые строки при импорте
До определения локального состояния target importer проверяет supplied
`decor_asset` по следующей матрице:
| `exists_at_export` | `mime` | Результат |
|---|---|---|
| literal `true` | поддерживаемая строка | допустимо |
| literal `true` | отсутствует, `null` или неподдерживаемая строка | `ImportFailure("invalid_content")` |
| literal `false` | поддерживаемая строка | допустимо |
| literal `false` | отсутствует или `null` | допустимо: это исправляемый missing asset |
| literal `false` | неподдерживаемая непустая строка либо значение другого типа | `ImportFailure("invalid_content")` |
| поле отсутствует, `null`, `0`, `1`, строка, объект или массив | любое | `ImportFailure("invalid_content")` |
Во всех допустимых строках остаются обязательными:
- exact equality supplied `asset_id` и `hash` с SHA-256, выведенным из payload;
- exact identity строки (`kind`, `owner`, `owner_id`, `field`, `url`) и отсутствие
лишних/дублированных/пропущенных строк;
- повторная проверка target blob чтением bytes и сравнением SHA-256.
Поддерживаемый MIME — только `image/png`, `image/jpeg`, `image/webp` или
`image/svg+xml`. Неподдерживаемый указанный MIME нельзя маскировать
`exists_at_export: false`.
### 3. Локальное состояние target
Источник не определяет доступность на целевой системе:
- target blob с exact hash → `state: available`, `exists_on_target: true`,
отдельное подтверждение для этой строки не требуется;
- target blob отсутствует, нечитаем или hash не совпадает →
`state: missing_preserved`, `exists_on_target: false`, общий preview получает
`confirmation_required: true`;
- после подтверждения `_detach_missing()` не удаляет image record: `asset_id`,
geometry, opacity, flips и decor order сохраняются по контракту #51.
Значение `exists_at_export` в нормализованной preview-строке остаётся значением
из supplied manifest. `mime: null` не превращается в MIME найденного либо
предполагаемого файла и не становится authority.
## Совместимость и миграция
- Миграции config/model/storage нет.
- Номер export format не меняется: исправленный importer принимает ранее
сгенерированный самим House Plan v2 документ, который уже соответствовал
заявленному контракту `exists_at_export: false`.
- Все документы, которые принимались раньше, продолжают приниматься.
- Старый importer может по-прежнему отвергнуть такой JSON; исправление не может
сделать уже установленную старую версию совместимой вперёд.
- Отсутствующий `exists_at_export` не трактуется как legacy default: exporter
v2 всегда записывает поле, а fail-closed поведение защищает границу доверия.
## Безопасность и privacy
Manifest остаётся описательным, не авторитетным. Payload определяет полный набор
ссылок, а target bytes — фактическую доступность. Исключение для отсутствующего
MIME связано одновременно с exact image identity и literal false; оно не даёт
подсунуть внешний URL, пропустить ссылку, объявить другой hash/MIME или обойти
проверку файла. Новых данных в export и новых путей к файловой системе нет.
## Touch, accessibility, i18n и производительность
- Touch/View/kiosk не меняются: это backend round-trip до существующего preview.
- Новых текстов и ключей i18n нет; используются действующие missing-content
confirmation и repair-placeholder.
- На каждый asset остаются тот же один bounded поиск кандидатов и, при наличии
blob, один SHA-256 проход. Новых обходов, сетевых запросов и frontend bundle
кода нет; performance budgets не меняются.
## Затронутые файлы и модули
- `custom_components/houseplan/import_export.py` — bounded validation
`decor_asset` в `_content_state()`;
- `tests_backend/test_ha_import_export.py` — положительный round-trip и
отрицательная матрица;
- `docs/CONFIG-COMPATIBILITY.md` — точное значение missing MIME;
- `docs/USER-GUIDE.md`, `docs/USER-GUIDE.ru.md` — пользовательское правило
повторного экспорта/import missing image;
- `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md` — release note;
- этот файл и `docs/specs/README.md` — трассируемость ТЗ.
Frontend `src/**`, i18n JSON, version files, screenshots/golden и workflow не
затрагиваются.
## Критерии приёмки
- **AC1 — настоящий missing round-trip (backend).** Full export, созданный при
отсутствии blob и sidecar, содержит `exists_at_export: false, mime: null`;
его import preview не падает, выдаёт `missing_preserved` и требует
подтверждение.
- **AC2 — сохранение объекта (backend).** После подтверждённой подготовки
импорта image record сохраняет exact `asset_id`, geometry, opacity, flip flags
и decor order; `_detach_missing()` его не удаляет.
- **AC3 — три режима экспорта (backend).** Общая validation path доказана для
full, single-space и plan-only export: missing image row во всех режимах
импортируема по одному контракту, а projection/privacy каждого режима не
меняются.
- **AC4 — target reuse (backend).** Для строки
`exists_at_export: false, mime: null` existing target blob переиспользуется
только после exact SHA-256 проверки и получает `available` без ложного
missing confirmation.
- **AC5 — fail-closed MIME/flag (backend).** Параметризованный отрицательный
тест доказывает матрицу: null/omitted MIME допустим только при literal false;
unsupported non-null MIME отклоняется и при false; отсутствующий и любой
не-bool `exists_at_export` отклоняются.
- **AC6 — identity invariants (backend).** Mismatched `asset_id`/`hash`, лишняя,
пропущенная или дублированная manifest row по-прежнему дают
`invalid_content`; существующее покрытие остаётся зелёным.
- **AC7 — совместимость (backend + ревью кода).** Ранее допустимые строки
`exists_at_export: true` с поддерживаемым MIME и `false` с поддерживаемым MIME
не меняют результат; config/model/export version не повышается.
- **AC8 — документация и release (docs gate + ревью кода).** Оба User Guide и
compatibility doc описывают импортируемый missing round-trip; оба changelog
обновлены в том же `User-Visible: yes` implementation commit.
- **AC9 — гейты (commands + Linux CI).** В цикле реализации зелёные
`npm run typecheck`, `npm test`, `npm run build` и targeted backend tests;
полный HA harness остаётся каноническим Linux CI. Golden, smoke и performance
не требуются до команды на бету, поскольку визуальный/frontend output не
меняется.
## План автотестов
1. Добавить helper fixture с image record и отсутствующим source asset.
2. Параметризовать `kind=full`, `kind=space` и plan-only variant; создавать
документ через `create_export()`, не конструировать только вручную.
3. Для полного пути пропустить JSON через `create_preview()` на отдельном
пустом target root и проверить content state, confirmation и сохранённый
candidate payload.
4. Создать exact target blob без sidecar и доказать `available`; затем заменить
bytes и доказать `missing_preserved`.
5. Параметризовать `exists_at_export` и `mime` по таблице, включая Python
`False` отдельно от `0`, потому что `bool` — подкласс `int`.
6. Не заменять существующий тест #51: он остаётся регрессией для known MIME при
missing target.
## Release-артефакты
- В `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` — одна парная запись о том,
что повторно экспортированный план с отсутствующей пользовательской картинкой
снова импортируется с подтверждением и без потери объекта.
- В обоих User Guide — короткое уточнение рядом с missing image/import rules.
- В `docs/CONFIG-COMPATIBILITY.md` — нормативная truth table в компактной форме.
- Screenshots/golden не обновляются: видимый рендер и UI не меняются.
- Release/version/tag не входят в задачу; issue остаётся открытой в S8 до беты.
## Риски
1. **Слишком широкое ослабление manifest.** Снимается точной проверкой
`type(exists_at_export) is bool`, белым списком непустого MIME и неизменной
exact identity/hash validation.
2. **Python принимает `0` как `False`.** Проверка должна быть по типу и identity,
а тест содержит `0` и `1` как отрицательные значения.
3. **Source metadata становится authority.** Нельзя использовать supplied MIME
или availability для выбора target файла; target hash проверяется как раньше.
4. **Проверен helper, но не настоящий export/import.** AC1 и AC3 требуют
документы от `create_export()` и хотя бы один путь через `create_preview()`.
## Откат
Откат — revert implementation commit: importer снова потребует supported MIME
у каждой строки `decor_asset`. Данных и миграций откатывать не нужно; уже
импортированные image records остаются валидными по схеме #51. Цена отката —
возврат исходной невозможности импортировать собственный export с missing asset.
## Принятые предположения
- `exists_at_export: false` означает только подтверждённое exporter-ом отсутствие
verified source blob; оно не обещает отсутствие exact blob на target.
- `mime: null` — единственное корректное представление неизвестного MIME,
которое пишет текущий exporter; отсутствие ключа принимается эквивалентно
только в той же строго missing-ветке для устойчивости JSON producers.
- Восстановимый MIME нельзя получить из SHA-256 `asset_id` без blob/sidecar,
поэтому исправляется importer, а не вводится недостоверное значение exporter-а.
+1
View File
@@ -84,6 +84,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#314](https://github.com/Matysh/houseplan-card/issues/314) Атомарная запись v8 drafts и независимой геометрии | [314-v8-draft-write-regression.md](314-v8-draft-write-regression.md) |
| [#306](https://github.com/Matysh/houseplan-card/issues/306) Нулевые стены вместо виртуальных границ | [306-zero-thickness-walls.md](306-zero-thickness-walls.md) |
| [#348](https://github.com/Matysh/houseplan-card/issues/348) Полная немецкая локализация | [348-german-localization.md](348-german-localization.md) |
| [#428](https://github.com/Matysh/houseplan-card/issues/428) Round-trip экспорта с отсутствующей картинкой декора | [428-missing-decor-asset-roundtrip.md](428-missing-decor-asset-roundtrip.md) |
## P2