Files
felhom.eu/documentation/architecture/10-localisation.md
T
admin 4df2cd5174
gates / gates (push) Successful in 22s
docs: controller v0.255.0 — the globe fix on the sign-in-flow pages
R-579 filed and closed the same day: five shell templates loaded style.css
with no cache-buster, so a browser holding the pre-0.254.0 file rendered the
new globe unstyled; and the globe sat outside the card.

- STATUS.md rewritten for the operator: what was seen, why, the third defect
  found while fixing it (version disclosure on the guest share page, caught by
  TestShareGuest_HeadersTilesNoAdminChrome), and the one decision left —
  raise the fleet floor to 0.255.0, with what happens either way.
- 10-localisation.md §3: the shells' asset tag, and why the two guest pages get
  an opaque tag rather than the version.
- Audit D: the parity diff (91 of 106 fixtures identical, every dashboard page
  among them) and the live endpoint evidence from demo-hp guest 9201.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 15:12:20 +02:00

35 KiB
Raw Blame History

10 — The product in more than one language

LIVING DOCUMENT. Every localisation slice updates this file in the same session. Opened 2026-09-17 by the localisation starter, AFTER its spike ran (controller v0.247.0). Before it, no architecture document mentioned localisation at all (a grep of architecture/*.md for i18n|locali|language found one incidental line in 07).

Marks, as in 07 and 02: [DESIGN] — a decision taken, not derived from code. [FACT] — observed, with a citation. Unmarked means not yet classified.

This file carries the REASONING. The register (backlog/OPEN-ITEMS.md, R-553..R-562) carries the work. The numbers are in audits/I18N-INVENTORY-2026-09-17.md. The source is the truth.


1. The rule that makes this safe

[DESIGN] The Hungarian product must render byte-for-byte the same before and after every slice — measured page by page, never by reading. A household that never switches must not be able to tell a localisation release happened. English goes on top of an unchanged Hungarian product.

[FACT] It is enforceable, and enforced for the three converted pages: TestI18nParity (felhom-controller/controller/internal/web/i18n_parity_test.go) renders 14 fixture states of the launcher, /backups and /apps/<slug> in Hungarian and compares them with HTML captured from a clean worktree of origin/main 89dd3e94b1de before any template carried a marker. One changed byte in hu.json fails it naming the page and the line (red-proof, felhom-controller/REPORT.md). Fixtures are captured from unconverted templates and never regenerated to make a conversion pass — that is the release gate for every slice.

The one normalisation: relative ages („3 napja") become „# napja", because they come from the wall clock.

[FACT] And live, on demo-hp guest 9201 (2026-09-17, endpoint-level): the launcher, /backups and /apps/privatebin fetched in Hungarian on 0.246.0 and again on 0.247.0 are identical apart from the version string and the CSRF token, with equal raw byte counts (44 462 / 46 250 / 42 081), and again after a round trip through English (audits/i18n-2026-09-17/live/).


2. The mechanism, as measured

2.1 What was chosen, and why

[DESIGN] A flat message bundle per language, expanded into the template BEFORE html/template parses it. Decided by CC in the spike (the starter delegated the choice, §4 1.1 of the task).

option cost verdict
(a) golang.org/x/text/message catalogs + a T func a new external dependency; its value is gender/plural/ordinal selection not needed: the inventory found no gender agreement and only count plurals; Hungarian does not inflect after a numeral
(b1) flat bundle, runtime T template func every string passes through the contextual escaper: +, ', " change bytes; inside <script> a string becomes a quoted JS literal — 618 of the 1 867 template strings are in <script> rejected: breaks byte parity by construction
(b2) flat bundle, expanded at template LOAD one parsed template set per language (startup parses every template once per language — not measured); a translation is raw source text, so its context safety must be tested chosen: the Hungarian set is parsed from the same bytes, in the same escaping contexts — parity holds by construction and is then measured

2.2 How it works

  • [FACT] Bundles: controller/internal/i18n/locales/hu.json (authoritative — every key) and en.json, embedded (internal/i18n/i18n.go). Flat key → text. A value may carry template actions ({{.RecoveryAbandonDate}}) and inline markup (<strong>, <a>): those are the message's parameters, kept inside one message so a translator can move them. English plurals are key.one / key.other (Bundle.Plural), and a template value may also branch ({{if eq $n 1}}file{{else}}files{{end}}).
  • [FACT] A converted template carries {{T "key"}}. Server.loadTemplates parses one set per supported language through parseTemplateSet = ParseFS + i18n.Expand per file, naming templates exactly as ParseFS does. s.tmpl stays the Hungarian set, so every renderer that predates i18n is unchanged.
  • [FACT] An undefined key is left in place, so the set fails to load („function T not defined") — loud at startup and in every render test (TestExpandLeavesUndefinedMarker).
  • [FACT] executeTemplate renders dashboard pages in the request's language. Since slice 1 (v0.249.0 recovery, v0.250.0 the rest) the pages outside the dashboard chrome — login, claim, recovery, both guest share pages, the catch-all — render through executeTemplateLang: the language set and Lang, and deliberately NOT the session CSRF fields or the escrow reminder (R-543) that executeTemplate adds. Pinned by TestDirectRenderHandlersFollowLanguage (the real routes) and TestI18nDirectRenderPagesHaveNoAdminChrome (reminder genuinely due, absent from the page data of all six and from the guest page in both languages). No renderer uses s.tmpl directly any more.
  • [FACT] Go-side copy on a converted page: the handler names its title key (data["TitleKey"]; TestHandlerTitleKeysMatchHungarianTitle pins each handler's Hungarian literal equal to its hu.json value); the non-Hungarian sets override the copy-producing funcs stateLabel, timeAgo, timeAgoStr, nextRunLabel, statusText, infraMeta from the bundle (web/i18n_web.go localeFuncs). The Hungarian funcs are not touched; TestLocaleFuncsHungarianBundleMatchesFuncMap pins that hu.json carries the same words they return.

2.3 What a translation must not do (pinned by tests)

  • Carry different parameters. Same {{.Field}} set and same printf verbs as the Hungarian (TestBundleParametersMatchAcrossLanguages).
  • Break its context. Expansion is textual; inside a JS string a bare '/"/\ or newline ends it, inside a double-quoted attribute a " ends it — and html/template cannot see this, because it reads the expansion as the author's own source. A value may carry a quote only where the Hungarian carries the same one (TestI18nJSContextValuesAreSafe, red-proofed with Copied'). English therefore avoids apostrophes in JS contexts („do not", not „don't"); elsewhere it uses ’.
  • Render blank or raw. No raw key, no marker, no element empty in English that is not empty in Hungarian (TestI18nEnglishPages).

[FACT] What stays Hungarian on the English pages, live (v0.250.0, all 31 slice-1 templates converted): Go view-model text (flash lines, errors, the backup-target banner and offer, the whole-guest tier labels, the update badge „Naprakész", the catch-all's status sentence), three page titles built in Go around an app name (logs, deploy, tier-2 settings — R-566), and catalog copy (tagline, use cases, first steps) — slices 2 and 5. Deliberately left in a template: „Fut…" on backups_remote, which the page's own JS reads (R-563). Evidence: audits/i18n-slice1-2026-09-17/{A,B,C}/live/.

[FACT] Measured limits of the English page test. (1) It subtracts fixture DATA strings before looking for Hungarian; since slice 1 release A only data strings of ≥ 2 words or ≥ 12 characters are subtracted, so a one-word template title is no longer masked. (2) It sees accented Hungarian only. An ASCII-only Hungarian word left in a template („mp", „ db", „jelenlegi:", „FIGYELEM:", „, majd a(z)") passes it on the English page; release C found six such fragments by eye, none by a test (R-565).


3. Who picks the language, and how it flows

[DESIGN] Operator decision 2 (2026-09-17, agreed): the operator sets it per customer on the hub at creation (default Hungarian); the household can switch it on their dashboard; the box reports it so the hub's e-mails follow.

  • [FACT] Built in v0.247.0: settings.json language (hu|en; empty reads hu, Settings.GetLanguage); POST /settings/language (session CSRF like every form; lang, back; redirect drops the query); ?lang=hu|en per-request override, never persisted; the hub report's "language" field, always present, set at all four report build sites in cmd/controller/main.go.
  • [FACT] Live 2026-09-17 on demo-hp: POST /settings/language lang=en → 302, settings.json "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 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 is therefore the household's choice. The hub decodes the report with independent json.Unmarshal calls and no DisallowUnknownFields (hub/internal/api/handler.go handleReport), so the field was additive.

[DESIGN] Decided by CC in the spike — operator may reverse: the switch is SHOWN only on a page that is not Hungarian, or on a request carrying ?lang=. One answerable sentence: should a Hungarian household see an English switch while only three pages are English? Options: (1) show it to everyone now — cost: one click from a half-English dashboard, and every page but three keeps a Hungarian body; (2) hide it until slice 1 converts the remaining pages — cost: a household cannot discover English yet, which no household has asked for. Chosen (2): it follows rule §1 (nothing visible changes) and is reversed by deleting one condition in addLanguageData. The way back from English is always on screen.

[FACT] SUPERSEDED 2026-09-17 by slice 1 release C (controller v0.250.0): every template is converted, so the condition is deleted and the switch is on every dashboard page, in both languages. The 89 layout parity fixtures were re-captured and each equals its predecessor plus exactly one switch form (audits/i18n-slice1-2026-09-17/C/switch-fixture-diff.txt); TestLanguageSwitch_EndToEnd pins that a Hungarian household with no ?lang= sees it. Live on demo-hp: every Hungarian dashboard page equals its 0.249.0 fetch once the switch form is removed (live numbers aside); login, claim and the catch-all are unchanged. A Hungarian household sees it only when the fleet floor reaches 0.250.0 — the operator's cadence (R-242/R-468); no floor was raised.


4. Fallback

[DESIGN] Operator decision 3 (2026-09-17, agreed): a missing English line shows the Hungarian one and is counted by a gate; a missing line never shows a key or an empty box.

  • [FACT] Bundle.Text falls back to Hungarian and flags it; the loader logs i18n: en template set shows Hungarian for N markers; Bundle.Msg of a key absent everywhere returns the key (visible, never blank) and TestBundleKeysUsedExistInHungarian refuses to ship one.
  • [FACT] controller/scripts/i18n_missing_gate.py (in controller_gates.py, pre-push): keys exist, no orphans, the English gap is a ratchet — EN_MISSING_CEILING (0 today) convicts above AND below, so it can only be lowered deliberately. Raising it is a decision recorded here.

5. The gates, per language

[FACT] Found in the spike: moving copy out of the templates blinded four gates and staled one. The retrieval-promise gate went red on STALE allowlist entries the moment the first page converted; the emoji, native-confirm and secret-in-markup gates silently stopped seeing the moved copy. The HEAD versions of the emoji and retrieval gates PASSED a planted emoji and a planted retrieval promise in the bundles. They now judge the page as rendered in each language (controller/scripts/i18n_bundle.py read_template(path, lang)); mojibake scans the bundles; decoys for each are in controller/scripts/test_gate_decoys.py.

[DESIGN] Voice, per language:

  • hu — the product speaks in „te". The converted copy carries formal („ön") forms (R-516). They are not fixed by a localisation release (§1), so the gate counts them against HU_FORMAL_CEILING as a ratchet: a new one convicts, fixing one lowers the ceiling. The ceiling followed the conversion: 6 (spike) → 10 (release A) → 12 (release B) → 16 (release C). [FACT] Measured limit: the stem list is short — on the release C pages it counts 4 formal forms, while a wider list („írja be", „adja meg", „válassza", „biztosan eltávolítja", „engedélyezze", …) finds at least 22 keys (R-516).
  • en — second person, plain: no „please", no „kindly".

[FACT] The retrieval-promise gate scans English too since slice 1 release A (EN_PATTERNS, ALLOWLIST_EN, decoys). Translating exposed that its Hungarian stems miss split verbs („állíthatók vissza") — R-564. The hub copy gate and the catalog have no language concept (scripts/hub_copy_gate.py stems end in a Hungarian character class; catalog_gates.py checks no copy).


6. Deliberately out

  • [DESIGN] The hub's operator pages — the operator reads them; they stay English (decision 1).
  • [DESIGN] The first-boot wizard (controller/internal/setup/, 8 pages, 95 strings) — out of scope and obsolete (decision 4, 2026-09-17; 02-controller-module-map.md L56). A household still reaches it when bootstrap ingestion leaves customer.id empty (inventory §2.7). Its deletion is R-554.
  • [FACT] App containers' own UIs (Uptime Kuma, PrivateBin…) — not Felhom's copy (R-516 items 5-6).

7. The catalog's copy model

[DESIGN, proposed — slice 5 decides after seeing it work] A sibling block per language inside .felhom.yml, not a sibling file:

description: Titkosított jegyzetek…
app_info:
  first_steps: [...]
i18n:
  en:
    description: Encrypted notes…
    app_info:
      first_steps: [...]

Why: one app's copy stays in one file a reviewer sees whole; the controller's yaml.Unmarshal ignores unknown keys (internal/stacks/metadata.go L317), so older controllers are unaffected; a missing English field falls back field by field. Cost: 835 strings, ~4 984 words; the catalog copy gates need an English rule. Read on privatebin, nextcloud and immich (inventory §2.5).


8. Dates, numbers, plurals — as found

  • [FACT] Plurals: 42 Go format strings with %d and 7 template runs with a numeric parameter (inventory §2.2/2.1). Hungarian needs one form; English two.
  • [FACT] Dates: 10 layout literals in internal/web Go, 2 in templates that disagree with each other (2006. 01. 02. 15:04 vs 2006-01-02 15:04), 25 more in the controller; 9 copy-producing helpers (timeAgo „%d perce", nextRunLabel „ma"/„holnap", pruneLabel „vasárnap"…).
  • [FACT] Sizes print a decimal POINT (%.1f GB) — Hungarian convention is a comma. The Hungarian pages are already un-Hungarian there; §1 keeps it that way until someone decides otherwise (R-562).
  • [FACT] Word order: 237 Go format strings and 111 concatenations; in templates, JS sentences split around + name + („Az alkalmazás (" + name + ") törölve lett.") — translatable but fragile.
  • [FACT] Flash messages travel inside the redirect URL (?flash=<text>) in the language of the handler that redirected — slice 2 moves them to keys.

9. Compared, not shown — latent bugs a translation would trigger

[FACT] There were FIVE, and they are fixed (controller v0.251.0, R-553 + R-563, 2026-09-17). Four in Go and one in a page script. Each producer now attaches a machine-readable signal and each decision reads that signal; every Hungarian sentence is byte-identical (pinned per producer), and the hub report is unchanged.

decision read before reads now
deploy HTTP status — api.deployStatusFor „kötelező", „memória", "does not exist", "already deployed" stacks.ErrRequiredField, ErrPathMissing, ErrNotEnoughMemory (400); ErrAlreadyDeployed (409)
off-site failure class — ClassifyOffsiteFailure „tárhelykeretet" backup.ErrOffsiteQuota
alert placement — web/alerts.go „meghajtó" / „adattároló" in the warning monitor.WarnKindStorageNotSeparate, carried in HealthReport.WarningKinds (internal; NOT on the wire)
the stale off-site note — offboxWarningDisplay „nincs mentésre jelölt alkalmazás" in the PERSISTED text settings.OffboxTarget.LastWarningKind = backup.OffboxWarnNoAppsSelected
the remote-backup poll — backups_remote.html the displayed word „Fut" data-status="running" on the status element (R-563)

[DESIGN] The rule this establishes: a text signature may remain ONLY where the text is not ours. The classifier's restic and ssh signatures stay, because that output is neither written nor translated here; every sentence this product writes is display, and a decision reads a signal beside it. internal/util.KindErrorf exists for exactly that: the same message bytes fmt.Errorf produced, plus a sentinel for errors.Is — never fmt.Errorf("%w: …"), which would prepend the sentinel's own text to the customer's sentence.

[FACT] One exception, with an end date: a box upgraded to 0.251.0 carries the OLD persisted note with no kind until its next off-site run, so offboxWarningDisplay keeps the substring test for kind == "" only. Slice 2 (R-557) must not translate that producer until R-570 closes.

[FACT] Measured after the fix (audits/r553-2026-09-17/): the inventory's comparison detector finds no template compare (was one) and no Go compare of ours — the two remaining hits are the known false positive (an [INFO] log line) and the deliberate legacy fallback above; planted decoys prove the detector still convicts. What it does NOT cover: four API handlers pick their status by matching ENGLISH internal words — the same shape, one language over, filed as R-569 rather than folded in here.


10. The plan

Costs are CC-hours, estimated from the spike (mechanism + three pages + layout + gates ≈ one working session). Each slice ends with the parity gate green for every page it touched.

slice row what proves cost
0 (done) — mechanism, 3 pages + layout, setting, report field, gates Hungarian unchanged by measurement; English reachable spent
1 (done, v0.248.0–v0.250.0) R-556 the other 31 dashboard templates (~1 500 strings, 600 of them JS), parity fixtures per page; switch shown to everyone; English retrieval stems; the extractor's ASCII word list reviewed per page the whole dashboard in English with Hungarian byte-identical 12–16 h, three releases
2 (done, v0.252.0–v0.254.0) R-557 Go-side customer strings; flash-in-URL → keys; country names; alert texts; the 179 error messages; the saved notes; the globe a page's server messages follow the language CLOSED 2026-09-18
3 R-558 hub: per-customer language at creation; configgen renders it; customerMessages (39), the 5 lifecycle mails and the self-bind page in English; dispatcher reads the reported language a household's e-mails arrive in its language 6–8 h, one hub + one controller release
4 R-559 console banner (34 lines, console-font limits) and the download page an English household meets English from the first boot screen (in scope — ruling 1b) 4–6 h + an ISO train
5 R-560 catalog: §7 format, controller reads it, 835 strings / ~5 000 words, catalog copy gates per language an app card, its settings and its first steps in English 12–16 h
6 R-561 the volunteer guide in English (~1 600 words); then a stranger's first hour in English, the 2026-09-14 walk; closes R-516 against the inventory a newcomer can set up and use a box in English 6–8 h

10.1 Slice 2, as measured (release A, controller v0.252.0, 2026-09-18)

[FACT] The counts the plan carried were the inventory's, and they moved. At slice 2's base commit 736f54b49610 there were 1 120 Hungarian Go literals, not 1 141; handlers.go 120 (not 121), offbox_handlers.go 70 (not 72), storage_handlers.go 45 (not 48), api/router.go 18 (not 19). Release A converted 226 of them and added 237 country keys.

[DESIGN] A flash is a KEY, because it is read by a different request than the one that wrote it. ?flash=<key>&fa=<param>…, built by flashQuery, resolved by s.flashFrom. A value the bundle does not know is shown verbatim — that is what keeps a link already in a customer's tab, a bookmark or a back-forward cache reading correctly, and it is the same rule that makes a hand-typed ?flash= harmless (TestFlashKeyRoundTrip). The same shape one layer in: an alert banner is built by a background health cycle and read minutes later, so Alert carries MessageKey + MessageArgs and GetAlerts(lang) renders on the way out.

[DESIGN] Word order is Go's explicit argument index, not a second placeholder syntax. The plan proposed a named-parameter ({{.Name}}) form for multi-parameter Go messages. English reorders with %[2]s, which fmt already understands, so the Hungarian value stays the format string the code always had, byte for byte — and that is precisely what the parity gate compares. A second syntax would have needed an exception in the measurement, which is the thing this slice cannot afford. TestBundleParametersMatchAcrossLanguages counts an indexed verb as one verb.

[FACT] Parity for Go copy is measured, not read. controller/scripts/i18n_go_parity.py freezes every Go string literal at the base commit (7 467, scripts/i18n_go_base.json) and refuses a key whose Hungarian is not that text byte for byte — reworded, re-punctuated, split or joined. scripts/i18n_go_keys.json records what each key replaced (a string for one literal, an ordered list for a join, {"inside": …} for the three sites where the Hungarian was already URL-escaped inside a redirect target). In controller_gates.py; three decoys, each seen to convict.

[FACT] The gate found a live defect in its own first version — the R-565 class again. The capture filtered literals through an ASCII-Hungarian word list and missed seven real ones („Naponta", „5 percenkent", „Eletjel (Heartbeat)", „Adatbazis mentes", „Biztonsagi mentes", „Mentes integritas", „Rendszer allapot"); the gate refused the keys citing them. The filter is gone. The index answers one question — did the base commit contain this text? — and an unfiltered index answers it for every string. The lesson generalises: a word list is a list, and nobody knows which words are missing.

[FACT] Two claims in the plan that live source disproved, both about the wire.

  1. cloudflare/countries.go is not on the wire. report/builder.go carries country codes; no name leaves the box, and CountryName() has no caller. The 237 names were therefore free to translate, and are — at DISPLAY, in api/geo.go, re-sorted per language. The table stays: it validates codes.
  2. The hub does not compose every customer mail from the event kind. FormatCustomerEmail (hub/internal/notify/templates.go L167-210) uses customerMessages[eventType] when it has one and falls back to the controller's message when it has none, appending it as „- Üzenet: %s" whenever the two differ; several event types carry no entry precisely so the controller's sentence IS the mail. So notify/notifier.go's 31 messages are customer-facing text on the wire. The verdict (do not translate them here) was right; the reason was not, and the reason is what slice 3 has to act on.

[DESIGN] On the wire = not translated, and now measurable. internal/monitor and internal/notify carry wire goldens capturing those producers' exact bytes at the base commit. A later slice that translates one fails a test that prints both strings. R-558 is the row that unfreezes them, by teaching the hub which language to compose in.

[FACT] Still Hungarian after release A: 894 literals — 176 of them fmt.Errorf/errors.New (release B), the text a background run persists (release C), the R-570 producer, handler_debug.go (R-574), two copy-producing funcmap helpers (R-572) and the two channel-health banners (R-573).

[DESIGN] Persisted text (release C) — the plan's §16 option 1, its own stated default. A note is written in the box's language at the time it is written, and a household that switches sees last night's note in the old language until the next run rewrites it. The alternative (store a code, render live) costs a dozen new persisted fields and a legacy path for each — the R-570 shape, a dozen times over. Recorded here when release C ships.

10.2 Slice 2 release B — an error carries the key of the sentence it is (v0.253.0)

[DESIGN] An error is made where there is no language and printed where there is no key, so it carries the key across. util.MsgError(key, args…) / MsgErrorf(kind, key, args…). All 179 Hungarian error literals are converted; zero remain (an ASCII-fragment search with a positive and a negative control, case-insensitively, plus an accented-letter search).

Four properties, each one a different failure if it is missing:

property the failure without it pinned by
Error() is the Hungarian, byte for byte 179 producers could not be converted until every printer was, in one commit TestMsgErrorKeepsKindAndHuText
errors.Is answers for the kind and a wrapped cause KindErrorf returned the kind alone; a caller testing for the cause silently stops matching TestMsgErrorUnwrapsTheCauseToo
an error ARGUMENT renders recursively „formázás sikertelen: %w" is a sentence wrapping a sentence; half of it would stay Hungarian TestErrTextRendersAWrappedMessageErrorToo
a FOREIGN error prints verbatim restic, docker, ssh and the stdlib are not ours (§9's rule) TestErrTextFallsBackVerbatim

[DESIGN] Plurals are a BUNDLE rule, not a call-site flag. A key that carries .one/.other forms in a language is a plural key, and its FIRST parameter is the count (i18n.Bundle.form). Hungarian never carries them, so a Hungarian render is unchanged at every count. One answerable sentence: where does the fact that English needs two forms live? Options: (1) a flag at every producer of every count message, in three packages — cost: the one somebody forgets reads „3 app is not running", with nothing to catch it; (2) the bundle. Chosen (2): the bundle is where a translator works, and it needs no change at any call site. .one/.other become RESERVED suffixes — TestNoOrdinaryKeyEndsInAPluralSuffix, which caught a real collision (alert.deadapp.one) the day the rule landed.

[FACT] The parity gate has a blind spot, measured rather than reasoned (R-576). The bulk converter silently dropped the continuation of a multi-line concatenation, damaging 7 producers — and i18n_go_parity.py stayed GREEN, because every surviving fragment WAS a byte-equal base-commit literal. Its question is "is this text real?"; it cannot ask "did the call keep all of it?". Two BEHAVIOUR tests caught it, because they assert the sentence a customer reads. A structural gate over the TEXT cannot see a defect in the CALL — the general form, and the reason a rendered test is not made redundant by a gate.

[FACT] A second instrument defect, same session (recorded because the shape recurs). The script counting what was left was case-sensitive, so it reported "0 error literals remain" while five did („occ parancs sikertelen", „hub hiba", „OnlyOffice aldomain nem ismert" ×2). That is R-565's shape inside the measurement. Every "no Hungarian left" claim in this slice is made case-insensitively and with both controls.

[FACT] One gap release B could not close: R-575. memoryVerdict's soft overcommit WARNING is a string with no error to carry a key and no language where it is built, so it renders Hungarian on an English page. The copy is in the bundle; only the render is fixed. Named in the code, filed as a row.

10.3 Slice 2 release C — the saved notes, and a globe (v0.254.0). SLICE 2 CLOSED.

[DESIGN] A note a background run SAVES is written in the BOX's language at write time (operator ruling, §16 option 1). ~70 producers. The consequence, recorded because it is the cost of the choice: a household that switches language sees the previous run's note in the old language until the next run rewrites it — usually the next night. The alternative (store a code, render live) needs a dozen new persisted fields and a legacy path for each: the R-570 shape a dozen times over. EndRestoreOp now receives no Hungarian literal from anywhere.

[FACT] A deadlock, introduced and caught by the suite hanging (R-578). UpdateOffboxStatus holds the settings WRITE lock while running its callback; boxLang() reads the language through the READ lock; sync.RWMutex is not reentrant. A note rendered inside that callback deadlocks holding the settings lock, which wedges everything else on the box that touches settings.json. The only symptom was go test going from 8 minutes to a 25-minute timeout. The general rule this establishes: a helper that takes a lock must never be called from inside a callback that holds one — and a hang is the worst symptom to diagnose, which is why R-578 asks for a gate rather than one test in one package.

[DESIGN] Decision 6, superseded a second time: the switch is a GLOBE, and a visitor's language is their own. One answerable sentence: how does someone who cannot read Hungarian find the way out of Hungarian? Options: (1) keep two text links („Magyar"/„English") in the sidebar footer — cost: they wrap at the sidebar's width, and finding them means recognising two words as links; (2) one globe, the symbol every web user already reads as "language". Chosen (2), <details>/<summary> so the menu needs no script and a screen reader announces it. Language names inside are shown in their own language and are never translated. Drawn inline rather than in the icon sprite, because the sprite lives only in layout.html and the pages outside the dashboard chrome have their own shell.

[DESIGN] Who the page is FOR decides where its globe posts, and getting that wrong makes the button do nothing.

page reader globe posts to their choice lives in
every dashboard page the household, signed in /settings/language (session CSRF) settings.json
/recovery — an AUTHENTICATED route the household, signed in /settings/language settings.json
/login, /claim a visitor, no session /lang (no CSRF) the felhom_lang cookie, their browser
the two guest share pages, the catch-all a stranger / nobody no globe — (R-577, the operator's)

langFor's order is fixed: ?lang= → the household's setting when a session exists → the cookie when there is none → the setting → hu. A signed-in household never reads the cookie, so they cannot inherit a language a previous visitor picked in the same browser. The recovery row above was measured live before it was right: an anonymous form there sets a cookie that langFor then ignores, and the button appears to do nothing.

[FACT] Where the globe SITS, and a stale-stylesheet trap that only a screenshot could show (v0.255.0, R-579). On the pages outside the dashboard chrome the globe is inside the card, centred under the footer, with the menu opening upward — the shared .lang-globe-menu rule, so the two surfaces cannot drift apart. It was first placed at the corner of the VIEWPORT, which read as a stray browser control rather than part of the page. And five of those shells requested style.css with no ?v=, so a browser holding a copy from before the globe existed kept serving CSS with no .lang-globe rules and it rendered as a bare, unstyled <details>. Every test passed, because they all read the markup and the fault was in which CSS file the browser fetched. Version is now set in executeTemplateLang, once, for every shell.

[DESIGN] POST /lang is CSRF-exempt, for a reason narrow enough to check. The only achievable effect of a forged request is to change the language of the page the victim's own browser shows them. It writes one display-only cookie, reads nothing, touches no setting, and safeBackPath refuses a protocol-relative //host as well as an absolute URL — "starts with /" alone is not the test, because a browser reads //evil.example as another origin. If that handler ever gains a second effect it needs CSRF that day. That is the anonymous surface 04-control-plane-authorization.md governs: changing what a visitor reads is within it, changing anything the household owns is not.

[DESIGN] §16, the operator's default, taken: a successful CLAIM carries the visitor's language into the household's setting. Someone who switched the claim page to English and then claimed the box chose English. Only on success, and only there — the one moment an anonymous visitor becomes the household.

[FACT] The parity exceptions, measured rather than asserted. 106 fixtures, a real (LCS) diff against the fixtures as they stood at v0.253.0: 3 change shapes (the footer; the recovery globe; the login/claim globe) and 5 byte-identical, which are exactly the pages that must not change. audits/i18n-slice2-2026-09-18/C/parity-exception-diff.txt. A second measurement error worth keeping: the first attempt compared LINE BY INDEX, and an insertion shifts every line below it — it reported 60 520 changed lines and measured nothing. A line-index compare is not a diff.


11. Operator decisions

These are rulings, not proposals. Anything specced against a different assumption is wrong.

2026-09-17 (on the starter)

  1. Scope: what the household sees — the controller, its e-mails, the guide, the app catalog. Not the hub's operator pages.
  2. Who picks: the operator per customer at creation (default Hungarian); the household switches on the dashboard; the box reports it so the hub's e-mails follow.
  3. Fallback: a missing English line shows the Hungarian one and is counted; never a key or a blank.
  4. The first-boot wizard: out of scope, obsolete — a separate row to delete it (R-554).

Decided by CC in the spike — operator may reverse

  1. Mechanism (b2) — §2.1.

  2. Switch hidden while only three pages are English — §3. Superseded 2026-09-17 by slice 1 release C (v0.250.0): every template converted, switch shown to every household (§3). Superseded again 2026-09-18 by slice 2 release C (v0.254.0): the switch is a GLOBE, it is on the sign-in and claim pages too, and a visitor's choice lives in their own browser (§10.3).

  3. A successful claim carries the visitor's language into the household's setting (§16 default, taken 2026-09-18). Only on success; every other anonymous request leaves settings.json alone.

2026-09-17 (evening) — the two open questions, ruled

1b. The console banner and the download page ARE in scope (operator: „yes"). Slice 4 (R-559) is unblocked; it stays late in the plan and rides an ISO release train. 7. Interface nouns translate (operator: „translate"). „Indítópult" → Launcher, „Vezérlőpult" → Dashboard, „Biztonsági mentés" → Backup, as the spike built. App names and „Felhom" stay as they are. Every slice follows this.