## v0.259.0 — the claim page and the backup warnings answer in the reader's language (2026-09-21, R-596/R-598)
**MinAgent: 0.131.0** (unchanged). The Hungarian pages are byte-identical; the Go-parity gate
measures every new key against the frozen base capture and was red-proofed on a single added full
stop.
The 2026-09-20 English drill ended one screen short. This release is that screen.
- **R-596 (P1) — the claim page's ANSWERS.** The page was English and its answers were Hungarian:
a mistyped code was met with „Hibás vagy lejárt kód", and an English-speaking household could not
tell a typo from a dead code on the one screen between them and their machine. **Fourteen call
sites carrying nine distinct messages** now go through `s.msg(r, "claim.msg.*")`, so they follow
the request's language exactly as the template does.
- **`data["Title"]` was DEAD and is deleted, not translated.** R-596 named it as one of the
Hungarian strings on that page. It is not: `claim.html` is a standalone page with its own
`
` (already bundle-backed), and `.Title` is read only by `layout.html`, which this page
never includes. Translating it would have left a permanent false signal that the page's title is
decided in the handler.
- The anonymous, cookie-less claim page resolves its language from `customer.language` in
`controller.yaml` — `langFor` → `settings.GetLanguage` → `configLanguage`. That chain was
verified at source before any edit and is now PINNED by
`TestAnonymousClaimPageFollowsTheCustomerLanguageWithNoCookie`; it was previously an unpinned
assumption.
- The lockout is unchanged and language-blind: the test asserts the COUNTER, not the text, in both
languages, so nobody gets a longer run by switching a cookie.
- **R-598 — the backup page's PROTECTION WARNINGS**, which are promises about whether the customer's
files are safe. `backupTargetDegradedText` / `OfferText` / `AbsentText` are now bundle KEYS;
`degradedMessageFor` returns the key (the decision stays language-free and in one place) and the
caller that knows the reader resolves the words. `buildTierViews`, `backupTargetLabel` and
`loadGuestBackup` take the reader's language the same way `buildDataPathCards` already did.
- The English says exactly what the Hungarian says, and a test pins the NEGATION —
"protects against corrupted files, **but not against a disk failure**". An English sentence that
promised disk-failure protection would be worse than no translation.
- **An apostrophe cost a render.** The first English absent-drive sentence read "The system
backup's drive…" and never matched on the page: `html/template` escapes `'` to `'`. Caught
by the render test, not by review. All 23 new English values are now free of `' " < > &`.
- **The existing copy-contract tests were kept, not weakened.** Six assertions that compared
Hungarian WORDS would have silently become assertions about key spelling; each now resolves the key
through the real embedded bundle (`huText`), so they still convict on a reworded sentence and now
also convict if a key is ever dropped from `hu.json`.
- **This is the third and fourth instance of one defect class**, after R-573 and R-590: *a composed
sentence handed to a renderer as page data*. No template-parity fixture and no
`TestI18nEnglishPages` can see it, because the template renders the field faithfully — only the
value is wrong. Both new test files drive the real handlers and read the HTML, which is the only
instrument that can.
Red-proofs run this session: the wrong-code literal restored (the English test convicted on both
halves — the English absent AND the Hungarian present); one added full stop in `hu.json` (the
go-parity gate named both sides).
## v0.258.0 — the last four Hungarian things an English household met (2026-09-20, R-589/R-590/R-572/R-573)
**MinAgent: 0.131.0** (unchanged). The Hungarian pages are byte-identical — the parity fixtures and
the Go-parity gate both hold.
Slice 5's live proof found the residue this release clears: on an English app page the only
Felhom-authored Hungarian left was a badge and a sentence about backups. Both are built in **Go**,
which is why neither the template parity fixtures nor `TestI18nEnglishPages` could see them.
- **R-589 — the update badge.** `updateBadge` and `lifecycleBadge` gain bundle-backed forms in
`localeFuncs`, reusing `compareInstalledToTemplate` and `EffectiveLifecycle` so **the decision
cannot drift between the languages — only the words do**; the badge's `Class` is asserted equal
across languages for the same stack. The Hungarian forms in `templateFuncMap` are untouched, which
is the parity guarantee, and `TestLocaleFuncsHungarianBundleMatchesFuncMap` now pins that `hu.json`
says exactly what they say — because `i18n_go_keys.json` cites that test for these keys.
`lifecycleBadge` was not in the row; it is the same builder one function away, and leaving a known
identical defect there would have been a choice.
- **R-590 — the data-folder sentence, which is a PROMISE ABOUT THE CUSTOMER'S FILES.** It said, in
Hungarian and under an already-English folder label, that a drop-zone is temporary and unbacked. An
English household who cannot read it may leave originals in a folder the backup filter discards at
every tier. `consequenceFor` takes its lookup as a parameter, and the test asserts **the consequence
in both languages**: the excluded folder says temporary AND unbacked, a kept folder promises a
backup, an unclassified one promises nothing, and the two languages do not produce the same string
(which would mean the English fell back). The „%.1f GB szabad" free-space line went with it.
- **R-573 — the two channel banners.** The checker already CLASSIFIES the fault, so the banner is
keyed by that classification rather than by the sentence it happened to compose. The composed
sentence stays as the **fail-open fallback**: an unmapped reason renders it verbatim rather than a
blank banner or a raw key. These are the two most operationally important banners there are — the
2026-07-25 outage (R-77) ran 17.5 hours with this banner as the only signal.
- **R-572 — NOT what the row said, and the deletion is the finding.** The row said a template calling
`pruneLabel`/`nextPruneLabel` renders „vasárnap" on an English page. Measured: **no template and no
Go file called either.** They were dead func-map entries returning Hungarian. Translating dead code
would have added machinery with no reader and a test pinning a fiction, so they are **deleted**.
Deletion is the fail-loud direction, and that was proven rather than asserted: a template naming the
removed function makes `loadTemplates` panic at startup with `function "pruneLabel" not defined`.
`fmtDuration`, named in the same row, produces no Hungarian at all ("< 1s", "5s", "2m 3s") and is
left alone.
**Red-proofs, each seen to fail and then revert:** the `updateBadge` override removed from
`localeFuncs` (English read „Naprakész"); `MessageKey` dropped from the banner (English read the
Hungarian sentence); the folder sentence hard-coded back to Hungarian (the English arm convicted on
three assertions); one Hungarian byte changed in a badge key (the funcmap-equality test named both
sides); and a template naming a deleted helper (startup panic).
**The fourth red-proof says something about the gate, not just the code:** the Go-parity gate did
NOT convict that changed byte, because `localeFuncs` keys sit under `_preexisting` and are
deliberately not measured against the base capture. The citation to the test is what carries them —
so the test has to be real, and it is.
## v0.257.0 — the app catalog can speak English (2026-09-20, R-560 slice 5 Part A)
**MinAgent: 0.131.0** (unchanged).
The last Hungarian an English household meets on the dashboard is the text that comes from the app
catalog: each app's one-line description, its tagline, „mire jó" list, „első lépések", the labels and
help lines under every install setting, the prerequisites. 1 032 strings across 53 apps. This release
is the READ PATH for their English twins; the twins themselves arrive in catalog pushes.
**The format** (`10-localisation.md` §7, now measured rather than proposed). A sibling block inside
the SAME `.felhom.yml`, so a reviewer sees one app whole:
```yaml
description: "Titkosított jegyzet és szöveg megosztás"
i18n:
en:
description: "Encrypted note and text sharing"
```
- **`Metadata.For(lang)`** (`internal/stacks/metadata_i18n.go`) merges FIELD BY FIELD. An English
field that is absent — or blank — shows the Hungarian one, so a half-translated app is a legal,
shippable state. That is what makes the catalog batches safe to push one at a time.
- **`For("hu")` is the parsed struct with `I18n` cleared**, measured against all 53 real catalog
files in `internal/stacks/testdata/catalog/` (`TestMetaForHuIsIdentity`). The Hungarian product
cannot be changed by the presence of a translation.
- **Lists are replaced whole; everything else is matched by ITS OWN KEY** — `deploy_fields` by
`env_var`, options by `value`, `optional_config` groups by `match_group`, integrations by
`target`, data paths by `path`. Position matching would mistranslate silently the first time a
Hungarian field is inserted above another.
- **`For` never writes through.** Metadata lives in the stack manager's cache and is shared by
concurrent requests; an in-place merge would leak one household's language into another's page.
- **Pages reach copy only through `LocalizeStacks` / `LocalizeStackPtr` / `MetaFor`**, and
`TestNoDirectMetaCopyReadOnPages` keeps a named, reasoned allow-list of every direct `.Meta.`
read in `internal/web`, so a NEW page that reads catalog copy off the manager fails the suite
instead of quietly rendering Hungarian.
- Two copy producers sit in packages that have no request and therefore no language — the
integration rows and the initial-credentials note. Both are re-taken from the localised metadata
in the handler; identical bytes for Hungarian.
- **An older controller is unaffected**: `LoadMetadata` uses non-strict `yaml.Unmarshal` (this repo
constructs no `yaml.Decoder` at all, verified), so a pre-0.257.0 box drops the whole `i18n:` block.
That is what lets a catalog push reach the entire fleet ahead of the floor raise.
**Eight red-proofs**, each seen to fail and then revert: a mutated hu branch; deploy fields matched
by position; a blank English string winning over Hungarian; the defensive copy removed; optional
config, integrations and data paths matched by position; the untranslated half reaching the page.
**Two of them found a hollow test rather than a bug** — a struct copy shares its slices' backing
arrays, so the obvious `DeepEqual` mutation check passed a deliberately broken merge, and a one-entry
fixture cannot tell key matching from position matching. Both tests were rewritten.
## v0.256.1 — the push log says whether a household sentence was attached (2026-09-18, R-558)
**MinAgent: 0.131.0** (unchanged). One log line, no behaviour change.
Found while trying to PROVE v0.256.0 on a live box: there was nothing to look at. `Event pushed:` logs
the Hungarian message only, the hub logs the Hungarian message only, and the payload is inside TLS —
so "did the box attach a second sentence?" was unanswerable from either end. That is the fork in the
diagnosis when an English household reports a Hungarian mail, and it had no observable.
```
[INFO] Event pushed: app_deployed (info) [+household(en)] — Alkalmazás telepítve: PrivateBin
[INFO] Event pushed: app_deployed (info) [hu-only] — Alkalmazás telepítve: PrivateBin
```
The sentence itself is not logged — it is the same sentence twice and one copy is already on the
line. Pinned by `TestPushLogNamesWhetherAHouseholdSentenceWasAttached` in both languages.
**The general form: a feature whose only failure mode is "the wrong language arrived" needs an
observable that says which branch was taken. Without one, every diagnosis is a guess.**
## v0.256.0 — the box sends its own sentence in the household's language (2026-09-18, R-558 Part B)
**MinAgent: 0.131.0** (unchanged). **Needs hub v0.118.0+**, which shipped first and tolerates a box
that sends none of this — every box in the fleet is that box until this release reaches it.
Slice 3's other half. The hub now writes the household's e-mails in their language, but roughly a
third of those mails carry a sentence the BOX composed, naming a drive, an app or a number. The hub
cannot translate one. So the box sends it twice.
- **`message_customer`** on `POST /api/v1/event` — the same sentence in the household's language,
beside the unchanged Hungarian `message`. `omitempty`, and **a Hungarian household sends nothing
extra at all**: its payload stays byte-for-byte what it is today, so the hub's fallback path — the
one the whole fleet uses — keeps being the path production exercises, rather than becoming a branch
nobody takes.
- **19 producers converted** to bundle keys, rendered twice from ONE key: health ×3, controller
started/updated/update-failed, storage disconnected/reconnected, backup target absent/restored,
app deployed/deploy-started/deploy-failed/removed, crossdrive ×2, disaster recovery ×2, db-dump
completed. `message` stays Hungarian **always** — it is what the operator is mailed and what
`notification_log` records.
- **The Hungarian is byte-identical, and it is measured twice over**: `TestEventMessageWireTextIsFrozen`
(goldens captured at the slice-2 base commit) and the **Go parity gate**, which now checks all 19
keys against the base-commit literals and passes.
- **`customer.language` bootstraps a new box.** `GetLanguage` prefers the household's stored choice,
then `controller.yaml`'s `customer.language`, then Hungarian. The config value is **never written to
`settings.json`** — persisting it would record a choice the household never made, and a later
config pull could no longer be told apart from a deliberate switch.
- **The untranslatable tail** (a docker error, a validator's sentence) is appended to BOTH renderings,
so an English household gets an English sentence with a foreign tail rather than no sentence.
**Three guards had to learn the change, and one of them caught me:**
- The test seam `pushFn` now carries `messageCustomer`. A seam that cannot see a new field cannot test
it — the lesson this file already records one release back, when `PushEvent` had to become the seam.
- The R-329 severity contract walks call sites BY FUNCTION NAME. Converting producers moved two
dynamic-severity sites off `PushEvent`, and the register immediately reported them as no longer
existing — the "a stale register entry is also a failure" half doing exactly its job. The three new
push helpers are registered; the walk now checks **36** severity literals, up from 20.
- `TestConfigLanguageIsWiredInMain` reads `main.go` and asserts the setter is CALLED. `cmd/` is
gitignored, so ripgrep skips `main.go` and a reviewer's search finds nothing — which is precisely
how a seam gets built and never wired in this repo.
**A mistake worth recording:** the first pass of the conversion dropped the `displayName` argument
from three producers, which would have mailed customers `Alkalmazás telepítve: %!s(MISSING)`. It was
caught by reading the diff, and is now pinned by a test that refuses any sentence containing `%!`,
`MISSING`, `%s` or `%d` in either language.
## v0.255.0 — The globe on the sign-in-flow pages: styled, and inside the card (2026-09-18, fixes v0.254.0)
**MinAgent: 0.131.0** (unchanged). **No hub release needed.** Nothing on the wire moved. One Hungarian
block changes, on the pages outside the dashboard chrome only — **zero dashboard fixtures were
re-captured**.
Two defects in v0.254.0's globe, both visible on a real browser and neither catchable by any test that
existed:
**1. The stylesheet was requested with no cache-buster, so the globe rendered UNSTYLED.** `layout.html`
has always used `/static/style.css?v={{.Version}}`; the shells did not. A browser that had the file
cached from before v0.254.0 kept serving a copy with no `.lang-globe` rules in it, and the globe came
out as a bare `` — a stray triangle and two plain words, floating at the edge of the window.
**It was not three shells but FIVE**: `login`, `claim`, `recovery`, **and both guest share pages**. The
two share pages have no globe, but they have the same stale-stylesheet problem for any CSS change, and
their visitor is the likeliest of all to be holding an old copy. All five fixed.
**And `.Version` was not in the data of three of them** — which is exactly how the next shell would
have been missed. It is now set in `executeTemplateLang`, the single choke point every shell renders
through, so a new shell cannot forget it.
**2. The globe floated outside the card.** `.shell-lang` was pinned to the corner of the VIEWPORT, so
it read as part of the browser rather than part of the page. It now sits **inside the card, centred
under the footer**, with the menu opening upward — which is the shared `.lang-globe-menu` rule, so the
block adds no positioning of its own and the dashboard and the shells cannot drift apart.
**A third defect, found by an existing test while fixing the first two.** Putting `?v={{.Version}}`
on the guest share pages would have printed the controller version onto a page **a stranger holding a
capability URL can open** — `TestShareGuest_HeadersTilesNoAdminChrome` refuses exactly that, and
refused it. Those two pages now take an **opaque per-build tag** (`AssetTag`, four bytes of a hash of
the version): it busts a cache just as well and tells a visitor nothing about which build is running.
The fill lives in one function, `addShellAssetData`, called by `executeTemplateLang` **and** by the
parity harness — because a fixture rendered through a different data path is a picture of a page
nobody serves, which this release's predecessor got wrong twice.
`TestGlobeOnAnonymousShells` gained both halves — the stylesheet carries a non-empty `?v=`, and the
globe is inside the card and below the footer. Each was red-proofed by restoring the defect's exact
shape. The test server gained a version, because an empty `?v=` busts a cache exactly once and never
again, and baking that into a fixture would have hidden it.
## v0.254.0 — The saved notes follow the language, and the switch becomes a globe (2026-09-18, R-557 slice 2 release C — SLICE 2 CLOSED)
**MinAgent: 0.131.0** (unchanged). **No hub release needed.** Nothing on the wire moved.
**Hungarian changes in exactly TWO places, both declared and both measured** — the sidebar footer of
every dashboard page, and the three pages a visitor meets. Everything else is byte-identical.
**1. The notes a background run SAVES now follow the language.** The line under last night's backup,
the last error, the proof result, the restore outcome — written in the BOX's language at the moment
they are written (`util.Text(lang, key, …)`, `m.note`/`s.note`). The consequence, stated rather than
hidden: **a household that switches sees the note from the run before in the old language until the
next run rewrites it.** That is the operator's §16 option 1; storing a code and rendering live would
need a dozen new persisted fields and a legacy path for each — the R-570 shape a dozen times over.
Converted: the off-site failure classes and quota notes, the tier-2 reasons and warnings, the undo
phrases, the reconstitute hold, the restore record, and the whole restore-outcome family.
**`EndRestoreOp` no longer receives a Hungarian literal from anywhere.**
**2. The language switch is a globe.** Two text links wrapped in the sidebar footer and asked the
reader to recognise „Magyar"/„English" as links; a globe is the one symbol every web user already
reads as "language", so nobody has to read Hungarian to find their way out of Hungarian. It is
``/`` — a menu with no script, which opens on click and on Enter and which a screen
reader announces. Language names inside are shown in their own language and are never translated.
**3. The sign-in, claim and recovery pages get the same globe — and a visitor's choice stays theirs.**
Those pages are met by someone who has not signed in. They have no setting to read, and they must not
write the household's: a box's sign-in page is reachable by anyone who can reach the box. So the
choice lives in the visitor's own browser (`felhom_lang`, display-only, one of two values), and
`langFor` reads it **only when there is no session** — a signed-in household can never inherit a
language a previous visitor picked in the same browser.
`POST /lang` is exempt from CSRF for a narrow, checkable reason, written down where it is made: the
only thing a forged request can achieve is to change the language of the page the victim's own browser
shows them. It writes one cookie, reads nothing, touches no setting, and `safeBackPath` keeps its
redirect on this box (a protocol-relative `//evil.example` is refused — "starts with /" alone is not
the test). **If that handler ever gains a second effect, it needs CSRF that day.**
**§16, the operator's default, taken: a claim CARRIES the visitor's language.** Someone who switched
the claim page to English and then claimed the box chose English deliberately. Only on success, and
only there — the one moment an anonymous visitor becomes the household.
**No globe on the guest share pages or the not-found page.** A share visitor is a stranger and the
household's setting is the wrong default for them; that is a promise about the share feature and an
operator decision (**R-577**). Pinned by `TestGuestSharePagesHaveNoGlobe` so it stays a decision.
**The two parity exceptions, measured rather than asserted.** 106 fixtures rendered and compared with
a real (LCS) diff — **exactly two change shapes**: the dashboard footer (89 fixtures) and the globe in
the shells (12), with 5 byte-identical, which are precisely the three pages that must not change. The
whole diff is in `felhom.eu/documentation/audits/i18n-slice2-2026-09-18/C/parity-exception-diff.txt`.
**Two defects this release found in its own measurement, recorded rather than tidied away:**
- The parity HARNESS rendered the three visitor shells through the DASHBOARD path, so the fixture
would have baked a globe posting to `/settings/language` with a CSRF field — a form the real page
never serves. Caught by reading the diff before re-capturing. The harness now branches on
`i18nDirectTemplates`.
- The first "is the change only the two blocks?" measurement compared LINE BY INDEX. An insertion
shifts every line below it, so it reported 60 520 changed lines and measured nothing. **A
line-index compare is not a diff.**
`R-575` folded in where it was one call (`tier2NoTargetReason` now returns a note in the box's
language); the soft memory-overcommit warning stays as its row says.
## v0.253.0 — Errors carry the key of the sentence they are (2026-09-18, R-557 slice 2 release B)
**MinAgent: 0.131.0** (unchanged). **No hub release needed.** Every Hungarian sentence is byte-identical
to the literal it replaced, measured by `i18n_go_parity.py`, and nothing on the wire moved.
179 Hungarian sentences were built deep inside a package with `fmt.Errorf` and printed by whoever
caught them. They could not be translated where they are SHOWN (by then they are a finished string)
nor where they are MADE (that code has no request and no language). **Every one of them now carries
its key across that gap, and zero Hungarian error literals remain.**
`util.MsgError` does three things at once, and each was earned:
1. **`Error()` returns the Hungarian, byte for byte.** Every un-converted printer — a log line, a
`%v`, a third-party wrapper — keeps printing exactly what it printed before. That is what made
converting 179 producers safe without converting all their printers in the same commit.
2. **`errors.Is` still answers, for the kind AND for a wrapped cause.** `Unwrap() []error` returns
both. The predecessor `KindErrorf` returned the kind alone and dropped the cause; a converted
producer usually wraps one, and a caller testing for it would have silently stopped matching.
3. **An error ARGUMENT is rendered recursively**, so `„formázás sikertelen: %w"` translates the whole
chain. An inner error that is not ours (restic, docker, ssh, the stdlib) prints itself — that text
is not written here and is not translated here (R-553's rule).
- **Every display path goes through `errText`** (76 sites in `internal/web` and `internal/api`), and
`TestNoErrErrorInPageOutput` keeps it that way. A `strings.Contains(err.Error(), …)` is a
COMPARISON and is deliberately untouched — the four English ones are R-569.
- **The memory refusal is now an error where it is made.** `memoryVerdict` returns
`util.MsgErrorf(ErrNotEnoughMemory, …)` instead of a sentence a caller had to wrap, so the API's
409 and the household's language come from one value. `UpdateRefusal` gained a `Cause` so the same
error survives to the API through the update path.
- **Plurals, one rule, stated once:** 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
— it does not inflect after a numeral — so a Hungarian render is unchanged at every count. Not a
per-call-site flag: the one producer somebody forgot would read „3 app is not running" with nothing
to catch it. `TestPluralFirstArgIsNumeric` pins that every plural value really does take its count
first; `TestNoOrdinaryKeyEndsInAPluralSuffix` pins that `.one`/`.other` stay reserved — a real
collision (`alert.deadapp.one`) was caught by it on the day the rule landed.
**Two defects this release found in its own tooling, both recorded rather than quietly fixed:**
- The bulk converter **silently dropped the continuation of a multi-line concatenation**, damaging 7
producers. The parity gate did not catch it: every surviving fragment WAS a real base-commit
literal, so its question was answered yes while the call had lost text. Two behaviour tests
(`TestR356_ScenarioC`, `TestR379_ScenarioA`) caught it, because they assert the sentence a customer
reads. All 7 rebuilt as joined keys.
- The counting script was **case-sensitive**, so it reported "0 error literals left" while five
remained. The R-565 shape, in the instrument. Re-measured; the five are converted.
## v0.252.0 — The sentences the program writes follow the language (2026-09-18, R-557 slice 2 release A)
**MinAgent: 0.131.0** (unchanged). **No hub release needed.** Nothing a Hungarian household reads
changes: every converted sentence is byte-identical to the literal it replaced, measured by a new
gate rather than read, and nothing on the wire moved.
Slice 1 translated the dashboard's MARKUP. The sentences the program BUILDS — flash lines, page data,
the JSON the page's own script reads, the alert banners, the country names — were still Hungarian
literals in Go, so an English household clicked an English button and was answered in Hungarian.
This release moves 226 of them into the bundle and gives them a way to reach the reader.
**The flash line was the hard part.** It travels to the page INSIDE THE REDIRECT URL, so it is
rendered by a different request from the one that wrote it — until now in the language of whoever
redirected. It now travels as a bundle key plus its parameters (`?flash=flash.share.enabled&fa=…`),
and the page renders it. A link minted by an older controller carries prose; that is shown verbatim,
never as a raw key and never dropped (`TestFlashKeyRoundTrip`, including the legacy and the
`