hub v0.118.0: the household's e-mails follow the household's language (R-558 Part A)
gates / gates (push) Successful in 23s

The hub has written every customer e-mail in Hungarian whatever the box was set
to. The box has published its language since controller v0.247.0; nothing read
it. Now it does.

Nothing an operator reads changes. The Hungarian mails are byte-identical, and
that is a diff rather than a reading: 56 goldens per language captured from
v0.117.0 BEFORE any string moved, and all 56 Hungarian ones pass unchanged after
every sentence was routed through the new bundle.

- internal/i18n: flat bundle, 79 keys, hu authoritative + hu fallback, ceiling 0.
- customerMessages/severityLabels are DERIVED from the bundle, so a sentence is
  written in one place and all 40+ tests that read those maps still work.
- Language order: last reported -> created-with -> hu. reports.language defaults
  to EMPTY, never hu: "never told us" is not "chose Hungarian".
- message_customer on POST /api/v1/event, additive and optional forever, for the
  sentences the box composes and the hub cannot translate.
- The bind page is per-language, and its `expired` state stays Hungarian: it is
  the state an unknown token lands in, so rendering a real English customer's
  token in English would make the LANGUAGE answer what the TEXT refuses to.

Two defects found inside the release:
- R-581: the newest report was picked by received_at, which has SECOND
  granularity, so same-second reports tied and the winner was arbitrary. Ordered
  by the autoincrement id now. GetCustomers() still has the shape - row open.
- R-582: the English copy-guard stems, ported word for word from Hungarian,
  convicted 141 honest sentences. The English claim is a phrase with a modal.

