183727db9c
gates / gates (push) Successful in 25s
Part B's live proof is a LOG LINE rather than an e-mail, and the evidence page says why: severityNotifies drops `info`, so every safe converted producer mails nobody, and every converted producer above `info` describes something bad that is not true. Triggering one would mean a false record on a real box's timeline or a state change the fences forbid. The log line was built in v0.256.1 for exactly this, after finding there was nothing to look at on either side of the wire. 15:07:28 controller_started [hu-only] — Controller elindult (0.256.1) 15:08:25 controller_started [+household(en)] — Controller elindult (0.256.1) The Hungarian sentence is identical in both, and the hub stored the Hungarian in every case including the English-household push. R-558 CLOSED, R-555 closed with it. R-585 filed: six producers still send Hungarian only because their sentence arrives already finished from another package — `offbox_enlarge_blocked` matters most, since it has no hub entry so its raw sentence IS the household's whole mail. 10-localisation.md gains 10.4; STATUS rewritten for the operator with the two decisions left (raise the floor to 0.256.1; whether to rotate the demo password after R-584). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
550 lines
39 KiB
Markdown
550 lines
39 KiB
Markdown
# 10 — The product in more than one language
|
||
|
||
> **LIVING DOCUMENT. Every localisation slice updates this file in the same session.**
|
||
> Opened 2026-09-17 by the localisation starter, AFTER its spike ran (controller v0.247.0). Before it,
|
||
> no architecture document mentioned localisation at all (a grep of `architecture/*.md` for
|
||
> `i18n|locali|language` found one incidental line in 07).
|
||
>
|
||
> Marks, as in 07 and 02: **[DESIGN]** — a decision taken, not derived from code. **[FACT]** — observed,
|
||
> with a citation. Unmarked means not yet classified.
|
||
|
||
**This file carries the REASONING. The register (`backlog/OPEN-ITEMS.md`, R-553..R-562) carries the
|
||
work. The numbers are in `audits/I18N-INVENTORY-2026-09-17.md`. The source is the truth.**
|
||
|
||
---
|
||
|
||
## 1. The rule that makes this safe
|
||
|
||
**[DESIGN] The Hungarian product must render byte-for-byte the same before and after every slice —
|
||
measured page by page, never by reading.** A household that never switches must not be able to tell a
|
||
localisation release happened. English goes on top of an unchanged Hungarian product.
|
||
|
||
**[FACT]** It is enforceable, and enforced for the three converted pages: `TestI18nParity`
|
||
(`felhom-controller/controller/internal/web/i18n_parity_test.go`) renders 14 fixture states of the
|
||
launcher, `/backups` and `/apps/<slug>` in Hungarian and compares them with HTML captured from a clean
|
||
worktree of `origin/main` 89dd3e94b1de **before** any template carried a marker. One changed byte in
|
||
`hu.json` fails it naming the page and the line (red-proof,
|
||
`felhom-controller/REPORT.md`). **Fixtures are captured from unconverted templates and never
|
||
regenerated to make a conversion pass** — that is the release gate for every slice.
|
||
|
||
The one normalisation: relative ages („3 napja") become „# napja", because they come from the wall
|
||
clock.
|
||
|
||
**[FACT] And live, on demo-hp guest 9201 (2026-09-17, endpoint-level):** the launcher, `/backups` and
|
||
`/apps/privatebin` fetched in Hungarian on 0.246.0 and again on 0.247.0 are **identical apart from the
|
||
version string and the CSRF token**, with equal raw byte counts (44 462 / 46 250 / 42 081), and again
|
||
after a round trip through English (`audits/i18n-2026-09-17/live/`).
|
||
|
||
---
|
||
|
||
## 2. The mechanism, as measured
|
||
|
||
### 2.1 What was chosen, and why
|
||
|
||
**[DESIGN] A flat message bundle per language, expanded into the template BEFORE `html/template`
|
||
parses it.** Decided by CC in the spike (the starter delegated the choice, §4 1.1 of the task).
|
||
|
||
| option | cost | verdict |
|
||
|---|---|---|
|
||
| (a) `golang.org/x/text/message` catalogs + a `T` func | a new external dependency; its value is gender/plural/ordinal selection | **not needed**: the inventory found no gender agreement and only count plurals; Hungarian does not inflect after a numeral |
|
||
| (b1) flat bundle, **runtime** `T` template func | every string passes through the contextual escaper: `+`, `'`, `"` change bytes; inside `<script>` a string becomes a quoted JS literal — 618 of the 1 867 template strings are in `<script>` | **rejected**: breaks byte parity by construction |
|
||
| **(b2) flat bundle, expanded at template LOAD** | one parsed template set per language (startup parses every template once per language — not measured); a translation is raw source text, so its context safety must be tested | **chosen**: the Hungarian set is parsed from the same bytes, in the same escaping contexts — parity holds by construction and is then measured |
|
||
|
||
### 2.2 How it works
|
||
|
||
- **[FACT]** Bundles: `controller/internal/i18n/locales/hu.json` (authoritative — every key) and
|
||
`en.json`, embedded (`internal/i18n/i18n.go`). Flat `key → text`. A value may carry **template
|
||
actions** (`{{.RecoveryAbandonDate}}`) and **inline markup** (`<strong>`, `<a>`): those are the
|
||
message's parameters, kept inside one message so a translator can move them. English plurals are
|
||
`key.one` / `key.other` (`Bundle.Plural`), and a template value may also branch
|
||
(`{{if eq $n 1}}file{{else}}files{{end}}`).
|
||
- **[FACT]** A converted template carries `{{T "key"}}`. `Server.loadTemplates` parses one set per
|
||
supported language through `parseTemplateSet` = `ParseFS` + `i18n.Expand` per file, naming templates
|
||
exactly as `ParseFS` does. `s.tmpl` stays the Hungarian set, so every renderer that predates i18n is
|
||
unchanged.
|
||
- **[FACT]** An **undefined** key is left in place, so the set fails to load („function T not
|
||
defined") — loud at startup and in every render test (`TestExpandLeavesUndefinedMarker`).
|
||
- **[FACT]** `executeTemplate` renders dashboard pages in the request's language. Since slice 1
|
||
(v0.249.0 recovery, v0.250.0 the rest) the pages outside the dashboard chrome — login, claim,
|
||
recovery, both guest share pages, the catch-all — render through `executeTemplateLang`: the language
|
||
set and `Lang`, and deliberately NOT the session CSRF fields or the escrow reminder (R-543) that
|
||
`executeTemplate` adds. Pinned by `TestDirectRenderHandlersFollowLanguage` (the real routes) and
|
||
`TestI18nDirectRenderPagesHaveNoAdminChrome` (reminder genuinely due, absent from the page data of all
|
||
six and from the guest page in both languages). No renderer uses `s.tmpl` directly any more.
|
||
- **[FACT]** Go-side copy on a converted page: the handler names its title key (`data["TitleKey"]`;
|
||
`TestHandlerTitleKeysMatchHungarianTitle` pins each handler's Hungarian literal equal to its hu.json
|
||
value); the non-Hungarian sets override the copy-producing funcs `stateLabel`, `timeAgo`, `timeAgoStr`,
|
||
`nextRunLabel`, `statusText`, `infraMeta` from the bundle (`web/i18n_web.go` `localeFuncs`). The Hungarian funcs
|
||
are not touched; `TestLocaleFuncsHungarianBundleMatchesFuncMap` pins that `hu.json` carries the same
|
||
words they return.
|
||
|
||
### 2.3 What a translation must not do (pinned by tests)
|
||
|
||
- **Carry different parameters.** Same `{{.Field}}` set and same printf verbs as the Hungarian
|
||
(`TestBundleParametersMatchAcrossLanguages`).
|
||
- **Break its context.** Expansion is textual; inside a JS string a bare `'`/`"`/`\` or newline ends
|
||
it, inside a double-quoted attribute a `"` ends it — and `html/template` cannot see this, because it
|
||
reads the expansion as the author's own source. A value may carry a quote only where the Hungarian
|
||
carries the same one (`TestI18nJSContextValuesAreSafe`, red-proofed with `Copied'`). English
|
||
therefore avoids apostrophes in JS contexts („do not", not „don't"); elsewhere it uses ’.
|
||
- **Render blank or raw.** No raw key, no marker, no element empty in English that is not empty in
|
||
Hungarian (`TestI18nEnglishPages`).
|
||
|
||
**[FACT] What stays Hungarian on the English pages, live (v0.250.0, all 31 slice-1 templates converted):**
|
||
Go view-model text (flash lines, errors, the backup-target banner and offer, the whole-guest tier
|
||
labels, the update badge „Naprakész", the catch-all's status sentence), three page titles built in Go
|
||
around an app name (logs, deploy, tier-2 settings — R-566), and catalog copy (tagline, use cases, first
|
||
steps) — slices 2 and 5. Deliberately left in a template: „Fut…" on `backups_remote`, which the page's
|
||
own JS reads (R-563). Evidence: `audits/i18n-slice1-2026-09-17/{A,B,C}/live/`.
|
||
|
||
**[FACT] Measured limits of the English page test.** (1) It subtracts fixture DATA strings before
|
||
looking for Hungarian; since slice 1 release A only data strings of ≥ 2 words or ≥ 12 characters are
|
||
subtracted, so a one-word template title is no longer masked. (2) **It sees accented Hungarian only.**
|
||
An ASCII-only Hungarian word left in a template („mp", „ db", „jelenlegi:", „FIGYELEM:", „, majd a(z)")
|
||
passes it on the English page; release C found six such fragments by eye, none by a test (R-565).
|
||
|
||
---
|
||
|
||
## 3. Who picks the language, and how it flows
|
||
|
||
**[DESIGN] Operator decision 2 (2026-09-17, agreed): the operator sets it per customer on the hub at
|
||
creation (default Hungarian); the household can switch it on their dashboard; the box reports it so
|
||
the hub's e-mails follow.**
|
||
|
||
- **[FACT] Built in v0.247.0:** `settings.json` `language` (`hu`|`en`; empty reads `hu`,
|
||
`Settings.GetLanguage`); `POST /settings/language` (session CSRF like every form; `lang`, `back`;
|
||
redirect drops the query); `?lang=hu|en` per-request override, never persisted; the hub report's
|
||
`"language"` field, always present, set at all four report build sites in `cmd/controller/main.go`.
|
||
- **[FACT] Live 2026-09-17 on demo-hp:** `POST /settings/language lang=en` → 302, `settings.json`
|
||
`"language": "en"`, the three pages English; the hub's stored reports read no field (0.246.0), `hu`,
|
||
`en` at 12:54:43Z, `hu` at 12:55:22Z after switching back; without `_csrf` → 403 and nothing changed
|
||
(`audits/i18n-2026-09-17/live/README.md`).
|
||
- **[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
|
||
|
||
**[DESIGN, proposed — slice 5 decides after seeing it work]** A sibling block per language inside
|
||
`.felhom.yml`, not a sibling file:
|
||
|
||
```yaml
|
||
description: Titkosított jegyzetek…
|
||
app_info:
|
||
first_steps: [...]
|
||
i18n:
|
||
en:
|
||
description: Encrypted notes…
|
||
app_info:
|
||
first_steps: [...]
|
||
```
|
||
|
||
Why: one app's copy stays in one file a reviewer sees whole; the controller's `yaml.Unmarshal`
|
||
ignores unknown keys (`internal/stacks/metadata.go` L317), so older controllers are unaffected; a
|
||
missing English field falls back field by field. Cost: 835 strings, ~4 984 words; the catalog copy
|
||
gates need an English rule. Read on privatebin, nextcloud and immich (inventory §2.5).
|
||
|
||
---
|
||
|
||
## 8. Dates, numbers, plurals — as found
|
||
|
||
- **[FACT]** Plurals: 42 Go format strings with `%d` and 7 template runs with a numeric parameter
|
||
(inventory §2.2/2.1). Hungarian needs one form; English two.
|
||
- **[FACT]** Dates: 10 layout literals in `internal/web` Go, 2 in templates that **disagree with each
|
||
other** (`2006. 01. 02. 15:04` vs `2006-01-02 15:04`), 25 more in the controller; 9 copy-producing
|
||
helpers (`timeAgo` „%d perce", `nextRunLabel` „ma"/„holnap", `pruneLabel` „vasárnap"…).
|
||
- **[FACT]** Sizes print a decimal POINT (`%.1f GB`) — Hungarian convention is a comma. The Hungarian
|
||
pages are already un-Hungarian there; §1 keeps it that way until someone decides otherwise (R-562).
|
||
- **[FACT]** Word order: 237 Go format strings and 111 concatenations; in templates, JS sentences split
|
||
around `+ name +` („Az alkalmazás (" + name + ") törölve lett.") — translatable but fragile.
|
||
- **[FACT]** Flash messages travel **inside the redirect URL** (`?flash=<text>`) in the language of the
|
||
handler that redirected — slice 2 moves them to keys.
|
||
|
||
---
|
||
|
||
## 9. Compared, not shown — latent bugs a translation would trigger
|
||
|
||
**[FACT] 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.**
|
||
|
||
---
|
||
|
||
## 11. Operator decisions
|
||
|
||
These are rulings, not proposals. Anything specced against a different assumption is wrong.
|
||
|
||
### 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
|
||
|
||
5. **Mechanism (b2)** — §2.1.
|
||
6. ~~**Switch hidden while only three pages are English** — §3.~~ **Superseded 2026-09-17** by slice 1
|
||
release C (v0.250.0): every template converted, switch shown to every household (§3).
|
||
**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).
|
||
|
||
8. **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.
|