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
+26 -13
View File
@@ -2065,11 +2065,11 @@ var allowedEventTypes = map[string]bool{
// sends a dynamic Hungarian message (disk label + the triggering attribute names), so — like
// offbox_enlarge_blocked — there is deliberately NO customerMessages entry (which would discard the
// specifics via the templates.go fallback priority).
"disk_health_degraded": true,
"health_degraded": true,
"health_critical": true,
"health_recovered": true,
"app_deployed": true,
"disk_health_degraded": true,
"health_degraded": true,
"health_critical": true,
"health_recovered": true,
"app_deployed": true,
// R-536 (controller v0.244.0). `app_deployed` used to fire beside the 202 that merely ACCEPTED a
// deploy, so an install interrupted five seconds later stood on the timeline as a completed one
// — measured 2026-09-16 on the drill box with mealie, which ended `not_deployed`. The accept-time
@@ -2079,8 +2079,8 @@ var allowedEventTypes = map[string]bool{
//
// Both are customer-tier like `app_deployed` itself (NOT in operatorOnlyEvents): a household that
// pressed „Telepítés" is the party who wants to know it did not finish.
"app_deploy_started": true,
"app_deploy_failed": true,
"app_deploy_started": true,
"app_deploy_failed": true,
"app_removed": true,
"app_start_failed": true, // controller fix-3 (CAMPAIGN-3): a deployed app is not running
"disaster_recovery_started": true,
@@ -2152,11 +2152,18 @@ func (h *Handler) handleEvent(w http.ResponseWriter, r *http.Request) {
}
var payload struct {
CustomerID string `json:"customer_id"`
EventType string `json:"event_type"`
Severity string `json:"severity"`
Message string `json:"message"`
Details json.RawMessage `json:"details"`
CustomerID string `json:"customer_id"`
EventType string `json:"event_type"`
Severity string `json:"severity"`
Message string `json:"message"`
// MessageCustomer (controller v0.256.0, R-558) — the SAME sentence as Message, written in
// the household's language. Optional forever: an older controller sends none, and a box
// that never will (a parked one) must keep working exactly as it does today.
//
// It is used ONLY to render the household's e-mail. Message stays the Hungarian one that is
// stored, logged and mailed to the operator, so nothing an operator reads moves.
MessageCustomer string `json:"message_customer"`
Details json.RawMessage `json:"details"`
}
if err := json.Unmarshal(body, &payload); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
@@ -2208,6 +2215,11 @@ func (h *Handler) handleEvent(w http.ResponseWriter, r *http.Request) {
payload.Severity = "info"
}
// NO SEPARATE LENGTH CAP, and that is deliberate rather than an omission. `message` has never
// had one either: both are bounded by the 1 MB LimitReader on this request body, which is the
// only cap that has ever existed here. Adding one to the new field alone would make the two
// sentences behave differently for no measured reason — and a byte-count truncation would cut a
// UTF-8 sequence in half, turning a long Hungarian sentence into mojibake in a customer's inbox.
// Store details as JSON string
detailsStr := "{}"
if len(payload.Details) > 0 && string(payload.Details) != "null" {
@@ -2225,7 +2237,8 @@ func (h *Handler) handleEvent(w http.ResponseWriter, r *http.Request) {
// Dispatch notifications (non-blocking)
if h.dispatcher != nil {
go h.dispatcher.ProcessEvent(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, detailsStr, "controller")
go h.dispatcher.ProcessBoxEvent(payload.CustomerID, payload.EventType, payload.Severity,
payload.Message, payload.MessageCustomer, detailsStr, "controller")
}
w.Header().Set("Content-Type", "application/json")