The image is BUILT but NOT PUBLISHED, and that is deliberate: publishing to iso.felhom.eu is public and irreversible, and the runbook needs a proof install on BOTH menu entries plus a reboot against the uploaded bytes. That is supervised, so I stopped there. felhom-installer-1.29.0-pve9.2-1.iso, sha256 c67ceaa3…fb02, with every mechanically checkable criterion passing (G1, G2, G5, G6, G7, G9, G16 — including both payload files byte-identical to repo HEAD). The download pages still name 1.28.0, the image that IS published. Pointing them at a file that is not there would hand every reader a 404. A new site gate refuses the two pages naming different installer files or checksums, so whoever publishes 1.29.0 cannot update one and forget the other. Measured rather than read: the pairing banner is 24 rows on a 25-row console. One row of margin — so the height is now pinned, because two more lines push the HUNGARIAN code at row 5 off the top, and a banner whose code has scrolled away is furniture. R-587: two root-password files from July sit in the directory the public ISO is published from. Both 404 on the bucket (against a 200 control), so nothing leaked — but the only thing keeping them off is an --include pattern they miss by an accident of naming. A pattern that protects by coincidence is not a control. R-588: release records live in two different places, which made me wrongly conclude 1.28.0's gate had never been run. It had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
41 KiB
10 — The product in more than one language
LIVING DOCUMENT. Every localisation slice updates this file in the same session. Opened 2026-09-17 by the localisation starter, AFTER its spike ran (controller v0.247.0). Before it, no architecture document mentioned localisation at all (a grep of
architecture/*.mdfori18n|locali|languagefound one incidental line in 07).Marks, as in 07 and 02: [DESIGN] — a decision taken, not derived from code. [FACT] — observed, with a citation. Unmarked means not yet classified.
This file carries the REASONING. The register (backlog/OPEN-ITEMS.md, R-553..R-562) carries the
work. The numbers are in audits/I18N-INVENTORY-2026-09-17.md. The source is the truth.
1. The rule that makes this safe
[DESIGN] The Hungarian product must render byte-for-byte the same before and after every slice — measured page by page, never by reading. A household that never switches must not be able to tell a localisation release happened. English goes on top of an unchanged Hungarian product.
[FACT] It is enforceable, and enforced for the three converted pages: TestI18nParity
(felhom-controller/controller/internal/web/i18n_parity_test.go) renders 14 fixture states of the
launcher, /backups and /apps/<slug> in Hungarian and compares them with HTML captured from a clean
worktree of origin/main 89dd3e94b1de before any template carried a marker. One changed byte in
hu.json fails it naming the page and the line (red-proof,
felhom-controller/REPORT.md). Fixtures are captured from unconverted templates and never
regenerated to make a conversion pass — that is the release gate for every slice.
The one normalisation: relative ages („3 napja") become „# napja", because they come from the wall clock.
[FACT] And live, on demo-hp guest 9201 (2026-09-17, endpoint-level): the launcher, /backups and
/apps/privatebin fetched in Hungarian on 0.246.0 and again on 0.247.0 are identical apart from the
version string and the CSRF token, with equal raw byte counts (44 462 / 46 250 / 42 081), and again
after a round trip through English (audits/i18n-2026-09-17/live/).
2. The mechanism, as measured
2.1 What was chosen, and why
[DESIGN] A flat message bundle per language, expanded into the template BEFORE html/template
parses it. Decided by CC in the spike (the starter delegated the choice, §4 1.1 of the task).
| option | cost | verdict |
|---|---|---|
(a) golang.org/x/text/message catalogs + a T func |
a new external dependency; its value is gender/plural/ordinal selection | not needed: the inventory found no gender agreement and only count plurals; Hungarian does not inflect after a numeral |
(b1) flat bundle, runtime T template func |
every string passes through the contextual escaper: +, ', " change bytes; inside <script> a string becomes a quoted JS literal — 618 of the 1 867 template strings are in <script> |
rejected: breaks byte parity by construction |
| (b2) flat bundle, expanded at template LOAD | one parsed template set per language (startup parses every template once per language — not measured); a translation is raw source text, so its context safety must be tested | chosen: the Hungarian set is parsed from the same bytes, in the same escaping contexts — parity holds by construction and is then measured |
2.2 How it works
- [FACT] Bundles:
controller/internal/i18n/locales/hu.json(authoritative — every key) anden.json, embedded (internal/i18n/i18n.go). Flatkey → text. A value may carry template actions ({{.RecoveryAbandonDate}}) and inline markup (<strong>,<a>): those are the message's parameters, kept inside one message so a translator can move them. English plurals arekey.one/key.other(Bundle.Plural), and a template value may also branch ({{if eq $n 1}}file{{else}}files{{end}}). - [FACT] A converted template carries
{{T "key"}}.Server.loadTemplatesparses one set per supported language throughparseTemplateSet=ParseFS+i18n.Expandper file, naming templates exactly asParseFSdoes.s.tmplstays the Hungarian set, so every renderer that predates i18n is unchanged. - [FACT] An undefined key is left in place, so the set fails to load („function T not
defined") — loud at startup and in every render test (
TestExpandLeavesUndefinedMarker). - [FACT]
executeTemplaterenders dashboard pages in the request's language. Since slice 1 (v0.249.0 recovery, v0.250.0 the rest) the pages outside the dashboard chrome — login, claim, recovery, both guest share pages, the catch-all — render throughexecuteTemplateLang: the language set andLang, and deliberately NOT the session CSRF fields or the escrow reminder (R-543) thatexecuteTemplateadds. Pinned byTestDirectRenderHandlersFollowLanguage(the real routes) andTestI18nDirectRenderPagesHaveNoAdminChrome(reminder genuinely due, absent from the page data of all six and from the guest page in both languages). No renderer usess.tmpldirectly any more. - [FACT] Go-side copy on a converted page: the handler names its title key (
data["TitleKey"];TestHandlerTitleKeysMatchHungarianTitlepins each handler's Hungarian literal equal to its hu.json value); the non-Hungarian sets override the copy-producing funcsstateLabel,timeAgo,timeAgoStr,nextRunLabel,statusText,infraMetafrom the bundle (web/i18n_web.golocaleFuncs). The Hungarian funcs are not touched;TestLocaleFuncsHungarianBundleMatchesFuncMappins thathu.jsoncarries the same words they return.
2.3 What a translation must not do (pinned by tests)
- Carry different parameters. Same
{{.Field}}set and same printf verbs as the Hungarian (TestBundleParametersMatchAcrossLanguages). - Break its context. Expansion is textual; inside a JS string a bare
'/"/\or newline ends it, inside a double-quoted attribute a"ends it — andhtml/templatecannot see this, because it reads the expansion as the author's own source. A value may carry a quote only where the Hungarian carries the same one (TestI18nJSContextValuesAreSafe, red-proofed withCopied'). English therefore avoids apostrophes in JS contexts („do not", not „don't"); elsewhere it uses ’. - Render blank or raw. No raw key, no marker, no element empty in English that is not empty in
Hungarian (
TestI18nEnglishPages).
[FACT] What stays Hungarian on the English pages, live (v0.250.0, all 31 slice-1 templates converted):
Go view-model text (flash lines, errors, the backup-target banner and offer, the whole-guest tier
labels, the update badge „Naprakész", the catch-all's status sentence), three page titles built in Go
around an app name (logs, deploy, tier-2 settings — R-566), and catalog copy (tagline, use cases, first
steps) — slices 2 and 5. Deliberately left in a template: „Fut…" on backups_remote, which the page's
own JS reads (R-563). Evidence: audits/i18n-slice1-2026-09-17/{A,B,C}/live/.
[FACT] Measured limits of the English page test. (1) It subtracts fixture DATA strings before looking for Hungarian; since slice 1 release A only data strings of ≥ 2 words or ≥ 12 characters are subtracted, so a one-word template title is no longer masked. (2) It sees accented Hungarian only. An ASCII-only Hungarian word left in a template („mp", „ db", „jelenlegi:", „FIGYELEM:", „, majd a(z)") passes it on the English page; release C found six such fragments by eye, none by a test (R-565).
3. Who picks the language, and how it flows
[DESIGN] Operator decision 2 (2026-09-17, agreed): the operator sets it per customer on the hub at creation (default Hungarian); the household can switch it on their dashboard; the box reports it so the hub's e-mails follow.
- [FACT] Built in v0.247.0:
settings.jsonlanguage(hu|en; empty readshu,Settings.GetLanguage);POST /settings/language(session CSRF like every form;lang,back; redirect drops the query);?lang=hu|enper-request override, never persisted; the hub report's"language"field, always present, set at all four report build sites incmd/controller/main.go. - [FACT] Live 2026-09-17 on demo-hp:
POST /settings/language lang=en→ 302,settings.json"language": "en", the three pages English; the hub's stored reports read no field (0.246.0),hu,enat 12:54:43Z,huat 12:55:22Z after switching back; without_csrf→ 403 and nothing changed (audits/i18n-2026-09-17/live/README.md). - [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/severityLabelsare derived from the bundle. Order: last reported →customer_configs.language→hu(Store.CustomerLanguage).message_customeris accepted onPOST /api/v1/eventfor the box's own sentences. The bind page is per-language, withexpiredpinned to Hungarian so the language cannot become the oracle the text refuses to be. Full design:05-hub-architecture.md§15. R-555 closed — thelanguageallowlist entry is out ofwire_contract_gate.pyand 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.yamlnext tocustomer.id/name/domain/email(hub/internal/configgen/configgen.go), and the box uses it only while the household has never chosen (settings.jsonempty). The household's own choice always wins; the hub's e-mails follow the language the box reports, which is therefore the household's choice. The hub decodes the report with independentjson.Unmarshalcalls and noDisallowUnknownFields(hub/internal/api/handler.gohandleReport), so the field was additive.
[DESIGN] Decided by CC in the spike — operator may reverse: the switch is SHOWN only on a page that
is not Hungarian, or on a request carrying ?lang=. One answerable sentence: should a Hungarian
household see an English switch while only three pages are English? Options: (1) show it to everyone
now — cost: one click from a half-English dashboard, and every page but three keeps a Hungarian body;
(2) hide it until slice 1 converts the remaining pages — cost: a household cannot discover English
yet, which no household has asked for. Chosen (2): it follows rule §1 (nothing visible changes)
and is reversed by deleting one condition in addLanguageData. The way back from English is always on
screen.
[FACT] SUPERSEDED 2026-09-17 by slice 1 release C (controller v0.250.0): every template is converted,
so the condition is deleted and the switch is on every dashboard page, in both languages. The 89
layout parity fixtures were re-captured and each equals its predecessor plus exactly one switch form
(audits/i18n-slice1-2026-09-17/C/switch-fixture-diff.txt); TestLanguageSwitch_EndToEnd pins that a
Hungarian household with no ?lang= sees it. Live on demo-hp: every Hungarian dashboard page equals
its 0.249.0 fetch once the switch form is removed (live numbers aside); login, claim and the catch-all
are unchanged. A Hungarian household sees it only when the fleet floor reaches 0.250.0 — the
operator's cadence (R-242/R-468); no floor was raised.
4. Fallback
[DESIGN] Operator decision 3 (2026-09-17, agreed): a missing English line shows the Hungarian one and is counted by a gate; a missing line never shows a key or an empty box.
- [FACT]
Bundle.Textfalls back to Hungarian and flags it; the loader logsi18n: en template set shows Hungarian for N markers;Bundle.Msgof a key absent everywhere returns the key (visible, never blank) andTestBundleKeysUsedExistInHungarianrefuses to ship one. - [FACT]
controller/scripts/i18n_missing_gate.py(incontroller_gates.py, pre-push): keys exist, no orphans, the English gap is a ratchet —EN_MISSING_CEILING(0 today) convicts above AND below, so it can only be lowered deliberately. Raising it is a decision recorded here.
5. The gates, per language
[FACT] Found in the spike: moving copy out of the templates blinded four gates and staled one.
The retrieval-promise gate went red on STALE allowlist entries the moment the first page converted;
the emoji, native-confirm and secret-in-markup gates silently stopped seeing the moved copy. The HEAD
versions of the emoji and retrieval gates PASSED a planted emoji and a planted retrieval promise in the
bundles. They now judge the page as rendered in each language (controller/scripts/i18n_bundle.py
read_template(path, lang)); mojibake scans the bundles; decoys for each are in
controller/scripts/test_gate_decoys.py.
[DESIGN] Voice, per language:
- hu — the product speaks in „te". The converted copy carries formal („ön") forms (R-516). They are
not fixed by a localisation release (§1), so the gate counts them against
HU_FORMAL_CEILINGas a ratchet: a new one convicts, fixing one lowers the ceiling. The ceiling followed the conversion: 6 (spike) → 10 (release A) → 12 (release B) → 16 (release C). [FACT] Measured limit: the stem list is short — on the release C pages it counts 4 formal forms, while a wider list („írja be", „adja meg", „válassza", „biztosan eltávolítja", „engedélyezze", …) finds at least 22 keys (R-516). - en — second person, plain: no „please", no „kindly".
[FACT] The retrieval-promise gate scans English too since slice 1 release A (EN_PATTERNS,
ALLOWLIST_EN, decoys). Translating exposed that its Hungarian stems miss split verbs („állíthatók
vissza") — R-564. The hub copy gate and the catalog have no language concept
(scripts/hub_copy_gate.py stems end in a Hungarian character class; catalog_gates.py checks no copy).
6. Deliberately out
- [DESIGN] The hub's operator pages — the operator reads them; they stay English (decision 1).
- [DESIGN] The first-boot wizard (
controller/internal/setup/, 8 pages, 95 strings) — out of scope and obsolete (decision 4, 2026-09-17;02-controller-module-map.mdL56). A household still reaches it when bootstrap ingestion leavescustomer.idempty (inventory §2.7). Its deletion is R-554. - [FACT] App containers' own UIs (Uptime Kuma, PrivateBin…) — not Felhom's copy (R-516 items 5-6).
7. The catalog's copy model
[DESIGN, proposed — slice 5 decides after seeing it work] A sibling block per language inside
.felhom.yml, not a sibling file:
description: Titkosított jegyzetek…
app_info:
first_steps: [...]
i18n:
en:
description: Encrypted notes…
app_info:
first_steps: [...]
Why: one app's copy stays in one file a reviewer sees whole; the controller's yaml.Unmarshal
ignores unknown keys (internal/stacks/metadata.go L317), so older controllers are unaffected; a
missing English field falls back field by field. Cost: 835 strings, ~4 984 words; the catalog copy
gates need an English rule. Read on privatebin, nextcloud and immich (inventory §2.5).
8. Dates, numbers, plurals — as found
- [FACT] Plurals: 42 Go format strings with
%dand 7 template runs with a numeric parameter (inventory §2.2/2.1). Hungarian needs one form; English two. - [FACT] Dates: 10 layout literals in
internal/webGo, 2 in templates that disagree with each other (2006. 01. 02. 15:04vs2006-01-02 15:04), 25 more in the controller; 9 copy-producing helpers (timeAgo„%d perce",nextRunLabel„ma"/„holnap",pruneLabel„vasárnap"…). - [FACT] Sizes print a decimal POINT (
%.1f GB) — Hungarian convention is a comma. The Hungarian pages are already un-Hungarian there; §1 keeps it that way until someone decides otherwise (R-562). - [FACT] Word order: 237 Go format strings and 111 concatenations; in templates, JS sentences split
around
+ name +(„Az alkalmazás (" + name + ") törölve lett.") — translatable but fragile. - [FACT] Flash messages travel inside the redirect URL (
?flash=<text>) in the language of the handler that redirected — slice 2 moves them to keys.
9. Compared, not shown — latent bugs a translation would trigger
[FACT] 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.
cloudflare/countries.gois not on the wire.report/builder.gocarries country codes; no name leaves the box, andCountryName()has no caller. The 237 names were therefore free to translate, and are — at DISPLAY, inapi/geo.go, re-sorted per language. The table stays: it validates codes.- The hub does not compose every customer mail from the event kind.
FormatCustomerEmail(hub/internal/notify/templates.goL167-210) usescustomerMessages[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. Sonotify/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)
- Scope: what the household sees — the controller, its e-mails, the guide, the app catalog. Not the hub's operator pages.
- Who picks: the operator per customer at creation (default Hungarian); the household switches on the dashboard; the box reports it so the hub's e-mails follow.
- Fallback: a missing English line shows the Hungarian one and is counted; never a key or a blank.
- The first-boot wizard: out of scope, obsolete — a separate row to delete it (R-554).
Decided by CC in the spike — operator may reverse
-
Mechanism (b2) — §2.1.
-
Switch hidden while only three pages are English — §3.Superseded 2026-09-17 by slice 1 release C (v0.250.0): every template converted, switch shown to every household (§3). 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). -
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.jsonalone.
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).
11. Operator decisions
These are rulings, not proposals. Anything specced against a different assumption is wrong.