Files
felhom.eu/documentation/architecture/10-localisation.md
T
admin 8e9401c6bf
gates / gates (push) Successful in 27s
Localisation slice 5 CLOSED: the catalog speaks English and the floor is at 0.257.0
Part C shipped the same day the pilot was read: fifty apps in three pushes, 1 031 of
1 032 strings. The English Apps list shows ZERO Hungarian app descriptions across all
53 apps — the only Hungarian left on it is the "Naprakész" badge (R-589) and the
language picker naming itself, which is correct.

The Hungarian Apps list is identical to the pre-slice capture once the per-session
CSRF token AND Docker's own "Up N hours" container string are normalised. Both
normalisations are stated in the evidence rather than applied quietly — the second
one moved because two hours of wall clock passed between captures, not because any
copy changed.

Fleet floor raised to 0.257.0 with the declared MinAgent 0.131.0, above the vouched
golden so the declaration carries it. demo-felhom went 0.255.0 -> 0.257.0 by itself
in under 12 seconds and THEN rendered the English tagline: the floor delivered the
feature, not a version string.

Rows: R-593 (papra describes a session-signing key as "the app's subdomain" — the one
string left untranslated) and R-594 (the catalog gate can convict a retrieval promise
but has no way to REGISTER a true one, which the shared vocabulary's design calls
for). R-560 closed. 281 -> 287 rows.

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

51 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).
  • [FACT] Slice 3 Part A, hub v0.118.0 (2026-09-18): the hub READS the reported language and writes the household's e-mails in it. hub/internal/i18n (79 keys, hu authoritative, hu fallback, missing ceiling 0); 56 mail goldens captured from v0.117.0 BEFORE any string moved, and all 56 Hungarian ones pass unchanged. customerMessages/severityLabels are derived from the bundle. Order: last reported → customer_configs.language → hu (Store.CustomerLanguage). message_customer is accepted on POST /api/v1/event for the box's own sentences. The bind page is per-language, with expired pinned to Hungarian so the language cannot become the oracle the text refuses to be. Full design: 05-hub-architecture.md §15. R-555 closed — the language allowlist entry is out of wire_contract_gate.py and the gate now checks the field for real.
  • [DESIGN] Slice 3 Part A as planned — now built; the box half (Part B) is the remaining piece: 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

[FACT] MEASURED 2026-09-20 (controller v0.257.0 + catalog pilot). The proposal below was tried and it holds; what changed under measurement is written out, because the numbers it was specced against were wrong in two places.

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 L316 — verified: this repo constructs no yaml.Decoder at all, so there is no KnownFields anywhere to turn strict), so older controllers are unaffected; a missing English field falls back field by field.

[FACT] The read path is Metadata.For(lang) in controller/internal/stacks/metadata_i18n.go, reached only through LocalizeStacks / LocalizeStackPtr / Stack.MetaFor.

  • For("hu") is the parsed struct with I18n cleared and nothing else, deep-compared against all 53 real catalog files (TestMetaForHuIsIdentity, fixtures in internal/stacks/testdata/catalog/). §1 therefore holds by construction for catalog copy, and LocalizeStacks returns the INPUT SLICE for Hungarian rather than a copy, so nothing on the Hungarian path is even touched.
  • Fallback is field by field, and blank counts as absent (ruling 3). A half-translated app is a legal state — that is what makes the batches pushable one at a time.
  • The three prose lists replace WHOLE (use_cases, first_steps, prerequisites): merging item 3 of one language with item 4 of another produces a list nobody wrote. Every other list is matched by its own key — deploy_fields by env_var, options by value, optional_config groups by match_group (the Hungarian group value they translate; a group has no other identity), its fields by env_var, integrations by target, data paths by path. Position matching mistranslates silently the first time a Hungarian field is inserted above another, and the page still looks right.
  • For never writes through. The metadata lives in the stack manager's cache and is shared by concurrent requests; an in-place merge would put one household's language on another's page.
  • Two copy producers have no request and therefore no language — the integration rows (internal/integrations) and the initial-credentials note (internal/stacks/initialcreds.go). Both are re-taken from the localised metadata in the handler.

[FACT] The numbers, measured on 94bc5febaca2 — the inventory's were close on one count and wrong on another, and both matter to the gate:

inventory / task measured
copy strings 835 1 032 (incl. 13 placeholders the table omitted, and every ASCII label)
with a Hungarian letter 832 832 ✔
ASCII-ONLY Hungarian „Igen", „Nem", „Nincs" — three ~120. Those three do not occur; the three ASCII-only OPTION labels are „Magyar", „Angol", „Magyar + Angol" — language names, not yes/no words

The ASCII-only count is the one that decides an instrument: „Aldomain" (53×), „A szerver domain neve" (53×), „Jelentkezz be: …" (5×), „Magyar", „Angol", „Titkos kulcs", „Oszd meg a linket". An accent-only scan passes every one of them inside an English block — the same blind spot §2.3 records for the English page test (R-565), arriving again in a different repo. So app-catalog-felhom.eu/scripts/check-copy-i18n.py folds and matches word-bounded stems, with a positive and two negative controls printed on every run.

