Files
houseplan-card/docs/specs/434-v171-polish-audit.md
T
Codex d4dd027b0a build: prepare v1.71.0-beta.2 candidate
Issue: #426
Issue: #427
Issue: #428
Issue: #431
Issue: #432
Issue: #434
User-Visible: no
2026-09-03 15:23:40 +03:00

522 lines
41 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.
# ТЗ #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><allowed extension>`, где 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 проверяет точный путь
`<validated asset_id><validated extension>`:
- 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`.
Успешно проверенный явный запрос удаляет:
- `<asset_id>.json`, если он существует независимо от валидности;
- каждый обычный файл `<asset_id><allowed extension>` из фиксированного
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`.