# ТЗ #423 — Полиш support pipeline и защитных инструментов v1.70.0 - Issue: https://github.com/Matysh/houseplan-card/issues/423 - Приоритет: P3, `tech-debt` / `polish` - Маршрут: full; затронуты backend, frontend UX/i18n, transport, bundle graph и Playwright tooling, поэтому задача не проходит критерий одной поверхности лёгкого трека - Связанные контракты: #43 (Help & feedback), #352/#367 (bundle ownership и бюджет), #404/#407/#421 (browser exception guard), #422 (docs screenshot gate) ## Сценарий Пользователь обновляет карточку и интеграцию House Plan через HACS. Браузер ещё может держать предыдущий bundle, либо одна из двух частей обновляется раньше другой. Пользователь открывает «Помощь и обратную связь», пишет сообщение и при желании прикладывает обезличенный пакет. Одновременно разработчик должен иметь честные сведения о Repair families, дешёвый отказ при исчерпанном лимите preview, безопасное имя файла, контролируемый initial bundle и benchmark, который не пропускает browser exception. ## Что человек увидит до и после **До:** при любом несовпадении номера релиза форма полностью скрывается, даже если обе стороны поддерживают один и тот же support API. Скачанный JSON и multipart-вложение названы префиксом живого preview-token. Остальные дефекты почти не видны напрямую: новый тип Repair может отсутствовать в пакете, а запрос с исчерпанным лимитом сначала зря строит пакет до 8 МиБ. **После:** форма доступна при совместимом `support_api`, независимо от равенства номеров релиза. Старый backend без capability по-прежнему fail-closed показывает предложение обновиться. И ручное скачивание, и relay attachment получают одно имя по короткому префиксу SHA-256, без части capability-token. Тексты, поля, согласие на вложение и отправка внешне не меняются. ## Цель Закрыть шесть подтверждённых разрывов аудита v1.70.0 без изменения support package v1 и без расширения передаваемых приватных данных: 1. перечислять все активные стабильные семейства House Plan Repair; 2. не использовать capability-token в имени файла; 3. отклонять очевидно лишний preview до дорогой сборки; 4. согласовывать форму по версии support API, а не версии релиза; 5. вынести form-only локализации из initial View graph; 6. подключить backdrop decode benchmark к общему browser-error guard. ## Проблема и подтверждённые причины ### 1. Repair families `_support_repairs()` сейчас анализирует только `issue_id.startswith("broken_plan_")` и жёстко выдаёт `code: "broken_plan"`. Реестр уже хранит безопасный стабильный `translation_key`; именно он описывает семейство, тогда как issue id содержит идентичность конкретного экземпляра и может включать приватный placeholder. ### 2. Filename `ws_support_submit()` передаёт preview-token в `filename_token`, а `support_transport.py` пишет первые 32 символа в multipart filename. Frontend использует первые 12 символов того же token при Download JSON. Полный token не раскрывается, но имя без необходимости основано на bearer capability. ### 3. Preview quota `ws_support_preview()` захватывает копии store и исполняет `_build_snapshot()` до prune/count. Только после потенциальной валидации, псевдонимизации и сериализации 8 МиБ приходит `support_rate_limited`. ### 4. Compatibility `_buildSupportPreview()`, `_submitSupport()` и `_renderSupportDialog()` требуют `_haIntegrationVersion === CARD_VERSION`. Номер релиза является диагностикой, но не версией протокола. Владелец выбрал явную capability `support_api: 1`. ### 5. Lazy support copy 43 английских `support.*` и соответствующие русские строки входят в общий initial chunk через eager locale dictionaries. Только `support.title` нужен карточке до загрузки editor runtime; вся остальная форма уже lazy. ### 6. Backdrop benchmark `demo/benchmark_backdrop_decode.mjs` создаёт `page` напрямую и не вызывает ни `watchPage()`, ни `reportPageErrors()`. Необработанный `pageerror` может попасть в лог, но оставить exit code нулевым. ## Скоуп В скоупе: - безопасная агрегация Repair issues по `translation_key`; - одинаковый short-id из SHA-256 для browser download и multipart filename; - quota preflight до store copy/build и повторная проверка после асинхронной сборки; - top-level capability `support_api` в ответе `houseplan/config/get`; - component-memory состояние capability и единый чистый predicate совместимости; - lazy support dictionaries для RU/EN/DE/FR с прежними значениями строк; - bundle ownership test и зафиксированное уменьшение initial View gzip; - подключение backdrop benchmark к `watchPage()` и `reportPageErrors()`; - unit/backend/smoke/contract tests, User Guide и changelog RU/EN. ## Не-скоуп - изменение endpoint relay, consent, retention, формы, package schema или набора приватных данных; - изменение лимитов `3/3`, TTL 10 минут или replacement semantics; - отправка списка raw Repair ids, placeholders, titles или exception text; - новый negotiation endpoint или пробный submit ради определения capability; - ленивый вынос всей RU/EN локали: переносится только семейство support form; - изменение screenshot capture workflow: неверный комментарий и межпрогонный гейт уже принадлежат #422; - превращение backdrop benchmark в обязательный CI performance gate. ## UX - About и Guide доступны всегда, как сейчас. - Форма, checkbox и Send доступны, когда `support_api === 1`. - Отсутствующее, нецелое, нулевое или неизвестное значение capability считается несовместимым. UI показывает локализованное сообщение об обновлении до совместимых версий, не технический номер API. - Несовпадающие card/integration release versions сами по себе больше ничего не скрывают и не блокируют. - Никаких новых действий, переключателей, toast, фокусов и состояний загрузки. - Download JSON сохраняет те же bytes, но имя становится `houseplan-support-{sha256[0:12]}.json`. ## Контракт реализации ### 1. Safe Repair family Для каждой активной записи реестра с `domain === houseplan` берётся `translation_key` объекта issue. Значение включается только если это строка, соответствующая `^[a-z][a-z0-9_]{0,63}$`. Записи без безопасного ключа пропускаются fail-closed. Одинаковые ключи агрегируются, результат сортируется по `code` и имеет прежний вид `{ "code": string, "count": positive integer }`. Ни `issue_id`, ни translation placeholders, ни display text в package не попадают. Два `broken_plan_` по-прежнему дают `{code: "broken_plan", count: 2}`; будущее семейство с другим id автоматически попадает под своим translation key. ### 2. Filename short-id Short-id равен первым 12 lowercase hex символам уже рассчитанного SHA-256 exact attachment bytes. SHA уже входит в preview и request metadata; отдельный random id и новое поле WebSocket ответа не нужны. Благодаря случайному namespace псевдонимов новый preview имеет новые bytes/hash даже для того же плана. - frontend Download использует `preview.sha256.slice(0, 12)`; - backend transport строит имя только из валидированного `attachment_sha256[:12]`; - parameter `filename_token` удаляется из transport API; - при `attachment is None` filename не создаётся; - browser и relay получают одинаковое имя. ### 3. Двухфазная quota-проверка Один helper проверяет вместимость preview-map для `(owner, draft_id)`: 1. prune expired records по одному captured monotonic `now`; 2. при подсчёте временно исключить старую запись того же owner/draft, потому что успешный refresh заменит её; 3. проверить per-user и total limits; 4. при отказе отправить `support_rate_limited` до чтения store, deep copy и `async_add_executor_job(_build_snapshot)`. Старый preview текущего draft на preflight не удаляется: неудачная новая сборка не должна уничтожать пригодный снимок. После возврата executor выполняются новый prune и тот же capacity check, потому что во время `await` другой запрос мог занять слот. Только после успешной второй проверки старый token того же draft удаляется и новый record записывается без следующего `await`. ### 4. Версия support API Текущий backend добавляет в каждый полный и projected ответ `houseplan/config/get` поле: ```json { "support_api": 1 } ``` Поле — runtime capability, а не persisted config. Миграции нет. Карточка на каждом успешном `config/get` нормализует значение: только safe integer `1` считается поддерживаемым; отсутствие/другая версия сбрасывает прошлое значение, чтобы downgrade backend не оставил stale permission. Чистый helper `supportApiCompatible(value)` используется всеми тремя путями: render, preview build и submit. Release `integration_version` сохраняется для диагностики и других функций, но больше не участвует в support form gate. Добавочные изменения протокола остаются в API v1; breaking change получает новый номер и старый frontend fail-closed. ### 5. Lazy support dictionaries В основных `src/i18n/{en,ru,de,fr}.json` остаётся `support.title`, потому что header action существует до загрузки editor runtime. Остальные `support.*` переносятся без изменения текста и placeholders в отдельные словари, импортируемые только графом `houseplan-editor-runtime`. Lazy helper: - типизирует собственный набор support keys; - выбирает точный словарь RU/EN/DE/FR и синхронно fallback-ит в English; - использует общий `subst` для placeholders; - не мутирует глобальный LanguageRuntime и не создаёт дополнительной сетевой загрузки после загрузки editor runtime. Общие i18n tests отдельно доказывают равенство ключей/placeholders основного и support-наборов во всех четырёх языках. Dead-key scan рассматривает оба английских словаря. Production build обязан показать, что form-only English/Russian marker strings отсутствуют в `initialViewFiles` и присутствуют в `lazyEditorFiles`. Baseline для сравнения берётся из чистого `dev` на начале задачи: **291046 B gzip initial View**. После реализации значение должно быть строго меньше baseline; увеличение бюджета `300000` или простая рекалибровка запрещены. ### 6. Browser error guard benchmark Benchmark оборачивает созданную страницу штатным `watchPage()`. После всех cases, но до `browser.close()`, он вызывает `await reportPageErrors()`; при true завершает сценарий ненулевым кодом без печати ложного success. Синхронные ошибки отдельных слишком больших cases продолжают печататься как измерительный `FAIL` и не превращаются автоматически в crash всего benchmark: предмет guard — только необработанный page error. Contract test охватывает все `demo/benchmark_*.mjs`, которые создают Playwright page: такая страница должна пройти через `watchPage`, а скрипт — запросить `reportPageErrors` или `finish`. Отрицательная проверка удаляет один из этих вызовов и обязана краснеть. Для динамического доказательства сам benchmark получает служебный `--guard-probe`: режим не запускает тяжёлую decode-матрицу, создаёт контролируемый tail `pageerror`, проходит через тот же финальный verdict и обязан завершиться кодом 1 с сообщением общего guard. Обычный запуск без флага не меняет cases или формат строк измерений. ## Модель данных, API и миграция - Persisted config/layout, model version, import/export и support package v1 не меняются. - `support_api` — новое additive top-level поле response `config/get`; старые клиенты его игнорируют, старые backends не присылают. - `_haSupportApi` живёт только в памяти экземпляра карточки, в cache/localStorage не записывается. - Preview record сохраняет прежние bytes/hash/versions. Нового filename id в record нет. - Relay request JSON и validation schema не меняются; меняется только безопасное multipart filename. ## i18n Видимый текст `support.update_required` обновляется во всех RU/EN/DE/FR: вместо требования одинакового номера релиза он просит обновить карточку и интеграцию до совместимых версий. Остальные строки переносятся побайтово без правки смысла. Основные ключи: - `support.title` — остаётся в core dictionary; - `support.update_required` — lazy, новый смысл compatibility; - все остальные `support.*` — lazy, значения без изменения. ## Accessibility, touch и kiosk Разметка dialog, порядок tab, label/aria-live, focus, 44×44 targets и responsive layout не меняются. Touch, desktop и keyboard используют один compatibility predicate. Kiosk по-прежнему скрывает Help action. Вынос словарей не допускает пустого текста или English flash: editor runtime и его словари загружаются одной lazy graph dependency до открытия dialog. ## Security и privacy - Capability token остаётся только в WebSocket payload/runtime-map и никогда не входит в filename. - SHA short-id не добавляет новую информацию: полный SHA уже явно показан пользователю и отправляется relay для проверки bytes. - Repair aggregation принимает только стабильный безопасный translation key; сырые ids/placeholders исключены тестом. - Capability negotiation не вызывает relay и не ослабляет HA `may_write`. - Quota preflight уменьшает доступную злоумышленнику дорогую работу; финальная проверка сохраняет ограничение при конкурентных запросах. ## Производительность - Исчерпанная preview quota отклоняется до deep copy/schema/projection/JSON. - Обычный допустимый preview получает два линейных прохода по максимум трём records; стоимость ничтожна относительно snapshot build. - Initial View gzip обязан уменьшиться относительно 291046 B. Lazy editor может вырасти на перенесённые строки; суммарные bytes допустимы, потому что платит только открывающий редактор/Help пользователь. - Backdrop benchmark не становится CI gate и не меняет измеряемую матрицу; один финальный browser round-trip не входит в case timing. ## Затрагиваемые файлы Ожидаемый набор: - `custom_components/houseplan/websocket_api.py`; - `custom_components/houseplan/support_transport.py`; - `tests_backend/test_ha_websocket.py`; - `tests_backend/test_ha_support_transport.py`; - `src/houseplan-card.ts`, `src/houseplan-editor-runtime.ts`; - `src/support-feedback.ts` либо отдельный compatibility helper; - `src/i18n/{en,ru,de,fr}.json` и новые lazy support dictionaries/helper; - `test/support-feedback.test.mjs`, `test/i18n.test.mjs`, `test/i18n-dead-keys.test.mjs`, `test/bundle-assets.test.mjs`; - `demo/smoke_support_feedback.mjs`; - `demo/benchmark_backdrop_decode.mjs` и его contract test; - `docs/USER-GUIDE.md`, `docs/USER-GUIDE.ru.md`; - `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`. `.github/workflows/docs-screenshots.yml` не меняется в #423. ## Критерии приёмки - **AC1.** Два House Plan issues одного безопасного `translation_key` дают одну family/count; другое безопасное семейство попадает автоматически; foreign domain и unsafe/missing key не попадают, raw ids/placeholders отсутствуют. Доказательство: backend unit/HA test + code review. - **AC2.** Browser download и relay multipart используют exact filename `houseplan-support-{sha256[0:12]}.json`; capability-token отсутствует в имени, bytes/hash не меняются. Доказательство: frontend smoke + transport test. - **AC3.** При исчерпанной quota без replaceable token ответ `support_rate_limited` приходит без store loads/executor build; refresh своего draft имеет право заменить token и не удаляет старый при build failure. Доказательство: HA backend tests. - **AC4.** Если слот занят конкурентно во время snapshot `await`, финальная проверка не превышает лимит и не удаляет прежний token текущего draft. Доказательство: управляемый backend test. - **AC5.** `config/get` всегда возвращает `support_api: 1`, включая projection; поле не хранится в config. Старый backend без поля остаётся совместим с frontend load, но support form fail-closed. Доказательство: backend + unit. - **AC6.** Support form/preview/submit работают при разных release versions и `support_api === 1`; missing, invalid, 0 или 2 блокируют все три пути одним predicate. Доказательство: unit + `smoke_support_feedback.mjs`. - **AC7.** Все RU/EN/DE/FR support keys непусты, имеют одинаковый key/placeholder set и отображают прежний текст, кроме согласованного compatibility message. Core содержит только `support.title`; form-only strings принадлежат lazy editor graph. Доказательство: i18n + bundle ownership tests. - **AC8.** Production build имеет initial View gzip строго меньше baseline 291046 B без изменения budget; Help dialog на четырёх языках остаётся полным. Доказательство: build manifest + smoke/code review. - **AC9.** Backdrop benchmark регистрирует page через `watchPage`, запрашивает финальный browser-error verdict и в `--guard-probe` выходит ненулевым при injected pageerror без запуска decode-матрицы. Обычный режим сохраняет прежние cases/вывод. Доказательство: contract test + динамическая отрицательная probe. - **AC10.** `.github/workflows/docs-screenshots.yml` не меняется; #423 ссылается на #422 как владельца дублирующего пункта. Доказательство: diff/code review. - **AC11.** Typecheck, unit, build и затронутые backend tests зелёные; runtime package/config schemas и UI layout не меняются. Доказательство: локальный цикл и Linux CI. - **AC12.** User Guide и оба changelog описывают capability-based compatibility и безопасное имя без технических обещаний о внутреннем SHA-префиксе сверх нужного пользователю. Доказательство: docs guard/code review. ## План отрицательной проверки 1. Вернуть агрегацию по `broken_plan_` — тест с новым `translation_key` краснеет. 2. Вернуть filename от token — smoke/transport test находят token prefix. 3. Перенести preflight после executor либо убрать post-await check — отдельные quota tests краснеют на build call или превышении лимита. 4. Вернуть сравнение `integration_version === CARD_VERSION` — mixed-release smoke краснеет; удалить/reset `support_api` adoption — old-backend case краснеет. 5. Вернуть form strings в core dictionary — bundle ownership test находит marker в initial graph и initial gzip перестаёт уменьшаться. 6. Удалить `watchPage` или browser-error verdict из benchmark — contract/probe краснеет. ## Риски - **Stale capability после downgrade.** Missing/invalid field явно сбрасывает состояние на incompatible при каждом `config/get`. - **API v2 ошибочно принимается старой карточкой.** Predicate принимает ровно 1; additive изменения не повышают API, breaking change повышает. - **Quota race.** Capacity проверяется повторно после каждого `await`, запись replacement выполняется атомарным синхронным участком. - **Неудачный refresh уничтожает старый preview.** Старый token удаляется только после успешного build и финальной capacity-проверки. - **Repair key раскрывает данные.** Строгая форма ключа и запрет fallback к issue id оставляют только проектный translation key. - **Lazy copy теряет локаль/fallback.** Отдельная parity/placeholder matrix и smoke четырёх языков блокируют неполный перенос. - **Экономия бандла мнимая.** AC фиксирует owner graph и числовой baseline, не только общий budget. - **Benchmark начинает считать ожидаемый per-case FAIL как exception.** Guard реагирует только на pageerror; существующий catch/вывод cases сохраняется. ## Откат Откат implementation-коммита возвращает строгий release-version gate, прежние filenames, eager support strings, позднюю quota-проверку и старый benchmark. Persisted data и package schema откатывать не нужно. Backend additive `support_api` безопасно удалить вместе с frontend predicate одним релизом; старый frontend и так игнорирует поле. ## Release-артефакты - обязательны `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` в том же User-Visible commit; - обязательна правка раздела Help & feedback в обоих User Guide; - golden/docs screenshots не нужны: DOM/layout/text видимой совместимой формы не меняются, а compatibility message проверяется текстом/smoke; - performance artifact — before/after `dist/houseplan-assets.json` с baseline initial gzip 291046 B и доказательством ownership; - security evidence — transport test, где filename не содержит token; - issue остаётся открытым в `S8-merged` до пакетного закрытия при выпуске беты. ## Принятые предположения - Владелец подтвердил default: protocol capability важнее равенства product versions; первая и единственная поддерживаемая версия сейчас равна 1. - SHA-256 — уже раскрытая внутри consented preview метаинформация и допустимый источник short-id; новое случайное поле не добавляется. - `translation_key` — каноническое имя Repair family. Repair без безопасного translation key считается внутренне некорректным и в support package не показывается. - Все form-only support strings переносятся для четырёх локалей одновременно; оставлять RU eager при выносе только EN было бы скрытой архитектурной асимметрией. - Пункт issue про порядок комментария в docs workflow полностью принадлежит #422 и не расходует реализацию/ревью #423.