[FACT] The gate, fifth row of catalog_gates.py, static, in the pre-push hook:

  1. FREEZE — every Hungarian copy string equals scripts/copy_freeze/hu.json, captured in its own commit before any translation. Runs on all 53 apps whatever scope is named. A new app must be admitted with --add-app NAME --reason "…".
  2. STRUCTURE — the en block may carry copy fields and nothing else; every key-matched entry must have a Hungarian twin, or it is INERT on the box and the translator never learns.
  3. LANGUAGE — no accented letter, no ASCII-only Hungarian, no „please"/„kindly", no English retrieval promise the Hungarian does not make, the app name and „Felhom" preserved.
  4. CREDENTIALS — the login tokens inside default_creds and the initial-credentials note survive translation verbatim, matched on WORD boundaries.
  5. RATCHET — EN_MISSING_CEILING, convicting above and below, counted over the WHOLE catalog whatever scope is named.

33 decoy cases (R-421). Three defects in the gate were found by its own decoys and not by reading it — R-592.

[FACT] What a gate cannot judge, and the pilot report lists per app: whether an app's „first steps" name the buttons that app's ENGLISH interface actually shows. The Hungarian was written against whatever interface the author had; the English UI label may differ. Those lines are marked unverified and belong to the slice-6 walk (R-561).

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.


10.4 Slice 3 — the hub's e-mails follow the household (hub v0.118.x, controller v0.256.x). SLICE 3 CLOSED.

SHIPPED 2026-09-18. Full design: 05-hub-architecture.md §15.

The hub half (v0.118.0, v0.118.1). hub/internal/i18n, 79 keys, hu authoritative, hu fallback, missing-key ceiling 0. Every customer mail and the public bind page render from it. customerMessages/severityLabels are DERIVED from the bundle, so a sentence is written once and "a new event type enters allowedEventTypes and customerMessages together" now means a line in hu.json plus its English twin. 56 mail goldens per language, captured from v0.117.0 before any string moved; all 56 Hungarian ones pass unchanged. Language order: last reported → created-with → hu; reports.language defaults to EMPTY (never told us ≠ chose Hungarian) and the newest report is found by the autoincrement id, because received_at has second granularity. The bind page's expired state always renders Hungarian — it is the state an unknown token lands in, and the language must not answer what the text refuses to.

The box half (v0.256.0, v0.256.1). message_customer on POST /api/v1/event: the same sentence in the household's language, omitempty, beside the unchanged Hungarian message. A Hungarian household sends no second copy at all, so the fleet's payload is byte-for-byte what it is today and the hub's fallback path stays the one production exercises. 19 producers render both from ONE bundle key; the Go parity gate checks all 19 against the base-commit literals. customer.language bootstraps a new box (stored choice → config → hu) and is never persisted into settings.json. v0.256.1 added the one observable the feature was missing: the push log says [hu-only] or [+household(en)].

Proven live, twice. Part A: a real English e-mail read in the operator's inbox and the Hungarian one 74 seconds later, same button, same box. Part B: the same event pushed 57 seconds apart showing [hu-only] then [+household(en)], with an identical Hungarian sentence both times.

What is still Hungarian for an English household: six producers whose sentence arrives already finished from another package (R-585) — of which offbox_enlarge_blocked matters most, because it has no hub entry so its raw sentence IS the mail. Plus the R-570 sentence. The 15 operator-tier types are Hungarian by design.

Gaps filed: R-581 (same-second report ties, and GetCustomers() still has the shape), R-582 (an English copy-guard ported word-for-word convicted 141 honest sentences), R-583 (the test mail was the one mail that did not follow the language — and the one an operator would use to check), R-584 (credential-bearing probes left in a live guest's /tmp), R-585. R-555 closed.

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.

10.5 Slice 4 — the console banner and the download page (ISO 1.29.0 source, R-559)

SOURCE SHIPPED 2026-09-18. The image is built but NOT published — that is an operator step; see documentation/audits/i18n-slice4-2026-09-18/README.md.

The box's screen is BILINGUAL, and that is a decision rather than a stage. At the moment the pairing code is shown, nobody has told the box who owns it — there is no language to follow. So the three console texts carry the Hungarian block byte-for-byte as before, then one blank line, then an English block, inside the same frame: print_pairing_banner, print_bound_banner and install_felhom_issue (with the postinst's byte-coupled copy of the issue text). The GRUB entries gain an English half. §16 default taken: bilingual for good — it needs no hub change, no protocol field, and it has no way to show the wrong language.

