Files
felhom.eu/documentation/architecture/10-localisation.md
T
admin 5b6ade033f
gates / gates (push) Successful in 23s
i18n slice 1 release C (controller v0.250.0): R-556 CLOSED, live evidence, R-565..R-568, decision 6 superseded
- 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
2026-09-17 18:50:16 +02:00

19 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] 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)

  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).

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.