# ТЗ #434 — Полиш аудита v1.71.0-beta.1 - Issue: https://github.com/Matysh/houseplan-card/issues/434 - Приоритет: P3, `bug` / `polish` / `tests` - Маршрут: full; задача меняет Python- и TypeScript-продукт, хранение пользовательских файлов, rolling compatibility, асинхронный safety lifecycle и несколько независимых гейтов, поэтому не проходит лимит сложности и одной поверхности лёгкого трека - Связанные контракты: #51 (пользовательские изображения), #417 (ветви подтверждения опасных действий), #418/#423 (support preview), #419 (Area snapshot cleanup), #432 (resolve и целостность assets) ## Сценарий Home admin пользуется изображениями в Редакторе подложки и отдельной карточкой пространства, обновляет frontend и интеграцию через HACS, меняет язык либо отправляет обращение в поддержку. В редком аварийном состоянии Home Assistant может остановиться между записью blob и sidecar. Одновременно разработчик рассчитывает, что защитные тесты действительно краснеют при снятии проверок, а один зависший browser smoke не удерживает весь CI job до глобального timeout. ## Что человек увидит до и после **До:** аварийно оставшийся файл изображения не входит в лимит и не удаляется явным API-вызовом, повторная загрузка неверно выглядит как переиспользование; static card шумит ошибкой на старом backend, а восстановленное изображение может остаться `missing` до переподключения. В переходе языка подтверждение опасного действия может зависнуть или быть ошибочно отклонено, а невалидный ответ support preview удерживает временный слот десять минут. **После:** физический store честно учитывает аварийные blobs, повторная загрузка безопасно восстанавливает каталог, явное удаление действительно удаляет точный asset; обе карточки соблюдают capability и revision cache. Подтверждение всегда либо показано, либо немедленно и безопасно отменено, а неиспользованный support token отзывается. Видимые контролы, тексты и обычные успешные сценарии не меняются. ## Цель Закрыть девять подтверждённых разрывов аудита v1.71.0-beta.1 и закрепить для каждой защитной ветки отрицательный свидетель: 1. физический учёт и явное удаление orphan decor blobs; 2. capability guard в `houseplan-space-card`; 3. cache epoch resolve по ревизии config; 4. точный тест наличия blob для catalog row; 5. честный `reused` при восстановлении sidecar; 6. актуальный locale gate и симметричная отмена danger confirmation; 7. свидетель принадлежности кандидата текущему Area snapshot; 8. локальные timeout для German wait и каждого CI smoke-файла; 9. отзыв валидного support token из непринятого ответа. ## Подтверждённые причины 1. `read_catalog()` обходит только `*.json`, а `_read_catalog_row()` принимает строку лишь при существующем blob. Поэтому blob без корректного sidecar не виден ни каталогу, ни quota, а delete сначала ищет ту же невидимую строку. 2. `HpConfigSnapshot` не переносит `decor_assets_api`, и `_load()` static card безусловно вызывает `houseplan/assets/resolve`. 3. `resolveCache` хранит одну пару `набор id → Map` на connection и не включает принятую серверную ревизию config; `missing` тем самым становится бессрочным для прежнего набора id. 4. Тест с названием `test_catalog_ignores_missing_or_malformed_sidecars` создаёт blob для единственной валидной строки и не проверяет обратный случай «валидный sidecar, blob отсутствует». 5. Upload при уже существующем exact blob дописывает sidecar и возвращает `reused: true`, хотя до операции promoted catalog entry не существовала. 6. `_confirmDanger()` читает `_dangerConfirmLocaleGate` прошлого render. Между изменением языка и render это даёт ложное разрешение, а между завершением загрузки языка и render — ложный отказ. Переход в `warm/noChange` не отменяет уже открытый controller request и оставляет его в inert DOM. 7. Условие `snapshotBindings.has(binding)` в `resolveAreaSnapshotCleanup()` не имеет кейса, в котором все соседние проверки истинны, а binding отсутствует только в текущем snapshot. 8. `germanStarted` ограничен одной секундой, соседний `germanCompleted` ожидается без границы; CI запускает каждый `demo/smoke_*.mjs` простым `node`, а сама job `smoke` не имеет `timeout-minutes` и потому наследует 360-минутный default GitHub Actions. Единственные 20 минут в workflow относятся к другой job — `performance_smoke`. 9. `_buildSupportPreview()` извлекает token, но при невалидности другого поля бросает `support_rejected` до `_discardSupportPreview()`. ## Скоуп В скоупе: - независимый физический inventory promoted decor blobs для quota/delete; - восстановление missing/broken sidecar повторной exact загрузкой; - точная семантика `reused` для valid catalog hit и repaired orphan; - передача runtime capability в shared config snapshot и fail-closed guard static card; - revision-scoped positive/negative resolve cache в full и static card; - актуальная проверка locale readiness при каждом danger request и отмена открытого request при переходе в неотрисовываемый `warm`; - недостающие unit/backend/smoke cases и постоянные mutation witnesses; - bounded ожидание German route, per-file timeout и самостоятельный job timeout smoke-шарда в Validate; - best-effort discard каждого корректного support token, ответ которого не был принят в состояние диалога; - техническая документация и оба changelog. ## Не-скоуп - palette/файловый менеджер для отображения orphan blobs, автоматическая сборка мусора, age-based cleanup либо удаление по отсутствию ссылки; - восстановление metadata без повторной загрузки exact canonical bytes; - новый формат sidecar, asset id, URL, capability version, quota либо config; - повышение `decor_assets_api`, support protocol или export format; - изменение разрешений list/resolve/upload/delete и content GET; - изменение визуала decor image, static card, danger dialog или support form; - новое пользовательское сообщение о repaired orphan либо о техническом discard; - изменение двухподтверждённого Area-cleanup контракта #419; - полный рефакторинг LanguageRuntime, support pipeline или smoke sharding. ## Контракт поведения ### 1. Physical inventory и quota Promoted blob — обычный файл в decor-assets root с точным именем `<64 lowercase hex>`, где extension входит в действующий allowlist PNG/JPEG/WebP/SVG. Temporary `.asset-*`, sidecars, каталоги, prefix- совпадения и неизвестные расширения не являются promoted blobs. Quota до создания нового blob считается по физическому inventory, а не по доверенным sidecar rows: - file count — число promoted blob-файлов; - bytes — сумма их фактических `stat().st_size`; - blob учитывается даже при отсутствующем, битом, несовпавшем либо лживом sidecar и даже при неверном digest содержимого; - отсутствующий blob при сохранившемся sidecar не занимает file/byte quota; - каждый реально лежащий allowed-extension файл учитывается один раз; возможные несколько расширений одного hash — несколько физических файлов. Catalog/list/resolve при этом не становятся менее строгими: без корректного sidecar и существующего совпадающего blob orphan остаётся невидимым и не рендерится. Это разделяет доверенный каталог и физическое потребление диска. ### 2. Upload recovery и `reused` Под `upload_lock` backend до отказа по count/byte quota проверяет точный путь ``: - valid catalog row + подтвердившийся exact blob сохраняет прежний idempotent результат `reused: true` и не меняет имя/created_at; - exact blob без принимаемого catalog row хешируется и сравнивается с id; совпадение атомарно создаёт новый sidecar из текущей validated загрузки и возвращает `reused: false`, потому что promoted catalog entry создана этой операцией; - recovery не требует свободного file slot/bytes: физический blob уже вошёл в inventory и новых promoted bytes операция не добавляет; - несовпавший digest не перезаписывается и не удаляется по предположению; upload fail-closed возвращает действующую безопасную ошибку целостности; - broken/mismatched sidecar заменяется только при exact hash match того же id; ни один другой blob/sidecar не затрагивается. Обычная новая загрузка после recovery check применяет прежние 200 assets / 256 МиБ / low-disk границы и атомарную запись. Ошибка не оставляет новый blob, sidecar или temporary. ### 3. Явное удаление orphan `houseplan/assets/delete` по-прежнему требует write permission, корректный exact asset id и нулевой authoritative refcount под `write_lock + upload_lock`. Успешно проверенный явный запрос удаляет: - `.json`, если он существует независимо от валидности; - каждый обычный файл `` из фиксированного allowlist. Он не использует пользовательскую строку как glob, не удаляет directories, temporary, неизвестные расширения или prefix-совпадения. `removed: true`, если удалён хотя бы один exact sidecar/blob; `false`, если ни одного target не было. Ошибка `in_use` ничего не удаляет, даже если sidecar повреждён. Это не нарушает standing rule: причиной удаления является явное действие с точным asset id, а не вывод из отсутствия ссылок. ### 4. Capability static card и resolve cache `HpConfigSnapshot` получает runtime-only нормализованное поле capability. Только exact safe integer `decor_assets_api === 1` означает поддержку; отсутствующее, нецелое, иное либо malformed значение становится `null`. Каждый успешный fresh `config/get` авторитетен и может отозвать ранее принятую capability даже при неизменном config fingerprint. LocalStorage snapshot capability не доверяет и не превращает в persisted permission: до свежего ответа значение `null`. `houseplan-space-card`: - при exact capability вызывает resolve и рисует разрешённые assets; - без неё не вызывает `houseplan/assets/resolve`, очищает runtime map assets и использует прежнее fail-closed missing-поведение; - принимает capability-only изменение snapshot даже при неизменных config, layout и virtual-light fingerprints; - не показывает toast/raw WS error и не добавляет новый UI. `resolveDecorAssets()` получает обязательный opaque config epoch от вызывающей карточки. Для серверного snapshot epoch включает как минимум принятую числовую ревизию; full и static card передают ревизию одного и того же accepted config. Cache key равен `owner connection + epoch + sorted unique valid ids`: - одинаковые owner/epoch/id-set возвращают тот же `Map` без нового WS; - новый epoch вызывает новый resolve даже при том же id-set, включая повторную проверку прежних `missing`; - разные owner не делят cache; - failed call не кэшируется; - batching 200 и validation ответа не меняются. ### 5. Danger confirmation и текущий язык Решение о возможности открыть `hp-confirm` принимается по текущей конфигурации языка и текущему состоянию `LANGUAGE_RUNTIME` в момент request/update, а не по полю, записанному предыдущим render. - Если текущая ветка `warm → noChange` не может добавить confirmation, новый `_confirmDanger()` сразу возвращает `false`, ничего не регистрирует в controller и не ждёт следующего render. - Если язык уже перешёл из pending в ready/fallback до render, новый request не получает ложный отказ из-за прошлого `warm` и проходит обычный путь. - Уже открытый request при переходе в `warm` разрешается `false` до потери decision source. `hp-confirm` перестаёт быть действующим/доступным элементом, тогда как последний стабильный body сохраняется по семантике `noChange`, а host остаётся `inert` до готовности локали. - Onboarding, fixed-floor pending/invalid, lost-space guard, cold first-load и ready main branch сохраняют контракт #402/#417; количество подтверждений — не более одного, согласие никогда не переносится между языковыми состояниями. ### 6. Area snapshot negative witness Поведение #419 не меняется. Previous cleanup candidate переносится в следующий проход только если его binding одновременно валиден, revision конечна и binding присутствует в текущем `marker_area_snapshot`. Тест создаёт случай, где первые два условия истинны, registries допустимы, но snapshot binding отсутствует; кандидат обязан исчезнуть. Отдельная мутация снятия membership-check обязана покраснеть именно на этом assertion. ### 7. Bounded smoke execution - И `germanStarted`, и `germanCompleted` в `demo/smoke_danger_confirm_branches.mjs` ожидаются через локальную границу 1000 мс с разными диагностическими ошибками. Отсутствие completion не может превратить целевой smoke в бесконечное ожидание. - Validate smoke shard запускает каждый `demo/smoke_*.mjs` через GNU `timeout --kill-after=10s 180s`. Exit 124 считается обычным падением файла, его лог печатается и остальные назначенные shard-файлы продолжают выполняться; итог shard остаётся красным. - Job `smoke` получает собственный `timeout-minutes: 20`: это отдельная граница, которой сейчас нет, а значение соседней `performance_smoke` не считается её защитой. Детерминированное разбиение, browser install, exception guard и log artifacts не меняются. ### 8. Support preview token cleanup Любой ответ `houseplan/support/preview`, содержащий token формы ровно 48 lowercase hex, владеет временным backend slot. Если frontend не принимает этот ответ как текущий valid preview по любой причине, token best-effort отзывается ровно один раз через `houseplan/support/preview/discard`: - malformed hash/format/version/size/spaces/expires/text при валидном token; - ответ устаревшего generation/draft либо снятого consent; - отказ применить уже проверенный preview в текущее состояние. Malformed/отсутствующий token не отправляется в discard. Cleanup выполняется независимо от того, актуален ли уже UI request; только изменение видимого error state зависит от current generation. Ошибка discard не маскирует исходный `support_rejected`, не раскрывается пользователю и оставляет backend TTL последней защитой. Valid current preview, успешный submit и уже существующие close/uncheck paths не получают лишнего discard. ## Модель данных, API и совместимость - Persisted config/layout, model version, sidecar schema, asset id, export/import и support package не меняются; миграции нет. - `decor_assets_api: 1` уже существует в `config/get`; меняется только перенос и потребление существующей runtime capability в shared snapshot. - Новых WebSocket/HTTP endpoint и полей response нет. Уточняется смысл существующего upload `reused`: `true` означает, что valid catalog entry уже существовала до запроса; repaired orphan возвращает `false`. - Старый frontend игнорирует additive backend поведение. Новый frontend со старым backend fail-closed не вызывает неизвестный asset resolve из static card и продолжает рисовать остальные слои. - Orphan inventory вычисляется с диска и нигде не сохраняется. Repair sidecar использует действующую schema; downgrade не требует rollback данных. ## UX, accessibility, touch, kiosk и i18n - Новых кнопок, сообщений, focus targets, жестов, ARIA и переводов нет. - Full View, static View, kiosk, desktop и touch получают один capability/cache контракт; редактор остаётся desktop-first. - При несовместимом backend static card просто не рисует недоступное custom image, как для `missing`; остальные стены, комнаты, устройства и decor остаются видимы. - Danger confirmation сохраняет прежний текст, focus и alertdialog semantics; изменение только исключает зависший/устаревший dialog во время смены языка. - Support form сохраняет текущую ошибку `support_rejected`; cleanup не добавляет новый status либо toast. - Golden не требуется: ожидаемый визуальный кадр в устойчивых состояниях не меняется. Docs screenshots должны подтвердить нулевую pixel-дельту; при изменении `src/**` обновляется их source fingerprint по штатному workflow. ## Производительность, хранение и безопасность - Quota inventory — один bounded scan директории под уже существующим `upload_lock` на upload; `assets/list/resolve` не получают дополнительный полный scan. Стоимость O(число entries в asset root), физически ограниченном quota 200 promoted blobs плюс служебные файлы. - Повторный resolve в рамках одного config epoch остаётся O(1) cache hit; новый epoch делает не более одного batched WS-прохода на exact id-set. - Capability guard убирает заведомо отклоняемый WS-вызов на старом backend. - Per-file timeout ограничивает один smoke 180 секундами; самостоятельный `timeout-minutes: 20` ограничивает весь shard при системном зависании либо серии отдельных отказов. Это не меняет timeout соседней `performance_smoke`. - Quota не доверяет sidecar bytes/count, delete не использует glob и всегда повторно проверяет refs/permission. Digest-mismatch никогда не перезаписывается автоматически. - Support discard принимает только уже выданный token строгой формы и не добавляет данные к package/relay. Ошибка cleanup не раскрывает token, path или backend exception. ## Затронутые модули Ожидаемый набор; выделение чистых helpers допустимо без изменения контракта: - `custom_components/houseplan/decor_assets.py` — physical inventory/catalog seams; - `custom_components/houseplan/http_api.py` — quota и orphan upload recovery; - `custom_components/houseplan/websocket_api.py` — exact orphan delete; - `src/config-store.ts`, `src/space-card.ts`, `src/houseplan-card.ts`, `src/decor-assets.ts` — capability, epoch cache и danger lifecycle; - `src/houseplan-editor-runtime.ts` — support token cleanup; - `src/device-area-relocation.ts` меняется только если потребуется тестируемый seam; сам контракт cleanup не меняется; - `test/decor-assets.test.mjs`, `test/device-area-relocation.test.mjs`, новый либо существующий config/static-card contract test; - `tests_backend/test_decor_assets.py`, `tests_backend/test_ha_websocket.py` — inventory/upload/delete cases; - `demo/smoke_danger_confirm_branches.mjs`, `demo/smoke_support_feedback.mjs` — transition/cleanup integration; - `.github/workflows/validate.yml`, workflow contract test, `scripts/mutation-gate.mjs` — timeouts и постоянные свидетели; - `docs/ARCHITECTURE.md`, `docs/CONFIG-COMPATIBILITY.md`, `docs/TESTING.md`, `docs/SUPPORT-PRIVACY.md`, оба changelog и docs screenshot fingerprint. ## Критерии приёмки - **AC1 (backend/unit, storage).** Blob без sidecar и blob с malformed/ mismatched sidecar отсутствуют в catalog/list/resolve, но каждый входит в physical count и actual-byte quota; sidecar без blob не входит в catalog и не расходует blob quota. **Доказательство:** pure backend matrix с границами count/bytes ±1. - **AC2 (backend/HA, recovery).** Exact повторная загрузка valid orphan до проверки новой quota атомарно создаёт sidecar, возвращает `reused:false` и делает asset доступным list/resolve; последующая identical upload возвращает `reused:true`. Digest mismatch не меняет ни один файл. **Доказательство:** upload endpoint test с full-quota fixture и hash asserts. - **AC3 (backend/HA, explicit delete).** Writer удаляет unreferenced exact blobs всех allowed extensions и sidecar даже при missing/broken metadata; `removed` отражает наличие удалённого target. `in_use`, prefix, unknown ext, temp и directory остаются нетронутыми. **Доказательство:** WebSocket access / filesystem matrix. - **AC4 (backend/unit, regression).** Valid-shaped sidecar без matching blob отбрасывается общим catalog/direct-read validator; снятие `blob.is_file()` краснит точный тест. **Доказательство:** расширенный `test_catalog_ignores_missing_or_malformed_sidecars` + mutation gate. - **AC5 (unit/smoke, compatibility).** Fresh shared snapshot нормализует exact capability, localStorage seed имеет `null`; static card не вызывает resolve без exact v1, очищает asset map при downgrade и вызывает resolve после capability-only upgrade при том же config. **Доказательство:** shared-store unit + static-card network-counter smoke/contract. - **AC6 (unit, performance).** Одинаковые owner/epoch/sorted-id-set дают один resolve и один `Map`; при следующем config epoch тот же положительный либо negative-cached set вызывает новый resolve. Failed call не кэшируется, batching остаётся 200. **Доказательство:** `decor-assets` call-count matrix. - **AC7 (browser smoke, safety).** В обоих окнах ready→warm и warm→ready новый danger request принимает решение по текущему runtime; уже открытый dialog при переходе в warm резолвится `false`, исчезает как действующий decision source, controller пуст, stable body не заменён, host inert. Остальные ветви #417 остаются зелёными. **Доказательство:** расширенный branch smoke с bounded promise races и двумя отдельными mutants. - **AC8 (unit, regression).** Previous Area cleanup candidate, отсутствующий в текущем snapshot, не переносится при otherwise-valid данных; снятие `snapshotBindings.has(binding)` краснит только этот кейс. **Доказательство:** targeted unit + mutation gate. - **AC9 (unit/CI contract, liveness).** `germanCompleted` падает с собственной диагностикой не позднее 1000 мс; каждый CI smoke имеет 180-секундный TERM и 10-секундный KILL guard, timeout помечает shard красным, сохраняет лог и не пропускает последующие файлы; сама job `smoke` имеет независимый `timeout-minutes: 20`. **Доказательство:** completion-timeout probe + workflow unit с поочерёдно удалёнными per-file wrapper и job-level timeout. - **AC10 (browser smoke, privacy/lifecycle).** Valid token из invalid, stale или неприменённого preview response отзывается ровно один раз; malformed token не отзывается; current valid preview и success не получают преждевременный discard. UI сохраняет исходный error/current draft. **Доказательство:** support smoke с WS call counters + mutation gate. - **AC11 (review/docs, compatibility).** Нет schema/API version/i18n/visual изменений; standing no-inference deletion, write/ref guards и partial asset resolve сохранены. Architecture/compatibility/testing/privacy docs и оба changelog согласованы. **Доказательство:** diff review, docs checks и compatibility fixtures. - **AC12 (gates).** Typecheck, unit, build/bundle sync, selected frontend smokes, backend tests, no-new-any, docs, workflow contracts и все новые mutation witnesses зелёные на exact SHA. Golden и full performance остаются предрелизными, Linux CI — канон полного HA harness. ## Таблица защитных доказательств Точные имена тестов можно уточнить при реализации, но каждая строка сохраняет отдельный отрицательный witness по правилу #435. | AC | Чем доказан | Чем обязан покраснеть | |---|---|---| | AC1 | backend quota/catalog matrix | inventory снова строится из `read_catalog()` либо доверяет sidecar bytes; orphan не меняет count/bytes и boundary upload ложно проходит | | AC2 | HA upload recovery test | recovery выполняется после quota либо возвращает `reused:true`; full-quota repair отказывается или assertion response падает | | AC3 | HA delete filesystem matrix | delete снова зависит только от valid catalog row либо удаляет glob/prefix; orphan остаётся или sentinel исчезает | | AC4 | exact sidecar-without-blob test | mutation удаляет `blob.is_file()`; missing blob появляется в catalog | | AC5 | config-store/static-card call counters | mutation удаляет exact capability guard/revocation; старый backend получает resolve либо downgrade сохраняет map | | AC6 | resolve cache call-count unit | mutation удаляет epoch из key; второй epoch не делает WS и прежний missing остаётся | | AC7 | danger branch smoke | mutations возвращают cached render gate либо удаляют warm-transition cancel; promise зависает/ложно отклоняется или controller/DOM остаётся действующим | | AC8 | Area cleanup targeted unit | mutation удаляет `snapshotBindings.has(binding)`; отсутствующий binding переносится | | AC9 | timeout probe + workflow contract | mutation делает plain `await germanCompleted`, plain `node "$f"` либо удаляет `smoke.timeout-minutes`; probe превышает границу или contract assertion не находит одну из двух независимых границ | | AC10 | support smoke с exact discard counters | mutation переставляет validation throw до cleanup/удаляет cleanup; token не отзывается либо отзывается дважды | Для AC1–AC4/AC7/AC8/AC10, где полный backend/browser прогон дорог для ручного повтора, добавляются persistent entries в `scripts/mutation-gate.mjs`. Для AC5, AC6 и workflow contract допустим адресный unit red-run, если его команда и вывод внесены в code-review; пустого третьего столбца быть не может. ## План автотестов 1. Создать backend filesystem matrix: valid row, missing blob, missing sidecar, malformed/mismatched sidecar, wrong digest, duplicate allowed extensions, unknown extension, temp и directory; независимо проверить trusted catalog и physical inventory. 2. На count и byte limit проверить обычный отказ, exact orphan repair при уже полном store и честную последовательность `false → true` для `reused`. 3. Через HA WebSocket проверить delete для orphan/broken/valid metadata, writer/ read-only, in-use race и sentinels, которые не совпадают exact id+allowlist. 4. Расширить `decor-assets` unit матрицей owner × epoch × id order × missing × rejected call × 200/201 ids. 5. Проверить shared snapshot для fresh exact/missing/malformed capability, localStorage seed и capability-only upgrade/downgrade без смены config body. 6. В danger branch smoke удерживать locale request: отдельно открыть request до warm transition, вызвать request до следующего render после смены языка и завершить locale до следующего render. Все ожидания имеют локальный timeout. 7. Добавить exact Area candidate кейс и выполнить его с удалённым membership condition. 8. Добавить German completion timeout probe и статический workflow contract; timeout fixture обязан оставить следующий synthetic command выполненным, но итоговый status — failure; отдельная проверка отличает 20-минутный timeout `smoke` от одноимённого поля соседней `performance_smoke`. 9. В support smoke вернуть ответы с valid token и отдельно испорченными hash, format, size и current generation; посчитать discard по каждому token. 10. Запустить штатное дерево и каждый mutation witness: падение должно происходить по целевому assertion, а не по parse/import/внешнему глобальному timeout. ## Риски - **Физический scan примет посторонний файл за asset.** Смягчение: exact hash filename + фиксированный allowlist; это консервативно для quota, но никогда не делает файл доверенным catalog asset. - **Recovery перезапишет чужие bytes.** Смягчение: exact path и полный SHA-256; mismatch только отказывает и требует явного delete. - **Delete расширит область удаления.** Смягчение: fixed paths из allowlist, no glob, permission/ref locks и sentinel tests. - **Capability-only snapshot потеряется из-за fingerprint optimization.** Смягчение: capability включена в adoption decision и имеет отдельный тест без config/layout delta. - **Danger fix нарушит `noChange` и мигнёт stable body.** Смягчение: smoke сравнивает body identity/содержимое отдельно от удаляемого confirm outlet. - **Per-file timeout окажется слишком коротким на cold runner.** Смягчение: 180 секунд значительно выше обычного отдельного smoke, job получает отдельные 20 минут на установку и весь shard; timeout печатает точное имя и лог для пересмотра числа. - **Best-effort discard сам упадёт.** Смягчение: исходная ошибка остаётся основной, TTL backend сохраняется как финальная защита. ## Откат Откат — единый revert implementation commit. Persisted schema и migration отсутствуют. Уже восстановленные valid sidecars остаются обычными корректными catalog rows и безопасны для старой версии. Возврат старой quota/cache/confirm/ cleanup логики допустим только вместе с возвратом соответствующих тестов и changelog; пользовательские blobs при откате автоматически не удаляются. ## Release-артефакты - `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`: orphan recovery/quota/delete, static capability/cache retry и безопасные async cleanup fixes; - `docs/ARCHITECTURE.md`: trusted catalog против physical inventory и asset recovery/delete boundaries; - `docs/CONFIG-COMPATIBILITY.md`: runtime-only capability static card и cache epoch без повышения API; - `docs/TESTING.md`: 180-секундный per-smoke guard и negative witness policy; - `docs/SUPPORT-PRIVACY.md`: token из непринятого ответа также отзывается; - `scripts/mutation-gate.mjs`: постоянные backend/browser witnesses; - docs screenshot workflow: подтверждение нулевой pixel-дельты и обновлённый fingerprint из-за `src/**`; - i18n, user guide, golden baselines и performance profiles: без изменений; - implementation commit — `User-Visible: yes`, оба changelog в том же коммите. ## Принятые технические предположения Эти решения не меняют продуктовый замысел и могут быть свободно скорректированы ревьюером до S5: - orphan остаётся невидимым в palette до exact повторной загрузки: без sidecar нельзя достоверно показать имя, MIME и размеры; - quota считает фактические allowed-extension files, а не уникальные hash: каждое физическое потребление диска должно быть ограничено; - broken sidecar repair получает metadata/имя/created_at текущей загрузки и `reused:false`; прежним недоверенным полям sidecar не следуем; - explicit delete по hash охватывает все allowed extensions этого exact hash, потому что пользователь назвал content identity, а не одно недостоверное расширение из sidecar; - config cache epoch передаётся явно в `resolveDecorAssets`, а capability в localStorage не сохраняется; свежий server response — единственный authority; - 1000 мс для route-completion, 180 секунд + 10 секунд kill grace для файла и 20 минут для job `smoke` — технические liveness budgets, не пользовательские таймауты; - ссылки на строки из аудита ориентировочны: реализация привязывается к символам и проверяемому поведению на актуальном `dev`.