Files
felhom.eu/hub/internal/i18n/i18n.go
T
admin 9167cf53af
gates / gates (push) Successful in 23s
hub v0.118.0: the household's e-mails follow the household's language (R-558 Part A)
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
2026-09-18 16:20:11 +02:00

201 lines
6.4 KiB
Go

// Package i18n is the hub's customer-facing message bundle: one flat key → text map per language,
// embedded in the binary, Hungarian first and authoritative.
//
// Design: felhom.eu/documentation/architecture/10-localisation.md, slice 3 (R-558).
//
// THIS BUNDLE IS FOR THE HOUSEHOLD, NOT THE OPERATOR. Everything the operator reads — the hub's own
// pages, its logs, and FormatOperatorEmail — stays exactly as it is, in the language it is already
// in. A key that would change an operator surface does not belong here.
//
// Three rules, the same three the controller's bundle follows:
//
// 1. HUNGARIAN IS THE SOURCE. hu.json holds every key. A key missing from hu is a programming
// error and Msg says so loudly rather than rendering a blank line into a customer's mail.
// 2. FALLBACK IS HUNGARIAN, AND IT IS COUNTED. A key absent from en.json renders the Hungarian and
// is reported by MissingKeys, which the gate holds at zero. A household never sees a key name
// or an empty space where a sentence should be.
// 3. THE BOX'S OWN SENTENCES ARE NOT IN HERE. Roughly a third of the customer mails carry a
// sentence composed on the box ("the remote target is not set"). The hub cannot translate those
// and does not try: the box sends the household's version beside the Hungarian one
// (`message_customer`), and the hub picks. See FormatCustomerEmail.
//
// Unlike the controller's bundle there is no template expansion here: a mail is plain text, so the
// contextual-escaping problem that shaped the controller's design does not exist. Msg is a plain
// lookup and Msgf formats with the Go verbs the Hungarian already uses.
package i18n
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"sort"
"strings"
"sync"
)
//go:embed locales/*.json
var localeFS embed.FS
// Default is the language of a household that never chose one — and the only language every key has.
const Default = "hu"
// Supported lists the languages a household may be written to in, Default first.
var Supported = []string{"hu", "en"}
// Bundle holds every language's messages.
type Bundle struct {
msgs map[string]map[string]string
}
// Normalize maps any input to a supported language; everything unknown is Default.
//
// This is the ONLY gate between a reported language and a rendered mail. A box that reports
// garbage, an old box that reports nothing, and a box that reports "EN " all land on a real
// language rather than on an empty lookup.
func Normalize(lang string) string {
l := strings.ToLower(strings.TrimSpace(lang))
for _, s := range Supported {
if l == s {
return s
}
}
return Default
}
// IsSupported reports whether lang is exactly one of Supported. Used where an input must be
// REFUSED rather than coerced — the customer-creation form, which should not silently store `hu`
// for an operator who typed something else.
func IsSupported(lang string) bool {
for _, s := range Supported {
if lang == s {
return true
}
}
return false
}
// Load reads the embedded bundles.
func Load() (*Bundle, error) { return loadFrom(localeFS) }
func loadFrom(fsys fs.FS) (*Bundle, error) {
entries, err := fs.Glob(fsys, "locales/*.json")
if err != nil {
return nil, err
}
b := &Bundle{msgs: make(map[string]map[string]string, len(entries))}
for _, e := range entries {
raw, err := fs.ReadFile(fsys, e)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", e, err)
}
var m map[string]string
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("parsing %s: %w", e, err)
}
lang := strings.TrimSuffix(strings.TrimPrefix(e, "locales/"), ".json")
b.msgs[lang] = m
}
if _, ok := b.msgs[Default]; !ok {
return nil, fmt.Errorf("i18n: no %s.json — Hungarian is the source and must exist", Default)
}
return b, nil
}
var (
sharedOnce sync.Once
shared *Bundle
sharedErr error
)
// Shared returns the process-wide bundle, loaded once.
//
// It panics if the embedded bundle cannot be parsed. That is deliberate and it is a BUILD-TIME
// class of fault, not a runtime one: the JSON is compiled into the binary, so a parse failure means
// every mail this binary will ever send is broken. Failing at the first send, loudly, beats sending
// a thousand mails with blank bodies.
func Shared() *Bundle {
sharedOnce.Do(func() { shared, sharedErr = Load() })
if sharedErr != nil {
panic("i18n: embedded bundle is unloadable: " + sharedErr.Error())
}
return shared
}
// Msg returns the message for key in lang, falling back to Hungarian.
//
// A key that exists in NO language returns a visible marker rather than an empty string. An empty
// string in a mail body is invisible — the customer gets a mail with a hole in it and nobody ever
// learns. The marker is ugly on purpose.
func (b *Bundle) Msg(lang, key string) string {
if b == nil {
return "!" + key + "!"
}
if m, ok := b.msgs[Normalize(lang)]; ok {
if v, ok := m[key]; ok && v != "" {
return v
}
}
if m, ok := b.msgs[Default]; ok {
if v, ok := m[key]; ok && v != "" {
return v
}
}
return "!" + key + "!"
}
// Msgf formats the message for key in lang with args.
//
// Word order is the reason this exists rather than string concatenation at the call site: English
// reorders what Hungarian does not, and a translation reorders with Go's explicit argument indexes
// (`%[2]s`) inside its own value. The Hungarian value is then free to stay exactly the format
// string it always was, which is what makes the goldens hold.
func (b *Bundle) Msgf(lang, key string, args ...any) string {
return fmt.Sprintf(b.Msg(lang, key), args...)
}
// Has reports whether lang carries key in its own file (no fallback).
func (b *Bundle) Has(lang, key string) bool {
if b == nil {
return false
}
m, ok := b.msgs[lang]
if !ok {
return false
}
v, ok := m[key]
return ok && v != ""
}
// Keys lists lang's own keys, sorted.
func (b *Bundle) Keys(lang string) []string {
if b == nil {
return nil
}
m := b.msgs[lang]
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// MissingKeys lists the Hungarian keys that lang does not carry, sorted.
//
// The gate holds this at zero for every supported language. It is the difference between "we
// translated it" and "we believe we translated it".
func (b *Bundle) MissingKeys(lang string) []string {
if b == nil {
return nil
}
var out []string
for _, k := range b.Keys(Default) {
if !b.Has(lang, k) {
out = append(out, k)
}
}
sort.Strings(out)
return out
}