Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
17 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] Only
executeTemplateis language-aware (server.go). Login, claim, recovery and the guest share pages render throughs.tmpldirectly and stay Hungarian until their slice. - [FACT] Go-side copy on a converted page: the handler names its title key (
data["TitleKey"]); the non-Hungarian sets override the copy-producing funcsstateLabel,timeAgo,timeAgoStr,nextRunLabel,statusTextfrom 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: only Go view-model text (the backup-target banner and offer, the whole-guest tier labels, the update badge „Naprakész") and catalog copy (the app's tagline, use cases, first steps) — slices 2 and 5. No template copy.
[FACT] Measured limit of the English page test: it subtracts every string the fixture DATA carries (catalog copy, handler messages) before looking for Hungarian — so a template word identical to a data string is masked (the launcher cases pass the nav's „Indítópult" as the page title). The backups and app cases cover the nav; the limitation is recorded, not fixed.
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.
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 6 formal („ön") forms (R-516).
They are not fixed by a localisation release (§1), so the gate counts them against
HU_FORMAL_CEILING= 6 as a ratchet: a new one convicts, fixing one lowers the ceiling. - en — second person, plain: no „please", no „kindly".
[FACT] Not yet per-language: the retrieval-promise gate's stems are Hungarian only — an English
retrieval promise is unscanned (in R-556). 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 | 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.
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.