`
becomes `{ "code": "broken_plan", "count": N }`; raw issue id and placeholders
do not leave HA.
No exception message, stack, raw validation path or display name is permitted.
### 7.3 Exact plan snapshot and pseudonyms
Backend reads config and layout under the same `write_lock`, deep-copies once and
builds a new allowlist object. Redaction after serializing the original is forbidden.
Within one package all referential ids are mapped consistently to namespaced
sequential pseudonyms. The random namespace changes for every new preview, so a
pseudonym cannot correlate the same object between two reports:
- `space-k7m2-1`, `room-k7m2-1`, `wall-k7m2-1`, `opening-k7m2-1`,
`partition-k7m2-1`, `column-k7m2-1`, `decor-k7m2-1`, `marker-k7m2-1`;
- mappings are generated from current document order and a cryptographically
random per-package namespace; the raw mapping is never returned/stored;
- every cross-reference (`space`, `room_id`, host ids, wall ids, marker controls,
layout keys) is either remapped or omitted fail-closed;
- unknown object keys are dropped at every depth.
Included plan data:
- exact numeric geometry/coordinates, wall thickness, openings and transforms;
- structural/display enums, booleans and bounded numeric settings needed to render;
- decor geometry/style; decor text is replaced with a neutral placeholder;
- marker geometry/layout and safe display configuration; bindings/controls are
represented only by pseudonyms and stable domain/kind enums;
- space/room display names become localized-neutral `Space 1`/`Room 1` tokens;
- plan aspect and presence flags without file identity.
Excluded at source:
- all original ids and keys, HA area/floor/device/entity/config-entry ids;
- space/room/device/entity names and friendly names;
- marker description, link, PDFs, free-text templates, decor text and user notes;
- plan/background/file URLs, filenames, paths, MIME metadata and content manifests;
- uploaded image/PDF/manual bytes;
- entity states/attributes, live values, service data, vacuum trail/coordinates;
- HA installation id, IP/host, external/internal URLs, timezone and exact location;
- original `source_fingerprint`, raw checksums of user data and exact event times;
- undo/import optimizer snapshots and local browser storage.
Exact relative geometry is deliberately included by owner decision. Privacy text
must not call the whole attachment «anonymous» without qualification; it says
«обезличено, но содержит точную планировку».
### 7.4 Privacy invariant
Adversarial fixture заполняет **каждое** запрещённое поле уникальным sentinel,
включая unknown nested fields, Unicode, URL, path, HTML, email, entity id и
base64-looking text. В package bytes, preview, relay request metadata, logs and
error strings не встречается:
- sentinel verbatim;
- URL-encoded, JSON-escaped и base64 representation;
- raw ids from config/layout/Repair issue ids.
Тест не заменяет allowlist inspection: сериализатор строит typed projection и
имеет fail-closed tests на новые неизвестные поля схемы.
### 7.5 Limits
- attachment ≤ 8 MiB UTF-8, общий request ≤ 8.5 MiB;
- максимум три preview одновременно на пользователя и на integration instance;
- preview TTL 10 минут;
- message ≤ 10 000 code points, contact ≤ 320;
- package build не удерживает больше одной raw store copy и одной projection copy;
- превышение даёт `support_package_too_large`, ничего не отправляет.
## 8. Backend API
### 8.1 `houseplan/support/preview`
Input: strict frontend facts (`card_version`, browser enum/major, language,
pointer flags, registry enum/age bucket) and a random per-dialog `draft_id`.
Authorization: `_check_write()` / `may_write`; unauthorized fail-closed.
Output:
```json
{
"token": "opaque-random",
"expires_in": 600,
"size": 123456,
"sha256": "hex",
"spaces": 3,
"text": "{...}\n"
}
```
The in-memory record is bound to HA user id, integration entry and `draft_id`. It
stores only the already-sanitized bytes, hash, expiry and idempotency seed — never
raw config. Creating a replacement invalidates only the previous token of that
same draft; other card instances keep independent previews within the limit.
### 8.2 `houseplan/support/preview/discard`
Idempotently removes a token owned by the caller. Close dialog, checkbox off and
successful submit use this cleanup; disconnect/remount relies on TTL as the final
guard because one HA WebSocket connection may be shared by several card instances.
### 8.3 `houseplan/support/submit`
Input: `message`, optional `contact`, optional `preview_token`, `idempotency_key`.
The backend validates lengths again, resolves only a caller-owned non-expired token
and sends multipart to the compile-time allowlisted relay URL:
- part `request`: JSON with schema version, message, contact, safe versions,
attachment size/hash and idempotency key;
- optional part `attachment`: exact cached bytes, filename
`houseplan-support-{short-id}.json`, type `application/json`.
Requirements:
- HA shared aiohttp session; connect/total timeout 5/20 s;
- HTTPS only, fixed host, redirects disabled, no endpoint from user/config/message;
- no proxying of relay response text; map status to stable local codes;
- no body/contact/message/package in HA logs, traces or exception strings;
- token is consumed only on confirmed success; retry reuses exact bytes/key;
- response returns bounded `report_id`, never remote debug detail.
New stable errors are added to backend error registry and all locales:
`support_invalid_message`, `support_preview_expired`,
`support_package_too_large`, `support_rate_limited`, `support_unavailable`,
`support_rejected`.
Old backend without these commands leaves About/Guide usable and shows localized
«Обновите House Plan, чтобы отправлять репорты»; it does not offer a fake submit.
## 9. Project-controlled relay
### 9.1 Minimal architecture
The repository gains a separately deployable service under
`scripts/support-relay/**`, excluded from the HACS artifact. Keeping the complete
service (runtime, manifest, tests and deployment README) in this subtree makes
every relay-only commit class B under `AGENTS.md` / `PROCESS.md`; a product commit
that also changes `src/**` remains A+B and follows the stricter class-A flow. The
service exposes only `POST /v1/reports` and `GET /health`. Secrets exist only in
its deployment environment. The production sink is a **private maintainer channel
in Telegram** (owner's decision, 2026-09-01): the summary message carries the
generated report id, safe versions and the escaped plain-text message/contact, and
the support JSON is attached as a document. Markup mode is never enabled, so the
user's text is displayed literally and cannot forge the surrounding message. The
report is written to the relay spool **before** delivery is attempted, so a failed
delivery costs a promise, not the user's request. No public issue is created and no
e-mail provider participates.
**Two delivery channels, chosen by deployment.** `telegram` posts to the Bot API
directly. `ha_webhook` posts the summary to a private webhook of the maintainer's
own Home Assistant, which performs the last mile. The second channel exists
because the project node runs at a Russian hosting provider where every
`api.telegram.org` address is unreachable — direct delivery failed with «Network
is unreachable», not with a provider error. On this channel the package stays in
the relay spool and the summary names its path: the webhook carries text only,
so the address (which is its own access key) stays cheap to rotate, and the
geometry of a stranger's home does not travel through a messenger. Either
channel keeps the same contract: nothing is promised to the user until delivery
is confirmed.
Production URL is an immutable backend constant supplied after relay deployment.
No placeholder, localhost URL or configurable arbitrary endpoint may pass release
gates. A staging relay and exact production URL are dependencies of S5/implementation;
if they are unavailable, #43 receives `blocked` without weakening the contract.
### 9.2 Validation and abuse controls
- strict multipart schema; unknown parts/fields rejected;
- max request 8.5 MiB before buffering; JSON content/type/hash verified;
- attachment parsed and checked for format/version and top-level allowlist;
- message/contact rendered as escaped plain text only;
- 5 attempts/hour and 20/day per source address plus a global circuit breaker;
- **the source address is the one supplied by the trusted proxy, never one the
client can choose.** Behind a reverse proxy the relay runs with its
trusted-proxy switch on (`HP_RELAY_TRUSTED_PROXY`, on by default) and reads the
*last* element of `X-Forwarded-For`, because the first element is whatever the
caller sent; the proxy is configured to overwrite the header outright rather
than append to it. Both halves are required: without them a caller picks its own
rate-limit bucket by changing one line of the request, and every other limit in
this section becomes decorative. With the switch off the relay ignores the
header entirely and uses the connection address — correct only for a node
exposed directly, and **forbidden on any deployment behind a proxy**, where every
connection arrives from the proxy and all callers would share one bucket. Both
production hosts run behind Caddy, so both keep the switch on;
- source IP is used only through a daily-keyed rate-limit hash with ≤24 h TTL;
raw address is not written to app logs/storage/mail;
- idempotency key retained 24 h and returns the original report id;
- uniform public errors; internal provider responses are never reflected;
- mail/provider secrets are redacted by platform logging configuration.
Because open-source clients cannot hold a relay secret, the endpoint is intentionally
public and abuse protection is rate/size/schema based. This limitation is documented
and reviewed as a security trade-off; a hard-coded shared key is explicitly forbidden.
### 9.3 Retention and disclosure
- Relay stores the accepted report (message, contact, safe metadata and the
attachment) in a spool readable only by its own system user, and a daily timer
deletes everything older than **30 days**; maintainers may delete earlier. On
the `ha_webhook` channel the attachment never leaves that spool.
Storage is the price of the chosen channel: Telegram delivery leaves no archive
the project controls, so the deletion rule has to live where the project can
enforce and prove it.
- Idempotency record contains report id/status/hash only and expires after 24 h.
- UI privacy notice says: exact geometry and optional contact leave the user's HA,
transit the project relay and the maintainer messenger, are visible to
maintainers and are kept on the project node up to 30 days; network infrastructure necessarily sees the HA server address,
but House Plan does not retain it raw.
- Sending remains explicit opt-in; opening the dialog or building preview never
contacts the external relay.
## 10. State, compatibility and migration
- No House Plan config/layout schema changes and no migration.
- Form draft, checkbox, preview token and receipt are component-memory only.
- Remount, reload and card removal discard unsent draft; no silent persistence of
contact/message.
- Existing HA diagnostics, portable backups and #295 diagnostics stay byte- and
behaviour-compatible.
- Card/integration version mismatch follows fail-closed behaviour: no attachment
built by an unknown backend contract.
- Multiple card instances have independent drafts. Backend preview limits are per
HA user/entry and cannot expose one instance's token to another user.
## 11. Accessibility, touch and responsive layout
**Touch editor: supported.** The Help/Feedback dialog opened from View or any of
the three editors has the same complete touch contract; kiosk still hides it.
- Header button has 44×44 CSS px minimum touch target without changing adjacent
visual icon size.
- Dialog remains usable at 320 CSS px width, phone portrait/landscape, tablet and
HA Companion safe-area insets; footer actions wrap and body scrolls internally.
- Native label/input association, required/error text through `aria-describedby`,
status changes through restrained `aria-live="polite"`.
- On validation failure focus moves to message; on package/send failure focus moves
to error summary; success focuses receipt heading.
- Raw JSON `` is selectable and horizontally contained; it cannot widen or
clip the dialog.
- Escape/scrim close requires confirmation only while build/send is actually in
flight. A completed unsent text draft may be closed without persistence warning,
matching other non-destructive settings drafts.
- Reduced motion adds no new animation dependency.
## 12. i18n
All visible strings and stable errors exist in RU/EN/DE/FR. Russian contact label
is exact per §4; semantic translations use the same examples. User Guide routing
depends only on effective language: RU gets RU, every other locale gets EN.
Do not localize schema keys, error codes, format/version, hashes or report ids.
Relay summary message uses English stable field labels so support tooling can
parse them; user message/contact remain verbatim plain text.
## 13. Acceptance criteria and evidence
| AC | Contract | Evidence |
|---|---|---|
| AC1 | Help button follows General Settings visibility, order and kiosk rules; opens without changing mode/selection/zoom. | Browser smoke in View + three editors + kiosk/unauthorized negatives. |
| AC2 | About moves exactly once; RU Guide routes to RU, every other locale to EN. | DOM/i18n unit + smoke link assertions. |
| AC3 | Contact optional, message required/trimmed/bounded; checkbox false and draft non-persistent on each fresh open. | Frontend unit + browser smoke. |
| AC4 | Checkbox warning explicitly names exact geometry; no external request occurs on open/preview. | Text assertion + network capture. |
| AC5 | Preview/download/attachment are byte-identical and SHA-256 matches. | Backend test + browser Blob capture + relay fixture. |
| AC6 | Allowed geometry and references survive exact, while every id is package-local pseudonym and cross-references remain valid. | Backend projection round-trip/invariant suite. |
| AC7 | Forbidden sentinel corpus is absent verbatim/escaped/encoded from package, WS errors, HA logs and relay request metadata. | Adversarial backend/relay security test. |
| AC8 | Unknown nested config fields fail closed (dropped), never appear because serializer copied the source object. | Schema mutation test adding unknown sentinels at every depth. |
| AC9 | Only `may_write` can preview/submit; token cannot be read, discarded or sent by another user/entry. | HA websocket authorization tests. |
| AC10 | Expired/replaced/discarded tokens fail; config changes after preview do not change cached bytes. | Fake-clock backend tests. |
| AC11 | Submit is HTTPS/fixed-host/no-redirect, bounded and idempotent; logs contain no message/contact/body. | Stub aiohttp server + caplog + SSRF/redirect tests. |
| AC12 | Relay enforces schema, size, hash, idempotency and rate limits; HTML remains inert plain text. | Receiver unit/integration suite with a recording fake provider. |
| AC12a | A caller cannot select its own rate-limit bucket. With the trusted-proxy switch on, requests carrying different forged `X-Forwarded-For` values land in one source key, and the proxy configuration overwrites the header; with it off, the header is ignored altogether and the connection address is used. | Two receiver tests (one per switch position) plus the deployed proxy fragment under `scripts/support-relay/deploy/`. |
| AC13 | Success shows stable report id; timeout/error preserves form and exposes retry/manual recovery without claiming success. | Browser smoke across success/429/timeout/unknown command. |
| AC14 | Phone/tablet dialog, keyboard/focus and 44 px target satisfy View touch/accessibility contract. | Reviewed desktop + phone + tablet goldens and touch smoke. |
| AC15 | No existing backup/diagnostics/preflight behavior or payload changes. | Existing targeted frontend/backend suites unchanged. |
| AC16 | Relay production URL is real, health check green, secrets absent from HACS bundle/source and retention rule documented. | Release script + staging/prod probe + secret scan/deployment evidence. |
| AC17 | Maximum supported package builds without render/main-thread regression and refuses >8 MiB before outbound send. | Backend benchmark/limit test; View performance comparison. |
## 14. Test plan
### 14.1 Pure/backend
- deterministic package ordering and byte/hash fixture;
- full pseudonym/reference matrix across spaces, walls, openings, decor, markers
and layout;
- sentinel fixture including `broken_plan_` Repair normalization;
- valid/invalid frontend facts, message/contact Unicode bounds;
- write-lock consistency under concurrent config/layout mutation;
- token ownership, TTL, replacement, discard and exact-snapshot semantics;
- outbound mock for success, timeout, DNS/TLS, 302, 400/413/429/500, malformed
success and duplicate idempotency key;
- caplog assertions that no payload values escape.
### 14.2 Frontend/browser
- header visibility/order/kiosk/mode invariants;
- lazy runtime loading and dialog close/restore;
- form validation, checkbox lifecycle, preview refresh/expiry;
- exact download Blob bytes and Copy ID/manual fallback;
- unknown-old-backend degradation;
- mobile touch target, scroll, on-screen keyboard and orientation resize;
- golden scenes: desktop no attachment, desktop preview, phone validation error,
phone success, relay error/manual recovery, light and dark themes.
### 14.3 Relay/security
- parser/size/content-type/hash/schema/idempotency/rate-limit suite;
- HTML/header injection and Unicode controls remain plain text;
- raw IP and provider secrets absent from logs;
- fake provider verifies exact attachment bytes and escaped body;
- deployment config test enforces 30-day spool retention and 24-hour
idempotency/rate-key expiry.
### 14.4 Mutation requirements
Mutation gate must prove at least:
- checkbox default flips to true → frontend test red;
- package serializer copies one forbidden field → sentinel test red;
- pseudonym mapping leaves one raw host/layout id → reference/privacy test red;
- preview bytes are regenerated on submit → exact-byte test red;
- authorization guard removed → backend test red;
- redirect enabled/arbitrary URL accepted → SSRF test red;
- relay skips hash/rate/idempotency check → receiver tests red;
- relay trusts the client-supplied end of `X-Forwarded-For` (first element
instead of last) → `test_client_cannot_pick_its_own_rate_bucket` red;
- relay reads `X-Forwarded-For` regardless of the trusted-proxy switch (the
switch check is dropped) → `test_direct_node_ignores_the_forwarded_header` red;
- success shown on timeout → browser smoke red.
## 15. Performance and reliability budgets
- Cold ordinary View receives no relay calls and no eager support runtime growth;
bundle budget records lazy chunk delta separately.
- For the current maximum valid config, package projection + canonical JSON
completes in ≤750 ms on CI reference hardware and peak additional memory is
≤24 MiB; operation is backend-side and never blocks browser render.
- Submit total timeout 20 s; UI remains cancellable except while the single WS
request is in flight, and disconnect returns to retryable error.
- No queue/retry runs in background after dialog/card destruction. The user owns
every retry.
## 16. Risks and mitigations
1. **Exact geometry is personal.** Explicit unchecked consent, honest warning,
exact preview/download, 30-day retention, no binaries/names/ids.
2. **Allowlist drifts behind schema.** Unknown keys dropped and mutation/sentinel
tests; no generic deep-redaction helper.
3. **Public relay attracts spam.** Strict small schema, size/rate/global limits,
idempotency; no fake embedded secret.
4. **Endpoint becomes an SSRF proxy.** Immutable HTTPS URL, redirects off, bounded
response, no user-configured host.
5. **Channel/provider leak.** Private channel, plain text, documented provider,
limited retention, secret/log scans.
6. **Preview differs from sent data.** Backend caches sanitized bytes by owned TTL
token; submit never rebuilds.
7. **Stale card/backend.** Unknown command disables submit honestly while Help and
Guide remain available.
8. **Relay outage loses draft.** No auto-close; explicit retry and manual recovery.
9. **External infrastructure unavailable.** DoR/release gate blocks S5/S7 rather
than shipping a dead button or silent clipboard substitute.
## 17. Dependencies and Definition of Ready
Before product implementation begins, the following external facts must be
available and recorded in #43:
1. project-controlled relay deployment target and production HTTPS hostname;
2. private maintainer channel and its delivery credentials stored only in the
relay environment, as a path to a secret file rather than a value — for the
`ha_webhook` channel the webhook address is that credential;
3. configured and running 30-day deletion of the relay spool;
4. staging endpoint usable by CI without production delivery;
5. named maintainer responsible for relay alerts/disable switch.
These are technical/operational dependencies of the accepted Q1 default. If they
are absent after green spec review, issue remains `S5-ready` + `blocked`; product
code must not invent a public fallback or embed credentials.
## 18. Documentation and release artifacts
- `docs/USER-GUIDE.md` and `.ru.md`: button, fields, geometry warning, preview,
receipt/manual fallback;
- `docs/ARCHITECTURE.md`: package boundary, preview-token, outbound relay;
- new `docs/SUPPORT-PRIVACY.md`: exact allowlist/exclusions, delivery channel,
spool retention, rate-limit network metadata and deletion/contact path;
- `docs/TESTING.md`: support package/relay/golden commands;
- `scripts/support-relay/README.md`: deployment/runbook, health/disable/secret
rotation; relay runtime, manifest and tests remain inside this class-B subtree;
- process-gate regression fixture proves a relay-only implementation commit is
classified as B and therefore requires the issue/trailer checks;
- both changelogs in implementation commit (`User-Visible: yes`);
- reviewed desktop/phone/tablet light/dark golden artifacts and receiver security
report before beta.
## 19. Rollback
- Revert header/dialog/backend command changes: no persisted House Plan data or
migration remains.
- Disable relay endpoint first; clients receive retryable `support_unavailable`
and retain manual download path.
- Reports already delivered follow the disclosed 30-day deletion rule; rollback
never silently extends retention.
- Relay can stay deployed but disabled while old card versions disappear. It must
return uniform 503, not accept and drop reports.
- About links return to General Settings only if product rollback explicitly
restores the old UI; no state conversion is required.
## 20. Принятые технические предположения
Эти решения не меняют согласованный пользовательский контракт и могут быть
скорректированы ревьюером без нового вопроса владельцу:
- transport sink — приватный канал мейнтейнера в Telegram через relay проекта,
а не private issue и не почтовый ящик (решение владельца 2026-09-01);
- support runtime остаётся в существующем lazy editor chunk;
- preview bytes кешируются только в памяти backend;
- attachment — canonical uncompressed JSON, чтобы preview/download/send были
буквально одинаковыми;
- package-local sequential pseudonyms, а не стабильный cross-report hash;
- отсутствие shared client secret компенсируется rate/size/schema limits;
- retention 30 days for the whole delivered report, 24 hours for rate/idempotency
metadata.