The Hungarian is a golden, not a grep (scripts/iso/test/golden/*.hu.txt, captured before any English existed). The harness also pins: no Hungarian letter in the English block (with the Hungarian block as the positive control), every line ≤ 80 columns, and the whole paint ≤ 25 rows. The pairing banner is 24 rows on a 25-row console — one row of margin, which is why the height is pinned: two more lines and the Hungarian pairing code, at row 5, scrolls off the top.

The download page has an English twin at felhom.eu/en/download, linked both ways with hreflang. The rest of the marketing site stays Hungarian (ruling 1b). A site gate refuses the two pages naming different installer files or checksums.

The release gate had to be amended. G16 required every Felhom-authored string to be Hungarian and would have stopped this publication; ruling 1b supersedes that scope, so G16 was rewritten rather than waived — Hungarian FIRST, pinned by the golden.

Gaps filed: R-586 (the ISO harness had been red for two days and is in no gate), R-587 (root-password files in the publish source directory), R-588 (release records live in two places).

10.6 Slice 5 — the app catalog's own words (controller v0.257.0 + the catalog pilot, R-560)

PART A + PART B SHIPPED 2026-09-20. PART C — the other fifty apps — is deliberately not started: the §16 default was to stop after the pilot for the operator's read, and nobody has read it yet.

The format is no longer a proposal — §7 is now measured. The controller reads the block; three apps carry one; the fleet is unaffected until the floor moves.

What was proven, live, rather than reasoned about:

  • The English pages show the English. On demo-hp (0.257.0) the app pages for privatebin, paperless-ngx and romm, the Apps list and two deploy pages render the catalog's English text.
  • The Hungarian did not move. The same seven pages fetched in Hungarian before and after the catalog push are byte-identical apart from the per-session CSRF token — equal raw byte counts (43 253 / 41 532 / 41 547 / 147 121 / 74 326 / 70 290) and equal hashes once the token is normalised.
  • An old controller ignores the block. demo-felhom runs 0.255.0. With the pilot synced onto that box — confirmed positively: the sync named the three apps and the block is in both the cache and the stack copy — its pages hash identically before and after, and its log carries no parse warning in a 93-line window that includes the sync lines, so the absence is a fact and not a dead log.

What the English pages still carry in Hungarian, measured with an ASCII-folded scan plus an accent scan, both controlled: exactly three things, and one of them is correct.

  1. The language picker's own „Magyar" button — a language picker names each language in its own tongue. Not a defect.
  2. The update badge „Naprakész" and its tooltip — R-589. Slice 1 listed it (§2.3); slice 2 was to take it and closed without it.
  3. The data-folder card's consequence sentence — R-590, and this one makes a promise about the customer's files while the label above it is already English.

Everything else Hungarian on an English Apps LIST is simply the fifty apps nobody has translated yet.

Gaps filed: R-589, R-590, R-591 (Stack.Copy() has one shallow field and it is the new one), R-592 (three defects inside the new gate, each found by its own decoy).

Evidence: documentation/audits/i18n-slice5-2026-09-20/.

PART C SHIPPED THE SAME DAY. The operator read the pilot's English and said go, so the other fifty followed its voice in three pushes — 17, 17 and 16 apps; 319, 317 and 306 strings. 1 031 of the catalog's 1 032 customer-facing strings now carry an English twin.

[DESIGN, decided by CC] The blocks are GENERATED from a flat {path: english} map, not hand-written. Fifty nested blocks whose keys must match the Hungarian side exactly is fifty chances to mistype an env_var — and a mistyped key is INERT on the box, not an error, so the translator never learns. The generator builds from the same flat paths the freeze uses, which are derived from the Hungarian file itself, so a key that does not exist on the Hungarian side cannot be written at all. The review then happens on the map, where one line is one string, instead of on YAML indentation.

[FACT] The one string left untranslated, on purpose. papra's deploy_fields[AUTH_SECRET].description reads „Az alkalmazás aldomainje" — the sentence that belongs on SUBDOMAIN, sitting on a session-signing key (R-593). §1 forbids changing the Hungarian in a localisation release, and translating a wrong sentence faithfully would ship the error in a second language. So it falls back. That is why EN_MISSING_CEILING's floor is 1 rather than 0, and it is written into the ceiling's own comment so a later session does not "reach zero" by editing Hungarian.

[FACT] Measured on the English Apps list, all 53 apps: ZERO Hungarian app descriptions. The only Hungarian left on that page is the „Naprakész" badge (R-589, ten occurrences) and the language picker naming itself. The Hungarian Apps list is identical to the pre-slice capture once the per-session CSRF token and Docker's own „Up N hours" string are normalised.

[FACT] The gate convicted two of the translator's own sentences and was HALF right. Vaultwarden's invite step ended „…can open an account", and the English retrieval-promise pattern reads can … open as the claim that sealed backups can be opened. Opening an ACCOUNT is not that claim, so the conviction was a false positive — and the wording was also the weaker wording, so it became „can sign up". But the gate has no way to REGISTER a true occurrence, which the shared vocabulary's own design calls for; R-594.

[FACT] The fleet floor was raised to 0.257.0 the same session (operator asked), min_agent 0.131.0 declared — above the vouched golden 0.246.0, so the declaration is what carries it (R-472). Hub: managed floor SERVED for demo-felhom: floor 0.257.0, agent requirement "0.131.0" from declared (golden 0.246.0). demo-felhom went 0.255.0 → 0.257.0 by itself in under 12 seconds, healthy, its own log reading settle-gate: GO — at/above floor 0.257.0, and it then rendered the English tagline — the floor delivered function, not just a version string. Peti's box and tester-1 are DOWN and take it unattended when they return; untested on this version.

11. Operator decisions

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