8e9401c6bf
gates / gates (push) Successful in 27s
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
721 lines
51 KiB
Markdown
721 lines
51 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
|
||
|
||
**[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:
|
||
|
||
```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` 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
|
||
|
||
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.
|
||
|
||
### 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.
|