Files
houseplan-card/docs/specs/500-config-adoption-boundary.md
T
claude[bot] 93c6c7551b fix(adoption): post-write callers skip their tail on a refused gate (#500 r1 M1)
`space/delete` (both runtimes), Optimize Undo and Import apply now treat
`asset-wait` like every reload path: nothing was adopted, so no toast, no
space switch, no history/undo reset — the dialog is released and the
scheduled reload owns the rest. Unit and smoke cover the refused branch for
all four paths; the spec's reactivity risk row states the real mechanism.

Issue: #500
User-Visible: no
2026-09-10 11:41:16 +00:00

378 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# #500 — Одна граница владения config/adoption
- **Issue:** https://github.com/Matysh/houseplan-card/issues/500
- **Тип / приоритет:** tech-debt / P3
- **Статус ТЗ:** готово к ревью (r3 — учтены High-1/High-2 r1 и High-3 r2)
- **Трек:** полный; §5 не проходит по сложности и риску (7/10), числу
поверхностей (7 модулей) и state-контракту revision+fingerprint, который
читают все feature-runtime и multi-client сценарии
- **Оценка:** пользовательская ценность 5/10; ценность для разработки 8/10;
сложность 7/10; риск 7/10
- **Класс:** A (`src/**`) — по прямому указанию владельца 2026-09-10
- **Связано:** #490 (lost-ACK recovery — четвёртый вызывающий seam), #493
(lifecycle generation панели — вторая граница из issue, здесь не трогается),
#491/#495 (backend-транзакции — не трогаются), #34 → #425 (umbrella не
возобновляется), #314 (rollback отклонённой записи), #368 (revision-less
write)
## 1. Пользовательский сценарий
Пользователь этого не наблюдает. Персона, ради которой существует граница, —
администратор дома, который правит план с двух клиентов (десктоп и планшет),
и домочадцы, чей View получает чужие правки живьём. Момент — любой приход
authoritative конфига в карточку: первая загрузка, реакция на `config_updated`,
recovery после потерянного ответа, удаление пространства, Optimize, копия
пространства, тёплый старт из кэша.
## 2. До и после
**До:** идентичность конфига (`rev`, fingerprint) и его тело хранятся в полях
хоста, которые пишут шесть модулей в шестнадцати местах; каждый новый путь
adoption сам вспоминает, что перед заменой структуры надо подготовить фон,
начать continuity-кандидата, а после — пересобрать устройства и кэш. Один из
четырёх существующих путей (`space/delete`) этого не делает и переписывает
ревизию из другого ответа.
**После:** ревизии, fingerprint и приём ответов сервера принадлежат одному
типизированному модулю; все четыре пути adoption проходят одну и ту же
последовательность; прямая запись идентичности вне модуля запрещена
lint-тестом. Для пользователя ничего не меняется, кроме одного: удаление
пространства при параллельной смене фона другим клиентом больше не даёт
переходного пустого фона.
## 3. Проблема
Материал: `origin/dev` = `a824acc1`.
1. Тройка `(_serverCfg, _cfgRev, _cfgContentFingerprint)` и пара
`(_layout, _layoutRev, _layoutContentFingerprint)` — поля хоста без
владельца. `_serverCfg =` — **18 присваиваний в 8 модулях**
(`houseplan-card.ts` 6, `houseplan-editor-runtime.ts` 6,
`plan-optimize-write.ts`, `serialized-write-queue.ts`,
`space-copy-runtime.ts`, `summary-panel-runtime-loaded.ts`,
`vacuum-calibration-write.ts:108`, `editors/vacuum-maps-section.ts:107`);
`_cfgRev =` — 6 в 4 модулях; `_layoutRev =` — 7 в 4; fingerprint config —
10 мест в 5 модулях, включая `_cacheSnapshot()`, который его попутно
пересчитывает; fingerprint layout — 5 мест.
2. Seam `_adoptStructuralResponses` (`houseplan-card.ts:4231`) имеет **семь**
вызывающих в двух профилях. *Reload* — три: `_loadFromServer` (4315),
`_reloadConfigOnly` (4496), summary recovery
(`summary-panel-runtime-loaded.ts:611`); пред-условия (сравнение
fingerprint → `_signer.prepareImage` → `_beginContinuityCandidate`) и
пост-шаги (`_syncDecorAssets`, `_adoptInitialSpace`,
`_resumePendingNavMode`, `_cacheSnapshot`, `_restoreZoom`,
`_regSignature = ''`, `_maybeRebuildDevices`) продублированы во всех трёх.
*Post-write* — четыре, каждый после собственной парной записи перечитывает
`config/get` + `layout/get`: `space/delete` в
`houseplan-onboarding-runtime.ts:476` **и его байт-в-байт дубликат** в
`houseplan-editor-runtime.ts:8693`, `plan/optimize_undo`
(`houseplan-editor-runtime.ts:9577`), `import/apply`
(`houseplan-editor-runtime.ts:9736`). Ни один из четырёх не проходит
гейт фона и continuity перед сменой структуры.
3. Оба `space/delete` после adoption перезаписывают `_cfgRev`/`_layoutRev`
ревизиями из ответа `space/delete` (`houseplan-onboarding-runtime.ts:477–478`,
`houseplan-editor-runtime.ts:8694–8695`) — второй писатель той же ревизии
после adoption. Тот же класс дефекта, что M1 r1 в #490, в двух копиях.
4. `SummaryPanelHost` экспортирует восемь внутренних шагов adoption как
публичный контракт (`summary-panel-host.ts:84–94`): любой следующий
feature-runtime получит тот же набор ножей и повторит #490 M1.
5. `houseplan-card.ts` — 13 699 строк при потолке 13 700 (`core-file-budget`).
## 4. Скоуп
1. Новый модуль `src/config-adoption.ts` — владелец идентичности config и
layout и единственная точка превращения ответа сервера в локальное
состояние (§6.1–6.2).
2. Единая гейт-последовательность adoption для всех **семи** путей в двух
профилях (§6.3); `_adoptStructuralResponses` становится внутренней деталью
модуля.
3. Хост оставляет `_serverCfg`, `_cfgRev`, `_cfgContentFingerprint`, `_layout`,
`_layoutRev`, `_layoutContentFingerprint` как **делегаты** к модулю
(чтение — всюду как раньше; запись — только через модуль).
4. Сужение `SummaryPanelHost` и host-контракта onboarding-runtime до одного
метода adoption вместо восьми шагов.
5. Lint-тест «идентичность пишет только модуль» по образцу
`single-source-numbers`; allowlist локальных замен `_serverCfg`/`_layout`
— зафиксированный список, только вниз. Vacuum-писатели
(`vacuum-calibration-write.ts`, `editors/vacuum-maps-section.ts`) переходят
на `beginOptimistic`/`stageLocalConfig`/`rollbackOptimistic` модуля вместе
с переносом `rollbackOptimistic` и в allowlist не входят.
6. Regression witness `space/delete` (оба входа) при параллельной смене
`plan_url` — до переноса; тесты модуля на синтетических ответах; мутанты
на защиты.
7. `docs/ARCHITECTURE.md` — абзац о границе; `core-file-budget` — храповик
вниз.
## 5. Не входит
- Feature lifecycle registry (вторая граница из issue): #493 дал панели
рабочий образец, открытого инцидента нет; отдельная задача при появлении
второго потребителя.
- Иммутабельность `_serverCfg` и перевод локальных правок редактора
(`{ ...cfg, settings }`, геометрические жесты in-place) на команды модуля —
это umbrella-rewrite (#34/#425). Локальные замены остаются в editor runtime
под allowlist.
- Backend: `websocket_api.py`, пары Optimize/Undo/Import (#491/#495).
- Изменение формата кэша localStorage `LS_CFG`, протокола WS, схемы конфига.
- Поведение `_sendConfigCandidate` при отсутствии `rev` в ответе (`rev + 1`)
— сохраняется как есть, централизуется и помечается долгом (§15).
- Любая правка UX, i18n, настроек, golden.
## 6. Контракт границы
### 6.1 Состояние и владение
```ts
// src/config-adoption.ts
export interface StructuralIdentity {
config: ServerConfig | null; // тело; ссылка та же, что видит редактор
configRev: number;
configFingerprint: string; // fingerprint принятого/отправленного тела
layout: DeviceLayout;
layoutRev: number;
layoutFingerprint: string;
}
```
Инварианты:
- **I1.** `configRev` и `layoutRev` меняются только тремя способами: adoption
authoritative ответа, приём ответа собственной записи, восстановление из
кэша. Никакой другой код их не присваивает (AC1).
- **I2.** Ревизия принимается только **вместе** с телом, к которому относится:
`adoptAuthoritative(cfgResp)` берёт `cfgResp.rev`; `acceptConfigWrite(candidate,
response)` берёт `response.rev` для `candidate`. Ревизия одного ответа не
прикладывается к телу другого (закрывает п.3 §3; AC4).
- **I3.** Одинаковый fingerprint при adoption — эхо: тело, история геометрии,
drag и viewport не трогаются, ревизия обновляется (как сегодня, AC3).
- **I4.** Ссылочная идентичность: модуль хранит тот объект, который получил,
без клонирования — редактор сравнивает `host._serverCfg === cfg`
(`houseplan-editor-runtime.ts:8099`) и мутирует тело in-place.
### 6.2 API модуля
```ts
export class ConfigAdoption implements StructuralIdentity {
/** Чистое сравнение без мутации: что изменится, если принять ответ. */
compare(cfgResp?: AuthoritativeConfigResponse, layResp?: LayoutResponse):
{ configChanged: boolean; layoutChanged: boolean; candidateConfig: ServerConfig | null };
/** Принять authoritative ответ(ы); побочные эффекты хоста — через callbacks. */
adoptAuthoritative(cfgResp, layResp?, layoutOverride?): { configChanged; layoutChanged };
/** После собственного config/set: fingerprint = candidate, rev = ответ. */
acceptConfigWrite(candidate: ServerConfig, response: { rev?: number }): void;
/** После layout/set или собственного layout-события. */
acceptLayoutWrite(layout: DeviceLayout, response: { rev?: number }): void;
/** После парной записи (plan/optimize, copy, import): оба тела + обе ревизии. */
acceptPairWrite(config, layout, response: { config_rev?; layout_rev? }): void;
/** Локальная замена тела редактором до записи; идентичность не меняется. */
stageLocalConfig(config: ServerConfig): void;
stageLocalLayout(layout: DeviceLayout): void;
/** Тёплый старт и кэш: формат LS_CFG неизменен. */
restoreCached(snapshot: CachedSnapshot): boolean;
snapshot(): CachedSnapshot;
/** Optimistic write (#314): захват и точный откат по rev+fingerprint. */
beginOptimistic(previous, attempted): OptimisticAttempt<ServerConfig>;
rollbackOptimistic(attempt): boolean;
}
```
Побочные эффекты хоста при смене структуры (очистка `_geometryHistory`,
`_devicePositionHistory`, `_cancelDeviceDrag`, `_pendingPhysicalWrites`,
`_clearRoomFocus`, `_cancelCameraTransition`, `_clearGeometryGesture`,
`_seedDecorStyle`, virtual lights, capabilities, `_continuity.note`) остаются в
хосте и вызываются модулем через типизированный `AdoptionHost`-интерфейс в
**том же порядке**, что сегодня в `_adoptStructuralResponses` — модуль владеет
идентичностью и последовательностью, не логикой этих подсистем.
### 6.3 Единая гейт-последовательность
```ts
export async function adoptAuthoritativeGated(host: AdoptionHost, input: {
cfgResp?: AuthoritativeConfigResponse; layResp?: LayoutResponse;
reason: 'structural-response' | 'config-reload' | 'summary-recovery'
| 'space-delete' | 'optimize-undo' | 'import-apply';
profile: 'reload' | 'post-write';
}): Promise<'adopted' | 'asset-wait'>;
```
Шаги 1–4 общие для обоих профилей, шаг 5 — по профилю. Профиль `reload` —
ровно как сегодня в `_reloadConfigOnly`/summary recovery:
1. `compare` → если структура не изменилась, шаги 2–3 пропускаются.
2. `await host._signer.prepareImage(hass, host._candidateBackdrop(candidate))`;
при отказе — `_continuity.note('asset-failed')`, `_scheduleLoadRetry(true)`,
возврат `'asset-wait'`, **ничего не адоптировано**.
3. Если `_continuity.hasCompleteFrame && state === 'steady'` —
`_beginContinuityCandidate(reason, true)`.
4. `adoptAuthoritative(...)`.
5. Пост-шаги профиля `reload`: `_syncDecorAssets(candidate)`
(fire-and-forget), `_adoptInitialSpace(_model, true)`,
`_resumePendingNavMode()`, `_cacheSnapshot()`, `_restoreZoom()` если
видимое пространство сменилось, `_regSignature = ''`,
`_maybeRebuildDevices()`.
Профиль `post-write` **пост-шагов не имеет**: последовательность
заканчивается шагом 4, а хвост каждого post-write вызывающего (очистки,
`_cfgEpoch++`, выбор пространства, `_cacheSnapshot`, toast) остаётся в
вызывающем **без изменений** — включая `_adoptInitialSpace(_model, true)`
в ветке `_hasFixedFloor` у `import/apply`
(`houseplan-editor-runtime.ts:9757`) и `_commitSpace(...)` у обоих
`space/delete` и у `import/apply` без фиксированного этажа. Общий хвост
у четырёх путей разный по составу и порядку; вычленять из него «общую
часть» значило бы менять порядок побочных эффектов, чего это ТЗ не делает.
Особенности вызывающих остаются снаружи и не размножаются:
`_loadFromServer` — флаги `_connectionWasLost`, `_serverStorage`, warm-viewport
логика `_warmVpArmed`, `_loadOk`, подписки; `_reloadConfigOnly` — continuity
кандидат `'config-reload'` **до** запроса и ветка `_cfgWriting`; summary —
`confirmedSummaryPanelWriteRecovery` и генерация; `space/delete` —
`_commitSpace` при удалении текущего пространства и toast; `optimize_undo` —
сброс `_canOptimizeUndo`/`_undoKind`, очистки историй, `_cfgEpoch++`, toast;
`import/apply` — очистки `_dirtyPos`/`_sentPos`/`_defPos`, снапшотов
устройств, `_signer.invalidate` + `_resign`, `_cfgEpoch++`, выбор
пространства (`_adoptInitialSpace` при `_hasFixedFloor`, иначе
`_commitSpace(nextSpace)`), `_cacheSnapshot`. Две копии
`space/delete` обязаны вызывать одну последовательность; слияние их в один
метод хоста допустимо, но не требуется этим ТЗ.
**Единственное намеренное изменение поведения — гейт на post-write путях.**
Все четыре post-write пути получают шаги 2–3 (готовность фона и continuity
перед сменой структуры), а оба `space/delete` перестают перезаписывать
`_cfgRev`/`_layoutRev` ревизиями из ответа `space/delete` — обе ревизии
берутся из адоптированных `config/get` и `layout/get` (I2). Backend отдаёт в
`space/delete` те же значения, что затем вернёт `get`, поэтому в штатном
случае числа совпадают; расходятся они только при параллельной записи между
`delete` и `get` — и тогда прежний код ставил ревизию **старше** тела, а
следующая запись получала ложный `conflict`. Для `optimize_undo` и
`import/apply` изменение сводится к ожиданию `prepareImage` при смене
`plan_url` (Undo/Import могут вернуть другой фон) — сегодня они адоптируют
структуру до подписания фона, тот же переходный дефект, что M1 #490.
### 6.4 Сужение публичных контрактов
`SummaryPanelHost` теряет `_beginContinuityCandidate`, `_adoptStructuralResponses`,
`_syncDecorAssets`, `_adoptInitialSpace`, `_resumePendingNavMode`,
`_restoreZoom`, `_cacheSnapshot`, `_candidateBackdrop`, `_scheduleLoadRetry`,
`_signer`, `_continuity` и получает один `_adoptAuthoritative(cfgResp, reason)`.
Host-интерфейсы onboarding-runtime и editor-runtime
(`houseplan-editor-runtime.ts:842`) теряют `_adoptStructuralResponses` и
получают тот же метод с профилем `post-write`. `plan-optimize-write.ts` и
`space-copy-runtime.ts` вместо пяти полей идентичности получают
`acceptPairWrite`. `serialized-write-queue.ts` сохраняет чистые
`enqueueSerializedWrite`/`optimisticAttempt`; `rollbackOptimistic` переезжает
в модуль (единственный писатель тела из отката), и вместе с ним на модуль
переходят все его вызывающие — editor runtime, `vacuum-calibration-write.ts`,
`editors/vacuum-maps-section.ts`.
## 7. UX
Нет изменений. Персоны View и киоск не затрагиваются; touch-контракт
(`docs/TOUCH-SUPPORT.md`) не меняется.
## 8. Модель данных и миграция
Нет. Формат `LS_CFG` (`config, rev, config_fingerprint, layout, layout_rev,
layout_fingerprint, virtual_lights`) сохраняется побайтово; старый кэш без
fingerprint восстанавливается с пересчётом, как сегодня. Схема конфига, WS
и `docs/CONFIG-COMPATIBILITY.md` — без изменений.
## 9. i18n
Нет новых ключей; `toast.cfg_reload_failed`, `toast.space_deleted`,
`toast.conflict` используются как прежде.
## 10. Критерии приёмки
| # | AC | Чем доказан | Чем краснеет |
|---|---|---|---|
| AC1 | Идентичность пишет только модуль: в `src/**` вне `config-adoption.ts` нет присваиваний `_cfgRev`, `_layoutRev`, `_cfgContentFingerprint`, `_layoutContentFingerprint` (в т.ч. через `host.`); присваивания тел `_serverCfg =`/`_layout =` вне модуля — только локальные замены до записи (staging) в allowlist `{ 'houseplan-editor-runtime.ts': N, 'houseplan-card.ts': M }` (размещение устройств), счётчики зафиксированы и могут только уменьшаться; во **всех остальных** файлах `src/**` — ноль, в том числе `plan-optimize-write.ts`, `serialized-write-queue.ts`, `space-copy-runtime.ts`, `summary-panel-runtime-loaded.ts`, `houseplan-onboarding-runtime.ts`, `vacuum-calibration-write.ts`, `editors/vacuum-maps-section.ts` (последние два — через `stageLocalConfig`/`beginOptimistic`/`rollbackOptimistic` модуля) | unit `test/config-adoption-ownership.test.mjs` (regex по `src/**`, по образцу `single-source-numbers`) | вернуть одно присваивание в `plan-optimize-write.ts` — тест красный; поднять счётчик allowlist без правки теста — красный |
| AC2 | Все **семь** путей adoption (§3 п.2) идут через `adoptAuthoritativeGated`; имя `_adoptStructuralResponses` не встречается в `src/**` вне `config-adoption.ts`; `SummaryPanelHost`, host-интерфейсы onboarding- и editor-runtime не содержат шагов §6.4 | unit (grep по `src/**` на отсутствие имён вне модуля) + `npm run typecheck` | оставить вызов `_adoptStructuralResponses` в любом из семи мест — красный |
| AC3 | Поведенческая нейтральность: таблица переходов модуля на синтетических ответах совпадает с сегодняшней — эхо (тот же fingerprint: тело и истории не тронуты, rev обновлён), смена config (истории очищены, fingerprint новый), только layout, ответ без `rev` (старый rev сохранён), `layoutOverride`, virtual lights, профиль `post-write` завершается шагом 4 и не выполняет пост-шагов профиля `reload` (хост-callbacks `_adoptInitialSpace`/`_resumePendingNavMode`/`_restoreZoom`/`_syncDecorAssets` не вызываются модулем); хвосты четырёх post-write вызывающих не меняются — сверка диффом на ревью; существующие свидетели зелёные без изменения ожиданий: `demo/smoke_summary_panel.mjs` (lost-ACK + параллельная правка + порядок `prepare → adopt`), `demo/smoke_ws_resilience.mjs`, `test/render-invalidation.test.mjs`, `test/serialized-write-queue.test.mjs`, `test/summary-panel-runtime.test.mjs`, `test/config-store.test.mjs` | unit `test/config-adoption.test.mjs` + перечисленные смоки/юниты | мутант «эхо тоже очищает историю» — unit красный; мутант «prepareImage не ждём» — `smoke_summary_panel` красный (`recoveryPreparesBackdropBeforeAdoption`, уже есть) |
| AC4 | Post-write пути: для каждого из четырёх (`space/delete` ×2 входа, `optimize_undo`, `import/apply`) при параллельной смене `plan_url` другим клиентом `prepareImage` вызывается **до** adoption; после обоих `space/delete` `configRev`/`layoutRev` равны ревизиям адоптированных `config/get`/`layout/get`, а не ответа `delete` | `demo/smoke_post_write_adoption.mjs` на production bundle: четыре сценария с synthetic HA, моки `prepareImage` и порядок вызовов (по образцу `recoveryPreparesBackdropBeforeAdoption`), для `space/delete` — оба входа (onboarding и editor runtime) и проверка ревизий; unit модуля «ревизия не берётся из чужого ответа» (I2) | вернуть `host._cfgRev = response.config_rev` в любой копии `delete` — смок и unit красные; убрать гейт в любом из четырёх — смок красный |
| AC5 | Тёплый старт: `snapshot()` → `restoreCached()` восстанавливает идентичность ровно (rev, оба fingerprint, layout, virtual lights); ключи `LS_CFG` неизменны; кэш без fingerprint восстанавливается с пересчётом | unit `test/config-adoption.test.mjs` (round-trip и фикстура старого кэша) | переименовать ключ или потерять `layout_rev` — красный |
| AC6 | Optimistic rollback (#314) через модуль: откат только при совпадении rev **и** fingerprint попытки; откат не меняет rev; конфликтный reload побеждает | `test/serialized-write-queue.test.mjs` перенесён/адаптирован + мутант | мутант «откат без проверки rev» — красный |
| AC7 | Бюджеты: `core-file-budget` для `houseplan-card.ts` опущен до нового размера (храповик вниз); общий бюджет initial View (`INITIAL_VIEW_GZIP_BUDGET`, 301 066 Б) **не поднят**; если факт выходит за полосу потолка, потолок перецентрируется по практике #485/#506 — с датированной записью причины и измеренного факта в `scripts/bundle-budget.mjs`, а рост называется в хендоффе; новых WS-запросов на путях adoption нет (те же `config/get`/`layout/get`, что сегодня) | `test/core-file-budget.test.mjs`, `npm run bundle:budget`, `test/bundle-assets.test.mjs` (полоса ±2000, запас ≥500 Б с обеих сторон); отсутствие новых `callWS` — ревью диффа | поднять `core-file-budget` или общий бюджет — красный (по правилу тестов); перецентровка без записи — находка ревью |
| AC8 | Документация: `docs/ARCHITECTURE.md` описывает границу (владелец, три способа смены ревизии, единая последовательность); changelog не трогается (`User-Visible: no`) | ревью кода | — |
## 11. План автотестов
- `test/config-adoption.test.mjs` — таблица переходов AC3, round-trip AC5,
I2/I4 (ссылка та же; ревизия не от чужого ответа), rollback AC6.
- `test/config-adoption-ownership.test.mjs` — lint AC1/AC2 по `src/**`.
- `demo/smoke_post_write_adoption.mjs` — AC4 на production bundle с
synthetic HA: второй клиент меняет `plan_url` между записью и `get`; четыре
сценария (`space/delete` из onboarding и из editor runtime, `optimize_undo`,
`import/apply`), порядок `prepare → adopt`, ревизии из `get`.
- Существующие: `smoke_summary_panel`, `smoke_ws_resilience`,
`render-invalidation`, `serialized-write-queue`, `summary-panel-runtime`,
`config-store`, `core-file-budget` — без изменения ожиданий,
кроме адаптации импорта в `serialized-write-queue.test.mjs`.
- Мутанты в `scripts/mutation-gate.mjs` (§2.7, защиты в продуктовом коде):
`config-adoption-echo-clears-history`, `config-adoption-rev-from-foreign-response`,
`config-adoption-rollback-ignores-rev`, `post-write-skips-asset-gate`
(снимает гейт в `post-write` профиле — смок AC4 красный на всех четырёх).
- Гейт по диффу: `typecheck`, `test`, `build`, `bundle:budget`, `no-new-any`;
смоки по `smoke-select` для `_serverCfg`/`_cfgRev`/`_adoptStructuralResponses`;
golden/perf — предрелизный набор (диффом не задеты: рендер не меняется).
## 12. Риски и защита
| Риск | Защита |
|---|---|
| Реактивность: `_serverCfg` и `_layout` — реактивные свойства Lit (`static properties`), и `willUpdate` держит на `changed.has('_serverCfg')` инвариант эпохи геометрии и инвалидацию render-lifecycle; замена тела внутри владельца минует аксессор | Владелец сообщает о каждой замене ссылки тела хуком `onBodyReplaced(field, previous)`, карточка подключает его к `requestUpdate(field, previous)` — ровно то, что делал аксессор; эхо и изменения только идентичности событие не порождают. Юнит на контракт в `test/config-adoption.test.mjs`; свидетель — `smoke_room_resize` (покраснел на первом кандидате именно из-за этого, r1 Validate) |
| Редактор сравнивает и мутирует тело по ссылке | I4 — без клонирования; unit на идентичность ссылки |
| Скрытый порядок побочных эффектов в `_adoptStructuralResponses` | Порядок переносится один в один, тест-таблица AC3 фиксирует наблюдаемые результаты; ревьюер сверяет диффом |
| Ребейз-конфликты в `houseplan-card.ts`/`houseplan-editor-runtime.ts` (горячие файлы) | Небольшие коммиты: (1) модуль + тесты, (2) хост-делегаты, (3) четыре пути, (4) сужение контрактов, (5) lint + бюджеты + docs; `scripts/rebase-on-dev.mjs` |
| Гейт на post-write путях — единственное изменение поведения | Собственный AC4 и смок на все четыре пути; зафиксировано в §6.3 как намеренное |
| Профиль `post-write` случайно получит пост-шаги `reload` (`_adoptInitialSpace`, `_restoreZoom`) или хвост вызывающего изменится при переносе | Профиль явный в сигнатуре и не имеет пост-шагов; таблица AC3 включает кейс «post-write завершается шагом 4»; хвосты вызывающих в диффе не меняются (ревью); смок AC4 проверяет сегодняшнее правило выбора пространства после Import: при `_hasFixedFloor` — `_adoptInitialSpace`, иначе целевое/прежнее пространство — а не его отсутствие |
| Windows-гейт владельца | Никаких новых spawn/путей; тесты — чистые Node |
## 13. Откат
Один revert коммитов ветки `issue/500-config-adoption-boundary`; данные,
кэш и протокол не менялись, откатывать нечего кроме кода. Флага Labs нет —
рефакторинг без пользовательской поверхности.
## 14. Release-артефакты
- Changelog RU/EN: **нет** (`User-Visible: no` на всех коммитах).
- Пользовательская документация: нет.
- Golden/скриншоты: нет (визуал не меняется).
- `docs/ARCHITECTURE.md`: абзац о границе (AC8).
- Performance: бюджеты по AC7 (`core-file-budget` вниз; потолок initial View
перецентрирован с записью, общий бюджет не тронут), без новых замеров.
- Security: нет.
## 15. Принято предположительно (техническое, менять свободно)
1. Имя и место модуля — `src/config-adoption.ts`, класс `ConfigAdoption`;
хост держит один экземпляр `_adoption` и делегаты-геттеры.
2. Побочные эффекты хоста при смене структуры остаются в хосте и вызываются
через `AdoptionHost` — модуль владеет идентичностью и порядком, не
подсистемами. Альтернатива «перенести очистки в модуль» отвергнута:
это тянет за собой историю геометрии, камеру и жесты (umbrella).
3. `_sendConfigCandidate` сохраняет `rev ?? rev + 1`; централизуется в
`acceptConfigWrite`, помечается `// TODO(#500-followup)`; отдельный issue
после этой задачи — угаданная ревизия при ответе без `rev` противоречит I2,
но менять её здесь значит менять поведение с backend-заглушками (демо).
4. Allowlist AC1: `houseplan-editor-runtime.ts` (замены `_serverCfg`/`_layout`
до записи) и `houseplan-card.ts` (staging `_layout` при размещении
устройств, `applyDevicePlacement`); начальные значения — фактические числа
после переноса, только вниз.
5. `_reloadLayoutOnly` (слияние `mine` поверх ответа) использует
`acceptLayoutWrite(merged, resp)` — семантика та же: тело слитое, ревизия
ответа; отдельного «adopt layout with override» не заводится.
6. Lifecycle registry не начинается даже частично: post-write пути берут
готовую последовательность, а не новый механизм регистрации.
8. Отказ гейта на post-write пути (`asset-wait`): вызывающий не выполняет
хвост (тост, выбор пространства, очистки), освобождает `busy` своего
диалога и выходит — как reload-пути; запланированный `_scheduleLoadRetry`
адоптирует структуру позже. Сервер операцию уже выполнил, локальное
состояние догоняет ретраем (ревью кода r1, M1).
7. Два профиля вместо одного: у `reload` пост-шаги общие и одинаковые в трёх
местах, у `post-write` хвосты разные (Import выбирает пространство по
`_hasFixedFloor`, Delete — `_commitSpace`, Undo — сброс undo-состояния),
поэтому `post-write` = только гейт + adoption, хвосты остаются на месте.
Склеивать профили значило бы менять поведение Import/Undo/Delete (выбор
пространства, viewport). Третьего профиля не заводится; новый вызывающий
с иным набором шагов — повод пересмотреть границу, а не добавить ветку.