- audits/i18n-slice1-2026-09-17/C/: live before/after/en/back on demo-hp (CSRF redacted), hub report hu/en/hu, red-proofs, the switch fixture diff (89 of 89), green gate. - 10-localisation.md: §2.2 executeTemplateLang facts, §2.3 what stays Hungarian + the English test's ASCII blind spot, §3 decision 6 SUPERSEDED 2026-09-17, §5 formal ceiling 16 and its under-count, English retrieval stems; §10 slice 1 done; §11 decision 6 struck. - Register: R-556 closed to CLOSED-ITEMS; R-516 extended; R-565 (ASCII-only Hungarian invisible to the English page test), R-566 (three app-name page titles), R-567 (wizard nav highlight), R-568 (disk rows reorder). 260 -> 263 open. - Capability map row, STATUS (needs you: the floor delivers the switch). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
19 KiB
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/*.mdfori18n|locali|languagefound 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) anden.json, embedded (internal/i18n/i18n.go). Flatkey → 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 arekey.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.loadTemplatesparses one set per supported language throughparseTemplateSet=ParseFS+i18n.Expandper file, naming templates exactly asParseFSdoes.s.tmplstays 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]
executeTemplaterenders 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 throughexecuteTemplateLang: the language set andLang, and deliberately NOT the session CSRF fields or the escrow reminder (R-543) thatexecuteTemplateadds. Pinned byTestDirectRenderHandlersFollowLanguage(the real routes) andTestI18nDirectRenderPagesHaveNoAdminChrome(reminder genuinely due, absent from the page data of all six and from the guest page in both languages). No renderer usess.tmpldirectly any more. - [FACT] Go-side copy on a converted page: the handler names its title key (
data["TitleKey"];TestHandlerTitleKeysMatchHungarianTitlepins each handler's Hungarian literal equal to its hu.json value); the non-Hungarian sets override the copy-producing funcsstateLabel,timeAgo,timeAgoStr,nextRunLabel,statusText,infraMetafrom the bundle (web/i18n_web.golocaleFuncs). The Hungarian funcs are not touched;TestLocaleFuncsHungarianBundleMatchesFuncMappins thathu.jsoncarries 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 — andhtml/templatecannot 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 withCopied'). 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.jsonlanguage(hu|en; empty readshu,Settings.GetLanguage);POST /settings/language(session CSRF like every form;lang,back; redirect drops the query);?lang=hu|enper-request override, never persisted; the hub report's"language"field, always present, set at all four report build sites incmd/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,enat 12:54:43Z,huat 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.yamlnext tocustomer.id/name/domain/email(hub/internal/configgen/configgen.go), and the box uses it only while the household has never chosen (settings.jsonempty). 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 independentjson.Unmarshalcalls and noDisallowUnknownFields(hub/internal/api/handler.gohandleReport), 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.Textfalls back to Hungarian and flags it; the loader logsi18n: en template set shows Hungarian for N markers;Bundle.Msgof a key absent everywhere returns the key (visible, never blank) andTestBundleKeysUsedExistInHungarianrefuses to ship one. - [FACT]
controller/scripts/i18n_missing_gate.py(incontroller_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_CEILINGas 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.mdL56). A household still reaches it when bootstrap ingestion leavescustomer.idempty (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
%dand 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/webGo, 2 in templates that disagree with each other (2006. 01. 02. 15:04vs2006-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] Four places decide behaviour by matching Hungarian wording (inventory §3): an HTTP status
(api/router.go:478), an off-site quota classification (backup/offbox.go:186), an alert's placement
(web/alerts.go:208), and a persisted warning compared by marker (web/handlers.go:927). Each
breaks the day the words change in either language. R-553 fixes them first, before slice 2 touches a
single Go string.
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 | R-557 (after R-553) | Go-side customer strings: 947 shown + 184 errors; flash-in-URL → keys; country names; alert texts | a page's server messages follow the language | 16–20 h |
| 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 |
11. Operator decisions
These are rulings, not proposals. Anything specced against a different assumption is wrong.
2026-09-17 (on the starter)
- Scope: what the household sees — the controller, its e-mails, the guide, the app catalog. Not the hub's operator pages.
- 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.
- Fallback: a missing English line shows the Hungarian one and is counted; never a key or a blank.
- 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
- Mechanism (b2) — §2.1.
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).
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.