docs: v0.257.0 — README, REPORT, CONTEXT for the catalog read path (R-560)
gates / gates (push) Successful in 25s

README gains "Catalog copy in a second language" under §18: the format, the
field-by-field fallback, whole-list replacement vs key matching, the no-write-through
rule, the four templates that carry copy, and why an older controller is unaffected.

REPORT records the nine red-proofs — including the two that convicted a hollow TEST
rather than the code — and the live proof on both demo boxes, naming the method
(endpoint level, no browser on DooPlex) rather than implying a click-through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-20 14:43:52 +02:00
parent 60f0a86bd4
commit eb806f5780
3 changed files with 167 additions and 44 deletions
+114 -42
View File
@@ -1,56 +1,128 @@
# REPORT — localisation slice 3 Part B: the box sends its sentence in the household's language
# REPORT — v0.257.0: the app catalog can speak English (R-560, slice 5 Part A)
**controller v0.256.0** · base `8fb2f9ef9d5a` · 2026-09-18 · R-558 Part B · MinAgent 0.131.0 (unchanged)
2026-09-20 · `main` `60f0a86` · image `gitea.dooplex.hu/admin/felhom-controller:0.257.0` · live on
demo-hp guest 9201. MinAgent 0.131.0, unchanged.
## What shipped
## What this release does
The hub half (v0.118.0/0.118.1) writes a household's e-mails in their language. This is the other
half: about a third of those mails carry a sentence the BOX composed — naming a drive, an app, a
number — and the hub cannot translate one. The box now sends it twice.
An app's customer-facing text lives in the catalog's `.felhom.yml` and was Hungarian only. This
release lets the same file carry an English twin in an `i18n: {en: …}` sibling block, and makes the
dashboard read it when the household is on English.
- **`message_customer`** on the event wire: the same sentence in the household's language, beside the
unchanged Hungarian `message`. `omitempty`, and **a Hungarian household sends nothing extra**, so
its payload stays byte-for-byte what every box in the fleet sends today.
- **19 producers converted** to bundle keys, both renderings from ONE key.
- **`customer.language` bootstraps a new box**: stored choice → config → Hungarian. The config value
is never persisted into `settings.json`; doing so would record a choice nobody made.
Architecture document read first and named here: `felhom.eu/documentation/architecture/10-localisation.md`
— §1 (the Hungarian must not move), §4 (fallback), §5 (the gates), §7 (the catalog's copy model, which
this release turns from a proposal into a measured fact), §11 ruling 7 (app names are not translated).
## The Hungarian did not move, measured twice
## The five properties, and why each is load-bearing
- `TestEventMessageWireTextIsFrozen` — goldens captured at the slice-2 base commit.
- **The Go parity gate** — all 19 new keys checked byte-for-byte against the base-commit literals
(`688 slice-2 keys listed, 34 pre-existing, 484 named in Go`, gate OK).
1. **`For("hu")` is the parsed struct with `I18n` cleared, and nothing else.** Deep-compared against
all 53 real catalog files, copied into `internal/stacks/testdata/catalog/`. `LocalizeStacks`
returns the INPUT SLICE for Hungarian rather than a copy, so nothing on the Hungarian path is
even touched. §1 holds by construction, then measured live.
2. **Fallback is field by field, and blank counts as absent.** A half-translated app is a legal,
shippable state — that is what makes fifty apps pushable a batch at a time.
3. **The three prose lists replace WHOLE; every other list is matched by its own key** — `env_var`,
option `value`, `match_group`, `target`, `path`. Position matching mistranslates silently the
first time a Hungarian field is inserted above another, and the page still looks right.
4. **`For` never writes through.** The metadata is the stack manager's and is shared by concurrent
requests; an in-place merge would put one household's language on another's page.
5. **An older controller ignores the block.** `LoadMetadata` uses non-strict `yaml.Unmarshal` — this
repo constructs no `yaml.Decoder` at all, so there is no `KnownFields` anywhere to turn strict.
Proven live below.
## Three guards had to learn the change, and one caught me
## Where a page may read catalog copy
- **The test seam** `pushFn` now carries `messageCustomer`. A seam that cannot see a new field cannot
test it — the lesson this repo recorded one release ago 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 reported them as no longer existing — the
"a stale register entry is also a failure" half doing its job. The three new 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 exactly how
a seam gets built and never wired in this repo. Red-proofed by deleting the call.
**Four templates** carry catalog copy — `dashboard.html` (the app rows' second line), `stacks.html`
(the Apps list), `app_info.html` (the app page, including the data-folder cards) and `deploy.html`.
A fifth, `app_export.html`, reads `.Stack.Meta.Subdomain`, which is configuration.
## A mistake, named
**Five handler sites** feed them, and each now goes through `LocalizeStacks` / `LocalizeStackPtr` /
`meta.For(lang)`: the dashboard, the Apps list, the deploy page, the app page — and the logs page,
which today shows only the app's NAME and is routed through the same call anyway, because the next
person to add a line to `logs.html` should not have to notice that this one page was the exception.
Two producers sit in packages that 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, identical bytes for Hungarian.
The first pass of the conversion **dropped the `displayName` argument from three producers**
(`app_deployed`, `app_deploy_started`, `app_removed`). Shipped, that would have mailed customers
`Alkalmazás telepítve: %!s(MISSING)`. Caught by reading my own diff, and now pinned by a test that
refuses any sentence containing `%!`, `MISSING`, `%s` or `%d` in either language.
`TestNoDirectMetaCopyReadOnPages` parses every non-test file in `internal/web` and fails on any
`x.Meta.<copy-field>` read that is not in a named, reasoned allow-list (three entries today, all
`appDetailHandler`'s localised `found`). **What it cannot do, stated in the test:** it reads source,
so it cannot tell a localised receiver from an unlocalised one. It catches the ARRIVAL of a new copy
read, which is when a human has to think — that is the whole claim made for it.
## What is NOT converted, and why
## Tests, and the red-proofs
- **The operator-tier types** (15 of them, listed in the hub's `operatorOnlyEvents`): the operator
reads Hungarian, and these never reach a customer.
- **Producers whose sentence is composed in another package** and reaches the notifier already
finished — `backup_failed`, `db_dump_failed`, `backup_integrity_ok`/`_failed`,
`offbox_enlarge_blocked`, `local_api_endpoint_drift`. They still send Hungarian only, so an English
household can still see one Hungarian line in some mails. **Filed as a row rather than left as
prose.**
- The R-570 sentence, which stays Hungarian until that row closes.
| test | red-proof (each seen to fail, then reverted) |
|---|---|
| `TestMetaForHuIsIdentity` (53 real files) | a copy field mutated inside `For`'s hu branch — FAILED naming 53 apps |
| `TestDeployFieldsMatchedByKey` | deploy fields matched by position — FAILED, "Second field" landed on FIRST |
| `TestEmptyEnglishFallsBack` | a blank English string allowed to win — FAILED |
| `TestForDoesNotMutateTheReceiver` | the defensive copy removed — FAILED, showing the written-through strings |
| `TestOverlayMatches…` (optional_config) | groups matched by position — FAILED |
| `TestOverlayMatches…` (integrations) | integrations matched by position — FAILED |
| `TestOverlayMatches…` (data paths) | data paths matched by position — FAILED |
| `TestLocalizeStacksLeavesHungarianSliceAlone` | `I18n` left on the value handed to the page — FAILED |
| `TestNoDirectMetaCopyReadOnPages` | `_ = st.Meta.Description` added to `logsHandler` — FAILED naming file and field |
## Green
**Two of the nine convicted a hollow TEST rather than the code, and that is the finding worth
keeping.**
`go build` / `go vet` / `go test ./...` clean. All 17 controller gates OK. All 23 decoys behave.
- `TestForDoesNotMutateTheReceiver` first did the obvious thing: copy the struct, call `For`,
`DeepEqual` the copy against the original. **A struct copy shares its slices' backing arrays**, so
a write through `out.DeployFields[i].Label` changes BOTH sides and the compare passes. It passed a
deliberately broken merge. It now snapshots the concrete strings before the call.
- The optional-config / integrations / data-path fixture first carried ONE entry per list. With one
entry, position and key agree, and a position-matching implementation passes. It passed. The
fixture now carries two entries per list with only the second translated.
Full suite, vet and build green; `controller_gates.py` green (17 gates).
## Live proof — endpoint level, and that is stated because it matters
No browser exists on DooPlex. Every page was fetched with `curl` from inside guest 9201 at the
controller's container address, with the customer `Host` header and a real logged-in session: the
same handlers and the same templates a browser drives, with only the drawing skipped.
- **demo-hp, 0.257.0.** With the catalog pilot synced, `/apps/privatebin`, `/apps/paperless-ngx`,
`/apps/romm`, `/stacks` and two deploy pages render the English catalog text on `?lang=en`.
- **The Hungarian did not move.** The same seven pages 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.
- **demo-felhom, 0.255.0 — the old-controller check.** With the block 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 contains the sync's own lines, so the absence is evidence and not a
dead log.
Evidence: `felhom.eu/documentation/audits/i18n-slice5-2026-09-20/`.
## What is still Hungarian on an English app page
Measured with an accent scan AND an ASCII-folded stem scan, both with a positive and a negative
control. Three things, and the first is correct:
1. **the language picker's own „Magyar" button** — a picker names each language in its own tongue;
2. **the update badge „Naprakész" and its tooltip** — **R-589**. Slice 1 listed it (§2.3); slice 2
was the slice that was to take it, and closed without it;
3. **the data-folder card's consequence sentence** — **R-590**. This one makes a promise about the
customer's files, and the label above it is already English, so the line reads half and half.
The Apps LIST also shows fifty Hungarian descriptions — those are the apps nobody has translated yet.
## Gaps filed
**R-589** (the update badge) · **R-590** (the data-folder promise) · **R-591** (`Stack.Copy()` deep-copies
five `Meta` fields and not the new `I18n` map — safe today because nothing mutates an overlay, which is
exactly why it is worth a row) · **R-592** (three defects inside the new catalog gate, each found by its
own decoy).
## Needs the operator
**Raise the fleet floor to 0.257.0.** Until then only demo-hp can read an English app block at all;
every other box keeps showing Hungarian app text to an English household. Nothing breaks if it waits.
## Teardown
Nothing installed or removed on any box; no app deployed. Guest 9201 on demo-hp was restarted onto the
new image, which is the deploy. The staged password file and the capture scripts were shredded from
both Proxmox hosts and both guests. Both boxes left on Hungarian.