R-555 closed: the language allowlist entry is out of wire_contract_gate.py.
hub_copy_gate.py follows the sentences into the bundle - without that it would
have scanned four files that no longer hold any customer text and reported
success. Three new decoys incl. an innocent control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-18 16:20:11 +02:00
parent 20aafc3dec
commit 9167cf53af
150 changed files with 4063 additions and 410 deletions
@@ -284,3 +284,75 @@ the explicit Re-issue action") and the re-issue itself then refused with 400 —
tier. **Not built:** releasing ONLY the token on host delete. The endpoint's only removal op
(`deprovision`) destroys the backups too, so a token-only release needs a new endpoint operation.
## 15. Customer e-mails — what the hub writes to a household [hub v0.118.0, R-558]
**The section this document did not have.** The hub composes every sentence a household reads before
it has seen any box screen, and until v0.118.0 nothing here described that.
### 15.1 The four mails
| Mail | Trigger | Rendered by | Language source |
|---|---|---|---|
| Event notification (39 event types) | a box event, or a hub checker | `FormatCustomerEmail` | reported → created-with → `hu` |
| Claim / reset / re-enroll / claimed | the claim arc | `FormatClaimEmail` | created-with (no box has reported yet) |
| Self-bind link | customer creation, or the operator's button | `FormatSelfBindEmail` | created-with |
| The public bind PAGE at `/bind/<token>` | the customer opens the link | one template per language | created-with, **except `expired`** — see 15.4 |
The operator's channel (`FormatOperatorEmail`, and the R-182 backup-run digest) is **not** in this
table and is not localised. It is English, it names host ids and blob counts, and it is untouched.
### 15.2 Where the sentences live
`hub/internal/i18n/locales/{hu,en}.json`, one flat key→text map per language. Hungarian is
authoritative and holds every key; a key missing from English renders the Hungarian and is counted by
a gate held at zero. `customerMessages` and `severityLabels` are DERIVED from the bundle rather than
being literals, so a sentence is written in exactly one place. **A new event type therefore needs a
line in `hu.json` and its English twin**, alongside its `allowedEventTypes` entry — the long-standing
"both together" rule, in its new home.
### 15.3 The language order, and why it is that order
**Last reported → created-with → Hungarian** (`Store.CustomerLanguage`).
1. **What the box last reported** is what the HOUSEHOLD chose on their own dashboard. It outranks
everything else: the operator's creation-time pick is a default, never an override.
2. **The creation-time language** (`customer_configs.language`) covers the window before any box has
reported — which is precisely when the claim mail and the bind page are sent, so it is not an edge
case. It also seeds the box: configgen writes it as `customer.language`.
3. **Hungarian**, for every customer that predates all of this.
Two storage rules follow from that order and are easy to get wrong:
- `reports.language` defaults to **empty**, never `hu`. Empty means *this box has never told us*,
which is not the same as *this household chose Hungarian* — a controller older than v0.247.0 sends
no language at all, and storing `hu` would make a later real choice indistinguishable from the
absence of one.
- The newest report is found by the autoincrement **`id`**, not by `received_at`. `received_at` has
second granularity, so two reports arriving in one second tie and the winner is arbitrary.
A quiet-box alarm deliberately uses the last REPORTED language even though the box is silent: the
last thing it said is still the best thing known about the household.
### 15.4 The box's own sentences, and the one thing the hub cannot do
About a third of the customer mails carry a sentence the BOX composed, naming a drive, an app or a
number. **The hub cannot translate one.** So the box sends the household's version beside the
Hungarian one, as `message_customer` on `POST /api/v1/event`; the hub puts that in the household's
mail and keeps the Hungarian for the operator's. It is additive and optional **forever** — a parked
box will never send it, and its absence must leave the mail exactly as it was.
Until every box runs controller v0.256.0 or later, an English household's mail can carry one
Hungarian line. The rest of the mail is English. That is expected, not a defect.
**The bind page is a no-oracle surface, and the LANGUAGE is part of that.** The page folds an unknown
token into `expired` so a stranger cannot learn whether a link was ever real. If it then rendered a
real English customer's expired token in English and an unknown one in Hungarian, the language would
answer the question the text refuses to — for every customer who is not Hungarian. The `expired`
state therefore always renders in the default language; every other state already discloses that the
token is real. Pinned by `TestBindExpiredIsAlwaysDefaultLanguage`.
### 15.5 How "the Hungarian did not change" is known
56 goldens captured from v0.117.0 before any string moved, in
`hub/internal/notify/testdata/mail_goldens/hu/`, with the English set beside them. The claim is a
diff, not a reading. A golden is never regenerated to make a change pass.
+10 -1
View File
@@ -119,7 +119,16 @@ the hub's e-mails follow.**
`"language": "en"`, the three pages English; the hub's stored reports read no field (0.246.0), `hu`,
`en` at 12:54:43Z, `hu` at 12:55:22Z after switching back; without `_csrf` → 403 and nothing changed
(`audits/i18n-2026-09-17/live/README.md`).
- **[DESIGN] Not built — slice 3:** the hub stores a per-customer language, renders it into
- **[FACT] Slice 3 Part A, hub v0.118.0 (2026-09-18):** the hub READS the reported language and writes
the household's e-mails in it. `hub/internal/i18n` (79 keys, hu authoritative, hu fallback, missing
ceiling 0); 56 mail goldens captured from v0.117.0 BEFORE any string moved, and all 56 Hungarian
ones pass unchanged. `customerMessages`/`severityLabels` are derived from the bundle. Order: last
reported → `customer_configs.language` → `hu` (`Store.CustomerLanguage`). `message_customer` is
accepted on `POST /api/v1/event` for the box's own sentences. The bind page is per-language, with
`expired` pinned to Hungarian so the language cannot become the oracle the text refuses to be.
Full design: `05-hub-architecture.md` §15. **R-555 closed** — the `language` allowlist entry is out
of `wire_contract_gate.py` and the gate now checks the field for real.
- **[DESIGN] Slice 3 Part A as planned — now built; the box half (Part B) is the remaining piece:** the hub stores a per-customer language, renders it into
`controller.yaml` next to `customer.id/name/domain/email` (`hub/internal/configgen/configgen.go`),
and the box uses it **only while the household has never chosen** (`settings.json` empty). The
household's own choice always wins; the hub's e-mails follow the language the box **reports**, which