docs: review document for #43

Issue: #43
User-Visible: no
This commit is contained in:
claude[bot]
2026-09-02 00:05:36 +00:00
parent 3bbe555ad8
commit 469aa3f7bd
+210
View File
@@ -0,0 +1,210 @@
# SPEC-REVIEW-43-r4
- Issue: #43 «Диалог помощи и обратной связи с обезличенным support report»
- Этап: spec (PROCESS.md §2.4)
- Заход: r4 · блокирующих циклов израсходовано 2 из 4 (до этого раунда)
- Материал: `docs/specs/043-private-support-report.md` на HEAD `cd8d01ac`
(ветка `issue/43-help-feedback`), плюс код `scripts/support-relay/**` как
доказательство исполнимости заявленных в §9 контрактов — в объёме, до
которого дотягивается дельта этого раунда.
- Предыдущий раунд: r3, вердикт **жёлтый**, SHA `c43bf847` (High 0, Medium 1);
документ `docs/reviews/SPEC-REVIEW-43-r3.md`, опубликован коммитом
`55b51a02`. SHA r3 в самом документе назван явно (раздел «Материал»); в
сжатом комментарии-вердикте в issue он не повторён (там только «HEAD»), но
это не «находка №1 инструкции» — первоисточник (файл ревью) SHA не потерял.
SHA пересчитан и подтверждён по времени коммитов независимо от текста
документа: `git log --date=iso-strict` даёт `c43bf847` = `2026-09-02
01:03:57+03:00`, что на 27 с опережает комментарий-вердикт `r3`
(`22:21:03Z` = `01:21:03+03:00`, следующий коммит `55b51a02` = сам документ
ревью, коммит `22:21:09Z`).
## Дельта r3→r4
`git diff c43bf847..HEAD` (без учёта `docs/reviews/SPEC-REVIEW-43-r3.md`,
который сам является артефактом прошлого раунда, а не правкой автора):
- `docs/specs/043-private-support-report.md` — 10 строк добавлено, 0 удалено:
новый абзац в §9.2 (источник rate-limit), новая строка **AC12a** в §13,
новая строка обязательной мутации в §14.4;
- `scripts/support-relay/README.md` — 27 строк добавлено: минимальная
конфигурация автоматизации Home Assistant «House Plan: приёмщик обратной
связи → личка» (YAML) плюс пояснение, почему `parse_mode: plain_text` не
косметика.
Дельта строго докс-онли и локальна: адресует ровно два пункта прошлого
вердикта (Medium §9.2 и снятый-с-запиской Low про недокументированную
автоматизацию), кода не трогает, новую подсистему не задевает, объём
несравним с исходной задачей. Полный разбор не требуется — разбор по дельте
плюс всё, до чего эта дельта дотягивается (см. находку ниже: она лежит внутри
того же абзаца §9.2, который дельта редактирует).
## Закрытие раунда r3
| Находка r3 | Чем закрыта | Где видно |
|---|---|---|
| **Medium** — §9.2 не фиксирует источник rate-limit-адреса (только код/README, требование не переживёт рефакторинг `hp_relay/app.py`) | Частично: добавлен абзац в §9.2 «источник — последний элемент `X-Forwarded-For`, потому что прокси перезаписывает заголовок целиком», плюс **AC12a** в §13 и строка мутации в §14.4 | `docs/specs/043-private-support-report.md:446-452` (абзац §9.2), `:539` (AC12a), `:590-591` (§14.4). Текст дословно соответствует тому, что просил r3, **кроме** условности через `trusted_proxy` — см. новую находку ниже, это тот же абзац, не новый пробел, а недозакрытый прежний |
| **Low** (снят с запиской, не блокировал) — минимальная конфигурация вебхук-автоматизации не задокументирована в репозитории | Закрыт: полный YAML автоматизации (webhook-триггер, `local_only: false`, условие по `source`, `telegram_bot.send_message` с `parse_mode: plain_text`) добавлен в README | `scripts/support-relay/README.md:154-180`. Поля `source`/`text` в шаблоне совпадают буквально с тем, что шлёт код: `scripts/support-relay/hp_relay/delivery.py:180-184` (`HaWebhookDelivery.send`) формирует JSON именно с ключами `source`/`report_id`/`text` |
Low закрыт полностью и без оговорок. Medium закрыт **не полностью** — см. находку.
## Унаследовано из r3 (и через r3 — из r2/r1) без повторной проверки
Со ссылкой на `docs/reviews/SPEC-REVIEW-43-r3.md` (SHA `c43bf847`) и
транзитивно на r2 (`docs/reviews/SPEC-REVIEW-43-r2.md`, SHA `e25aa302`):
- §1–§8 — сценарий, UX-контракт, support package v1, backend API preview/
discard/submit — дельта их не касается;
- §9.1 — два канала доставки (`telegram`/`ha_webhook`), запись до попытки
доставки, privacy-текст §9.3 (кроме уже переоценённого абзаца §9.2) — не
менялись этой дельтой, r3 проверил их построчно против кода
(`hp_relay/delivery.py`, `app.py:107-116`, `config.py:66-68`);
- §10, §11 (включая каноническую фразу `Touch editor: supported`), §12;
- §13 AC1–AC11, AC13–AC17 (кроме новой AC12a);
- §15–§20, включая классификацию `scripts/support-relay/**` как class B
(закрыта в r1→r2, переподтверждена в r3 живым прогоном `process-gate.mjs`);
- полный аудит `hp_relay/{multipart,validate}.py` — вне дельты r2→r3 и вне
дельты r3→r4, предмет будущего код-ревью по AC5–AC12.
## Находки
### Medium (в скоупе #43, чинится в этой же задаче, вопрос технический — не владельцу)
**§9.2 закрывает находку r3 частично: описывает доверие к `X-Forwarded-For`
как безусловный архитектурный факт, а оно на самом деле включается флагом
конфигурации `HP_RELAY_TRUSTED_PROXY`, который в спеке, AC и списке
обязательных мутаций не упомянут вовсе.**
Читаю код (`scripts/support-relay/hp_relay/app.py:147-158`):
```python
def _source(self) -> str:
if service.cfg.trusted_proxy:
forwarded = self.headers.get("X-Forwarded-For", "")
if forwarded:
return forwarded.split(",")[-1].strip()
return self.client_address[0]
```
`trusted_proxy` берётся из `HP_RELAY_TRUSTED_PROXY` (`config.py:82`, default
`"1"`) — то есть чтение последнего элемента `X-Forwarded-For` действует только
при этом флаге; иначе источником становится адрес TCP-соединения, то есть
(при реальной топологии, где Caddy проксирует на `127.0.0.1`) **один и тот же
адрес для всех клиентов**, что не «безопаснее», а means-один общий
частотный бакет на весь публичный эндпоинт: одна активная попытка исчерпывает
лимит для всех источников разом, а не для конкретного клиента, — деградация
той же анти-abuse истории (§16, риск 3), только в обратную сторону.
Новый абзац §9.2 (добавленный этой дельтой) не называет этот флаг вообще:
> «The relay reads the *last* element of `X-Forwarded-For`, because the first
> element is whatever the caller sent, and the reverse proxy in front of it is
> configured to overwrite the header outright rather than append to it.»
Это верно только пока `trusted_proxy=true`; сам разговор о существовании
такого выключателя, о том, что оба продовых хоста (`support.houseplan.tech`,
`support-staging.houseplan.tech`) обязаны держать его включённым, и о
поведении при выключенном — в спеке отсутствует.
**Это ровно тот класс дефекта, который сама находка r3 была призвана
устранить**: в коде — правильно и обдуманно (для флага есть отдельное имя,
дефолт и комментарий в `env.example:18-19`), а в ТЗ — не зафиксировано, значит
не переживёт следующий рефакторинг `hp_relay/app.py` (случайное удаление
`if service.cfg.trusted_proxy:` при сохранении `.split(",")[-1]` тихо
расширит доверие к заголовку на любую топологию, включая прямую экспозицию
порта без прокси). Ни `AC12a`, ни новая строка §14.4 эту ветку не покрывают:
обе называют только подмену индекса элемента (`[0]` вместо `[-1]`), а не
факт, что chтение заголовка вообще управляется флагом.
Проверено также, что тестового покрытия ветки `trusted_proxy=False` нет:
`grep -n "trusted_proxy" scripts/support-relay/tests/test_relay.py` — 0
совпадений. Ветка «адрес TCP-соединения» сейчас не проверяется ни одним
тестом.
**Чем закрыть, не открывая вопрос владельцу** (технический пункт по §7.1,
решает автор ТЗ, ревьюер вправе оспорить):
1. в §9.2 назвать флаг явно: доверие к `X-Forwarded-For` действует только при
`HP_RELAY_TRUSTED_PROXY=1` (`trusted_proxy` в конфиге), и это обязательное
состояние для `prod`/`staging` — при выключенном или отсутствующем прокси
источником становится адрес TCP-соединения, и это осознанно более
консервативный, а не эквивалентный режим (одна корзина на весь трафик, а
не «без лимита»);
2. AC12a — добавить условие: гарантия действует только при `trusted_proxy`,
включённом на обоих продовых хостах; это часть DoR/release, а не просто
поведение по умолчанию;
3. §14.4 — добавить мутацию: снятие проверки `if service.cfg.trusted_proxy`
(доверие XFF независимо от флага) должно ронять новый тест, которого
сейчас нет — то есть пункт 3 требует и код (тест), не только текст ТЗ;
зафиксировать это явно, а не оставлять как молчаливый пробел.
Серьёзность — Medium: не открывает утечку данных и не позволяет обойти лимит
сильнее, чем позволяет топология (пока `trusted_proxy` включён по умолчанию
и прокси настроен верно — как сейчас и есть), но ровно то самое несоответствие
кода и ТЗ, которое r3 уже один раз квалифицировал как Medium в этом же
абзаце. Не блокирует по High, но без исправления это жёлтый вердикт.
## Что проверено и признано корректным
- **Low r3 закрыт полностью**: YAML-шаблон автоматизации в README дословно
совпадает с полями, которые шлёт `HaWebhookDelivery.send`
(`source`/`report_id`/`text`), включая объяснение, почему `parse_mode:
plain_text` обязателен (иначе текст пользователя разбирается как разметка).
- **Caddy-конфигурация** (`scripts/support-relay/deploy/Caddyfile.fragment`)
подтверждена чтением: `header_up X-Forwarded-For {remote_host}` стоит на
**обоих** сайтах (`support.houseplan.tech`, `support-staging.houseplan.tech`)
без исключений — то есть при нынешнем деплое реальная топология
соответствует тому, что описывает новый абзац §9.2 (с оговоркой из находки
выше: соответствие держится на конфиге, а не на архитектурной гарантии).
- **AC12a и новая строка §14.4** внутренне непротиворечивы и совпадают с уже
существующим (написанным до r3) тестом `test_client_cannot_pick_its_own_rate_bucket`
(`scripts/support-relay/tests/test_relay.py:452-467`) — читал тест, он
действительно шлёт три поддельных `X-Forwarded-For` и проверяет один общий
ключ корзины в `spool/rate/*.json`.
- **Классификация `scripts/support-relay/**` class B** не затронута дельтой,
живой прогон `node scripts/process-gate.mjs` на HEAD `cd8d01ac`: «гейт
пройден, предупреждений 0» (офлайн, диапазон `origin/dev..HEAD`, 10
коммитов).
- Коммит `cd8d01ac` несёт `Issue: #43` и `User-Visible: no` (docs-only,
корректно — правки в спеку и README не меняют видимое поведение продукта).
## Чего не проверял и почему
- `npm test` / `npm run build` (сверка трёх копий бандла) — не прогонял сам:
Validate на этом же SHA `cd8d01ac` завершился success (ссылка дана в
задании), покрывает `tsc`/`test`/`build`; дельта раунда и так не трогает
`src/**`, предмета для этих гейтов у неё нет.
- `node scripts/check-docs.mjs` — не требовался: дельта не трогает `src/**`.
- `npm run invariants` / инварианты модели — не требовались: дельта не
трогает геометрию, `layout`, `marker.space`, `open_spans`.
- `python -m pytest tests_backend -q` — не требовался: дельта не трогает
`custom_components/**`.
- `python3 -m unittest discover -s scripts/support-relay/tests` — **не
перегонял сам в этом раунде**: код relay этой дельтой не менялся (только
документация), а r3 уже прогнал полный набор (35/35 зелёных) и лично
проверил мутациями оба относящихся к этому раунду теста
(`test_client_cannot_pick_its_own_rate_bucket`,
`test_webhook_sends_text_and_keeps_the_package_on_the_node`) на способность
падать — наследую этот результат из r3 (см. таблицу наследования). Находка
выше — про то, чего в этом наборе тестов нет (`trusted_proxy=False`), а не
про то, что существующие тесты красные.
- Браузерные смоки, `npm run golden:verify` — не требовались: `src/**` для
фичи #43 ещё не реализован (§1–§8 остаются на этапе ТЗ).
- Полный построчный аудит `hp_relay/{multipart,validate}.py` — вне дельты
r3→r4, предмет будущего код-ревью по AC5–AC12.
## Гейты, которые прогнал сам
| Гейт | Результат |
|---|---|
| `node scripts/process-gate.mjs` | «гейт пройден, предупреждений 0» (офлайн, `origin/dev..HEAD`, 10 коммитов) |
| Чтение `hp_relay/app.py`, `config.py`, `env.example`, `test_relay.py`, `delivery.py`, `Caddyfile.fragment` | вручную, построчно — источник находки и подтверждения закрытий |
| `npx tsc --noEmit` / `npm test` / `npm run build` | не прогонял — Validate зелёный на этом же SHA `cd8d01ac` (см. задание раунда), дельта докс-онли |
## Вывод
Low r3 закрыт полностью. Medium r3 закрыт частично — тот же абзац §9.2
получил формулировку, которая верна только при включённом (по умолчанию, но
не зафиксированном как обязательное условие) флаге `trusted_proxy`, и это
условие не отражено ни в AC12a, ни в §14.4, ни где-либо ещё в ТЗ. High
находок нет. Вердикт — жёлтый, не блокирующий продвижение по High, но
возвращающий ТЗ на правку одного абзаца и одной AC-строки.