5b6ade033f
gates / gates (push) Successful in 23s
- 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
292 lines
19 KiB
Markdown
292 lines
19 KiB
Markdown
# 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:
|
||
|
||
```yaml
|
||
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
|
||
|
||
5. **Mechanism (b2)** — §2.1.
|
||
6. ~~**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.
|