v0.256.0: the box sends its own sentence in the household's language (R-558 Part B)
gates / gates (push) Successful in 24s

MinAgent: 0.131.0 (unchanged). Needs hub v0.118.0+, which shipped first and
tolerates a box that sends none of this - every box in the fleet is that box
until this release reaches it.

The hub writes a household's e-mails in their language now, but about a third
of those mails carry a sentence the BOX composed, naming a drive, an app or a
number. The hub cannot translate one. So the box sends it twice.

- message_customer on POST /api/v1/event, omitempty. A HUNGARIAN household
  sends nothing extra at all, so its payload stays byte-for-byte what every box
  sends today and the hub's fallback path keeps being the one production
  exercises rather than a branch nobody takes.
- 19 producers render both sentences from ONE bundle key. `message` stays
  Hungarian always: it is what the operator is mailed and what the hub logs.
- customer.language bootstraps a new box - stored choice, then config, then
  Hungarian. The config value is NEVER written into settings.json: that would
  record a choice the household never made.

The Hungarian did not move, measured twice: the wire golden from the slice-2
base commit, and the Go parity gate over all 19 new keys.

Three guards had to learn the change and one caught me: the test seam now
carries the new field; the R-329 severity register reported two dynamic sites
as no longer existing the moment they moved off PushEvent (the walk now checks
36 severity literals, up from 20); and TestConfigLanguageIsWiredInMain reads
main.go, because cmd/ is gitignored and ripgrep does not.

A mistake, named: the first pass dropped displayName from three producers,
which would have mailed customers "Alkalmazás telepítve: %!s(MISSING)". Caught
reading the diff; now pinned by a test that refuses %!/MISSING/%s/%d in either
language.

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:54:20 +02:00
parent 8fb2f9ef9d
commit bef39598d0
16 changed files with 1601 additions and 1068 deletions
@@ -0,0 +1,109 @@
package settings
import (
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
)
// The bootstrap rule (R-558): a box starts in the language the hub says, until the household picks.
func newSettings(t *testing.T) *Settings {
t.Helper()
s, err := Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
return s
}
func TestLanguageBootstrapFromConfig(t *testing.T) {
// 3. Nothing anywhere → Hungarian.
s := newSettings(t)
if got := s.GetLanguage(); got != "hu" {
t.Errorf("no choice, no config = %q, want hu", got)
}
// 2. The config's language, for a box nobody has told yet.
s.SetConfigLanguage("en")
if got := s.GetLanguage(); got != "en" {
t.Errorf("config en, no choice = %q, want en — a box created as English starts Hungarian", got)
}
// 1. An explicit choice WINS, in both directions.
if err := s.SetLanguage("hu"); err != nil {
t.Fatal(err)
}
if got := s.GetLanguage(); got != "hu" {
t.Errorf("chose hu with config en = %q, want hu — the household's choice lost", got)
}
s2 := newSettings(t)
s2.SetConfigLanguage("hu")
if err := s2.SetLanguage("en"); err != nil {
t.Fatal(err)
}
if got := s2.GetLanguage(); got != "en" {
t.Errorf("chose en with config hu = %q, want en", got)
}
// An unsupported config value is ignored, not stored and not rendered.
s3 := newSettings(t)
s3.SetConfigLanguage("klingon")
if got := s3.GetLanguage(); got != "hu" {
t.Errorf("unsupported config language = %q, want hu", got)
}
}
// The config default must NEVER be written to settings.json. If it were, a box would record a
// choice the household never made, and the next config pull could no longer be distinguished from
// a deliberate switch.
func TestConfigLanguageIsNotPersisted(t *testing.T) {
path := filepath.Join(t.TempDir(), "settings.json")
s, err := Load(path, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
s.SetConfigLanguage("en")
// Force a save through an unrelated setter, the way any ordinary use would.
if err := s.SetBackupWindowStart("01:00"); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), `"language"`) {
t.Errorf("the config default was persisted into settings.json — it is now indistinguishable "+
"from a household's choice:\n%s", raw)
}
// And a REAL choice IS persisted, or the control proves nothing.
if err := s.SetLanguage("en"); err != nil {
t.Fatal(err)
}
raw, _ = os.ReadFile(path)
if !strings.Contains(string(raw), `"language"`) {
t.Error("a real choice was NOT persisted — the negative control above is meaningless")
}
}
// SEAM-WIRING PIN (the class with four instances in this project): the setter exists, but is it
// CALLED? `cmd/` is gitignored, so ripgrep skips main.go and a reviewer's search finds nothing —
// which is exactly how a seam gets built and never wired here.
func TestConfigLanguageIsWiredInMain(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "cmd", "controller", "main.go"))
if err != nil {
t.Fatalf("cannot read main.go to check the wiring: %v", err)
}
src := string(raw)
if !strings.Contains(src, "sett.SetConfigLanguage(cfg.Customer.Language)") {
t.Error("main.go never calls SetConfigLanguage — the bootstrap default is an unwired seam, " +
"and every box would start Hungarian whatever the hub said")
}
// It must come from the CONFIG, not from a literal someone pasted to make this pass.
if strings.Contains(src, `SetConfigLanguage("`) {
t.Error("main.go passes a literal to SetConfigLanguage — it must pass cfg.Customer.Language")
}
}
+38 -3
View File
@@ -27,6 +27,13 @@ type Settings struct {
// surfaced to the dashboard as a persistent banner. Not persisted.
LoadWarning string `json:"-"`
// configLanguage (v0.256.0, R-558) is `customer.language` from controller.yaml — the language
// the hub says this box should START in. It is consulted ONLY when the household has never
// chosen: GetLanguage prefers the stored choice, always. Not persisted, and deliberately not a
// field of the JSON: it belongs to the config, which is re-pulled, and writing it into
// settings.json would turn a default into a choice the household never made.
configLanguage string `json:"-"`
// Auth
PasswordHash string `json:"password_hash,omitempty"` // bcrypt hash, overrides controller.yaml
@@ -819,12 +826,40 @@ func (s *Settings) SetBackupWindowStart(start string) error {
return s.save()
}
// GetLanguage returns the household's dashboard language, normalised: anything unset or unknown is
// Hungarian (i18n.Default).
// SetConfigLanguage records the language controller.yaml says this box should start in.
//
// Called once at startup and again after each config pull. It never touches s.Language: a
// re-delivered config must not be able to change what a household chose.
func (s *Settings) SetConfigLanguage(lang string) {
s.mu.Lock()
defer s.mu.Unlock()
s.configLanguage = lang
}
// GetLanguage returns the household's dashboard language, normalised.
//
// THE ORDER, and it is the whole of the bootstrap rule (R-558):
//
// 1. What the HOUSEHOLD chose, from settings.json. An explicit choice always wins, and it wins
// forever — a later config pull cannot unseat it.
// 2. What the hub said to start in (`customer.language`), for a box nobody has told yet. This is
// what makes "create the customer as English" produce an English box on first boot.
// 3. Hungarian.
//
// Steps 1 and 2 are DIFFERENT KINDS OF FACT and the difference is the reason this is not one field:
// an empty s.Language means "never chosen", which is not the same as "chose Hungarian". Collapsing
// them would make an English household's box silently revert to Hungarian the first time anyone
// looked at a config default.
func (s *Settings) GetLanguage() string {
s.mu.RLock()
defer s.mu.RUnlock()
return i18n.Normalize(s.Language)
if i18n.IsSupported(s.Language) {
return s.Language
}
if i18n.IsSupported(s.configLanguage) {
return s.configLanguage
}
return i18n.Default
}
// SetLanguage stores the household's dashboard language and saves. Only a supported language is