# REPORT — localisation slice 3 Part A: the hub's e-mails follow the household's language **hub v0.118.0** · felhom.eu base `20aafc3dec20` · 2026-09-18 · R-558 (Part A), R-555 closed ## Claims in the task that turned out wrong, named first 1. **"`customerMessages` 40 entries (L70)"** — **39 entries, at L69.** 2. **"`customers.language`, `appliances.reported_language`"** — **neither table exists.** There is no `customers` table and no `appliances` table. The real ones are **`customer_configs`** (the customer record) and **`reports`** (the box's heartbeat, one row per report). The columns added are `customer_configs.language` and `reports.language`. 3. **"no `ALTER TABLE customers ADD` found by grep — the migration pattern is something else; if it is not obvious, stop and report."** The pattern is completely obvious and there are ~20 instances: `s.db.Exec("ALTER TABLE ADD COLUMN NOT NULL DEFAULT ")`, error deliberately ignored so it is idempotent. The grep failed only because it named a table that does not exist. 4. **"`renderBackupRunFailures` gains a `lang` parameter"** — it is **operator-only and already English**. Its single caller is `FormatOperatorEmail`. Untouched. 5. **"`message_customer`'s length is capped like `message`"** — **`message` has no length cap.** Both are bounded only by the 1 MB `LimitReader` on the request body. I wrote a cap, then removed it: a byte-count truncation would also cut a UTF-8 sequence in half. 6. **"`python3 scripts/hub_gates.py` (or the runner the hub uses — name it)"** — there is no `hub_gates.py`. The hub's gates run from **`scripts/repo_gates.py`**, this repo's single runner. 7. **Line numbers** were mostly off by one or two (`customerMessages` 69 not 70, `severityLabels` 158 not 159, `FormatCustomerEmail` 166 not 167). Claims that were **right**: no mail goldens existed; the hub read no `language` anywhere outside a comment; `05-hub-architecture.md` had no customer-mail section; the box has published the field since v0.247.0. I briefly believed `Report.Language` was never assigned and was wrong — `cmd/` is gitignored, so `rg` skips `main.go`, where all four assignments live. ## What shipped The hub reads the language the box reports and writes the household's e-mail in it. **Nothing an operator reads changed.** Hungarian is byte-identical, measured. - **`internal/i18n`** — flat bundle, 79 keys, hu authoritative + hu fallback, missing ceiling 0. - **56 mail goldens per language**, captured from v0.117.0 **before** any string moved. All 56 Hungarian ones pass unchanged after the rewrite. A `nowFn` seam makes them byte-stable. - **`customerMessages`/`severityLabels` are derived from the bundle** — one place per sentence, and all 40+ existing tests and comments that read them still work. - **Order: last reported → created-with → `hu`.** `reports.language` defaults to **empty**, never `hu`: "never told us" is not "chose Hungarian". - **`message_customer`** accepted on `POST /api/v1/event` (additive, optional forever). - **Per-language bind page**, built the controller's way (substitute markers, then parse). - Customer form `language` select; `customer.language` in `controller.yaml` (diff: one line). ## Live evidence, read off the running hub The wire already worked and I measured it before writing anything (read-only copy of `hub.db` + WAL): ``` demo-felhom 2026-09-18 13:42:56 language='hu' controller 0.255.0 demo-hp 2026-09-18 13:45:24 language='en' controller 0.255.0 drill-r50 / peti-felhom / tester-1 language= (0.213.0 / 0.115.0 / 0.245.0) ``` That is the positive and the negative control in one read: boxes ≥ 0.247.0 report the field, older ones send nothing at all — which is exactly the case the empty default exists for. ## Two defects found inside this release - **R-581 (P2, partly still open).** `CustomerLanguage` picked the newest report by `received_at`, which has **second** granularity — same-second reports tie and the winner is arbitrary. A household that had just switched would get the old language back at random. Fixed by ordering on the autoincrement `id`; caught by a test that failed on the first version. **Still open: `GetCustomers()` has the same shape** and feeds the whole operator dashboard. - **R-582 (closed, kept for the lesson).** The English copy-guard stems, ported word-for-word from the Hungarian, convicted **141 honest sentences**. In Hungarian the stem *is* the claim (`visszaállíthat` = *can restore*); English splits the modal from the verb, so the claim is a phrase. Then the decoy suite caught the fix being too narrow — "can **still** be restored" walked through a pattern written for "can be restored". ## Gate work `wire_contract_gate.py`: the R-555 `language` allowlist entry **deleted** — the field is genuinely decoded now and the gate checks it (202 tags, was 201). `hub_copy_gate.py`: **the sentences moved into the bundle**, so `CUSTOMER_SURFACES` had to move with them — without that the four declared Go files would all still exist, the gate would still report success, and it would be scanning nothing. It learned English, and found one real occurrence (operator-tier, registered with its reason). Three new decoys including an **innocent control**; the `hub-copy` exemption is removed from `decoy_coverage_gate.py`. ## The mails | Group | Count | Golden | English | Live-tested | |---|---|---|---|---| | Event types (`mail.event.*`) | 39 | ✅ hu + en | ✅ | pending (§13.1) | | Severity labels | 4 | ✅ | ✅ | — | | Customer wrapper (subject, body, 2 lines, sign-off) | 5 | ✅ | ✅ | — | | Claim arc (claim / reset / reenroll / claimed) | 4×2 | ✅ | ✅ | pending (§13.2) | | Self-bind mail | 2 | ✅ | ✅ | pending | | Bind page | 21 | — (render tests) | ✅ | pending | | Operator mails | 3 | ✅ (unchanged) | n/a — never localised | — | ## Green `go build` / `go vet` / `go test ./...` clean. All 14 felhom.eu gates OK. 15/15 decoys behave. **Not yet done:** deploy + live proof (§13), and Part B (controller v0.256.0, the box's own sentence).