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
+3 -1
View File
@@ -7,7 +7,9 @@
>
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
Last updated: 2026-09-18 (v0.255.0 — the globe on the sign-in-flow pages: styled, and inside the card)
Last updated: 2026-09-20 (v0.257.0 — the app catalog can speak English)
> **2026-09-20 — v0.257.0 (R-560 slice 5 Part A).** The catalog's `.felhom.yml` may now carry an `i18n: {en: …}` sibling block, and the dashboard reads it. `Metadata.For(lang)` merges FIELD BY FIELD (absent or blank English shows Hungarian, so a half-translated app ships); `For("hu")` is the parsed struct with `I18n` cleared and nothing else, deep-compared over all **53 real catalog files** copied into `internal/stacks/testdata/catalog/`; the three prose lists replace WHOLE and every other list is matched by its own key (`env_var`, option `value`, `match_group`, `target`, `path`) — **never by position**; `For` never writes through the manager's shared metadata. Pages reach copy only via `LocalizeStacks`/`LocalizeStackPtr`/`MetaFor`, and `TestNoDirectMetaCopyReadOnPages` AST-parses `internal/web` against a named allow-list so the NEXT page to read catalog copy fails the suite instead of rendering Hungarian to an English household. **Two of the nine red-proofs convicted a hollow TEST, not the code:** 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 rewritten. Proven live on demo-hp (English pages English, seven Hungarian pages byte-identical apart from the CSRF token) and on demo-felhom **0.255.0** (block synced, pages identical, no parse warning in a 93-line log window). Gaps: R-589 (update badge still Hungarian in English), R-590 (the data-folder promise sentence), R-591 (`Stack.Copy()` does not deep-copy the new `Meta.I18n`), R-592 (three defects inside the new catalog gate, each found by its own decoy). **Floor NOT raised — the operator's step; until it moves, only demo-hp reads English app text.**
> **2026-09-18 — v0.255.0 (fixes the v0.254.0 globe).** The sign-in-flow pages requested `style.css` with **no `?v=`**, so a browser holding a pre-0.254.0 copy served CSS with no `.lang-globe` rules and the globe rendered as a bare unstyled `<details>` — **invisible to every test, because they all read markup and the fault was in which CSS file the browser fetched**. It was FIVE shells, not three (both guest share pages too), and `.Version` was missing from three of their data maps — now set once in `executeTemplateLang`. The globe also moved INSIDE the card, centred under the footer, menu opening upward via the shared rule. 15 shell fixtures re-captured, **zero dashboard ones**. R-579.
+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.
+50 -1
View File
@@ -3614,7 +3614,7 @@ without reaching `Images()`.
---
### 18. Dashboard language (i18n) (v0.247.0–v0.256.0)
### 18. Dashboard language (i18n) (v0.247.0–v0.257.0)
Design: `felhom.eu/documentation/architecture/10-localisation.md`. Inventory:
`felhom.eu/documentation/audits/I18N-INVENTORY-2026-09-17.md`.
@@ -3715,6 +3715,55 @@ reads** (see below).
captured from the unconverted templates (`internal/web/testdata/i18n_parity/`). Fixtures are never
regenerated to make a conversion pass.
#### Catalog copy in a second language (v0.257.0, R-560 slice 5)
The message bundle above covers text the CONTROLLER owns. An app's own words — its one-line
description, tagline, `use_cases`, `first_steps`, `prerequisites`, `default_creds`, the deploy-field
labels/descriptions/placeholders and select-option labels, `optional_config` group and field text,
integration labels, `data_paths` labels and the initial-credentials note — come from the catalog's
`.felhom.yml` and are Hungarian. They are NOT bundle keys: they belong to the catalog, change on a
catalog push, and a box may run a catalog the controller has never seen.
- **Format:** a sibling block in the SAME file, 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.I18n map[string]MetadataOverlay` (`internal/stacks/metadata_i18n.go`), `json:"-"` — the
untranslated half never travels to a page or an API answer; the merged view does.
- **Read path:** `Metadata.For(lang)`, reached only through `stacks.LocalizeStacks` (a list),
`LocalizeStackPtr` (one stack) or `Stack.MetaFor`. **`For("hu")` is the parsed struct with `I18n`
cleared and nothing else** — `TestMetaForHuIsIdentity` deep-compares it over all 53 real catalog
files in `internal/stacks/testdata/catalog/`. `LocalizeStacks` returns the INPUT SLICE for
Hungarian rather than a copy.
- **Field-by-field fallback:** an English field that is absent — or blank — shows the Hungarian one.
A half-translated app is a legal state, which is what lets the catalog be translated in batches.
- **The three prose lists replace WHOLE** (`use_cases`, `first_steps`, `prerequisites`): merging by
index 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.
- **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.
- **Pages:** the dashboard rows, the Apps list, the logs page, the deploy page and the app page.
`TestNoDirectMetaCopyReadOnPages` AST-parses `internal/web` and fails on any `x.Meta.<copy>` read
outside a named, reasoned allow-list — so a NEW page that reads catalog copy off the manager fails
the suite instead of quietly rendering Hungarian to an English household.
- **Two 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.
- **Older controllers are unaffected:** `LoadMetadata` uses non-strict `yaml.Unmarshal` (this repo
constructs no `yaml.Decoder`), so a pre-0.257.0 box drops the whole block. Proven live on a 0.255.0
box: pages byte-identical before and after the block arrived, no parse warning in a live log.
- **The catalog side** carries the freeze and the gate: `app-catalog-felhom.eu/scripts/copy_freeze/hu.json`
(every Hungarian copy string, captured before any translation) and `scripts/check-copy-i18n.py`
(freeze + structure + language + credential tokens + a coverage ratchet), fifth row of that repo's
`catalog_gates.py`.
## Repository Layout
```