docs: localisation slice 2 release B (controller v0.253.0) — R-557 progress, R-575, R-576
gates / gates (push) Successful in 25s

All 179 Hungarian error messages carry a key; zero remain. 10-localisation.md gains §10.2: the
four properties util.MsgError had to have at once and the failure each one prevents, and the
plural rule as a BUNDLE rule rather than a per-call-site flag, with the answerable sentence and
what the other option would have cost.

Two instrument defects recorded rather than tidied away, because both shapes recur:

R-576 — the parity gate has a measured blind spot. The bulk converter dropped the continuation
of multi-line concatenations, damaging 7 producers, and the gate stayed GREEN: every surviving
fragment WAS a byte-equal base-commit literal, so its question ("is this text real?") was
answered yes while the CALL had lost half its sentence. Two behaviour tests caught it. The
general form: a structural gate over the TEXT cannot see a defect in the CALL.

And the script counting what was left was case-sensitive, so it said "0 remain" while five did —
R-565's shape inside the measurement. Every "no Hungarian left" claim in this slice is now made
case-insensitively and with both controls.

R-575 — the soft memory-overcommit warning has no error to carry a key and no language where it
is built, so it renders Hungarian on an English page. Named in the code, not hidden.

Live evidence includes a mistake I made and corrected: a probe of the deploy refusal INSTALLED
vaultwarden on demo-hp (the endpoint accepts before it validates), the same mistake the previous
session recorded. Removed through the product's own path with its data; verified gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-18 11:51:06 +02:00
parent 92ee0f9881
commit 08e9ddbd5c
9 changed files with 261 additions and 32 deletions
+33 -30
View File
@@ -1,46 +1,49 @@
# STATUS — what works, what's broken, what's next
**Updated 2026-09-18 — the sentences the program writes now follow the language.**
**Updated 2026-09-18 (evening) — the sentences the program writes now follow the language, error messages included.**
> **Ready for a volunteer: yes, unchanged.** A Hungarian household sees exactly what it saw
> yesterday. I did not read the pages to decide that — I compared them, letter by letter.
**Decisions I took.** None. One choice you were asked for stayed on its own stated default: notes
saved to disk at night (last error, last warning) will be written in the box's language at the time
they are written. A household that switches language sees last night's note in the old language until
the next run. That is option 1 in the plan, and the plan says it is what happens if nobody decides.
**Decisions I took.** None you have to reverse. One choice you were asked for stayed on its own
stated default: notes saved to disk overnight will be written in the box's language at the time they
are written, so a household that switches sees last night's note in the old language until the next
run. That is what the plan says happens if nobody decides.
**What I exercised, on the demo HP box.** The pages were already English. The SENTENCES the program
writes into them were not — "Saved.", "The remote target is not set.", the country names, the yellow
banners at the top. I moved 226 of them, and they now follow the language. Two I want to name:
**What I did.** Two releases. The first moved the sentences the program writes into the pages — the
little green line after you press a button, the yellow banners, the country names, the answers the
page fetches in the background. The second did the **error messages**: about 180 sentences that are
written deep inside the program and printed by whatever catches them. All of them now travel with a
short code, so the page that shows them writes them in your language. **None are left in Hungarian
only.**
- **The little green "done" line after you press a button.** It travels inside the web address, so
it is written by one page and read by the next. It used to be written in whoever's language came
first. It now travels as a short code and the page you land on writes the sentence. A link you
already have open from before still shows its old sentence, word for word — I checked that live.
- **The country list.** All 237 names, and the list re-sorts itself, so the English list really is in
English alphabetical order and not Hungarian order with English words in it.
**My mistake, and what I did about it.** My first test of an error message asked the box to install an
app with nothing filled in, expecting a refusal — and it installed the app instead, because that
particular one needs nothing filled in. **This is the same mistake the session before mine made and
wrote down, and I repeated it.** I stopped the app and removed it the way a customer would, with its
data, and checked: no container, no folder, no leftover storage. The 23 apps that should be running
are all running. The real lesson is sharper than "pick a different app": that button accepts first and
checks afterwards, so it cannot be used to test a refusal at all. The tests were rewritten to use
requests that are refused before anything is created.
**How I know Hungarian did not change.** A new check freezes every Hungarian sentence in the program
as it stood before I started — 7 467 of them — and refuses any sentence that is not exactly that,
down to a comma. It caught a real mistake in itself on the first run and I fixed the check. Then on
the box: ten Hungarian pages before and after. Six identical. The other four differ only in things
that move on their own — a clock ticking over, an app that was unhealthy for a minute, and the
"update available" line, which is true on the old version and not on the new one.
**How I know Hungarian did not change.** A check freezes every Hungarian sentence as it stood before I
started — 7 467 of them — and refuses any that is not exactly that, down to a comma. Then on the box:
ten Hungarian pages before and after, six identical, the other four differing only in a clock ticking
over, an app unhealthy for a minute, and the "update available" line.
**What I deliberately did NOT translate.** Anything the central service reads and turns into an
e-mail to the household. I checked, and found the plan was wrong about this: the service does not
always write its own e-mail — for several kinds of message it sends the box's own sentence straight
on. Translating those here would have changed an e-mail nobody asked to change. They are now frozen
by a test, and they get their turn in the next step.
**Two things went wrong in my own tools, and I am naming them rather than tidying them away.** My
bulk converter quietly dropped the second half of sentences that were written across two lines — seven
of them — and **the freeze-check did not notice**, because each surviving half really was a real old
sentence. What caught it was two tests that read the sentence a customer would see. And my counting
script ignored capital letters, so it told me "none left" while five were. Both are written down with
a number.
**What is not done yet.** About 900 sentences remain. 176 of them are error messages, which is the
next release; the rest are notes saved to disk, which is the one after. Four smaller gaps I found
while working are written down with a number each.
**What is left.** The notes saved to disk overnight — one more release. Plus a handful of small gaps,
each with a number.
**Rows.** One closed, three opened. The register went from **264** to **266** open rows.
**Rows.** One closed, five opened across the day. The register went from **264** to **269** open rows.
**Needs you.** Nothing. The new version runs on the demo HP box only; the fleet floor is unchanged at
**Needs you.** Nothing. Both new versions run on the demo HP box only; the fleet floor is unchanged at
0.250.0. If you do nothing: the other boxes keep the version they have, and nothing breaks.
---
+45 -1
View File
@@ -279,7 +279,7 @@ session). Each slice ends with the parity gate green for every page it touched.
|---|---|---|---|---|
| 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 | R-557 (after R-553) | Go-side customer strings; flash-in-URL → keys; country names; alert texts | a page's server messages follow the language | 16–20 h, three releases · **A shipped 2026-09-18** |
| 2 | R-557 (after R-553) | Go-side customer strings; flash-in-URL → keys; country names; alert texts; the 179 error messages | a page's server messages follow the language | 16–20 h, three releases · **A and B shipped 2026-09-18; C remains** |
| 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 |
@@ -350,6 +350,50 @@ night's note in the old language until the next run rewrites it. The alternative
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.
---
## 11. Operator decisions
@@ -0,0 +1,58 @@
# Live validation — controller v0.253.0 on demo-hp guest 9201 (2026-09-18, release B)
**Method: endpoint-level, stated as such.** No browser on DooPlex. Session from a real `POST /login`;
`X-CSRF-Token` from the page meta for API calls; the password passed as a file and deleted from host
and guest afterwards.
## 0. A mistake I made, first — an app was installed on the demo box and removed
My first release-B probe posted `/api/stacks/vaultwarden/deploy` with empty values, expecting the
required-field refusal. **The deploy API answers 202 and validates ASYNCHRONOUSLY**, and vaultwarden
has no required field — so it installed. **That is the same mistake the 2026-09-17 session recorded
in STATUS.md, repeated.** It is not a trap in the product; it is a trap in the probe, and the lesson
is narrower than "pick a different app": **the deploy endpoint cannot be probed for a refusal at all,
because it accepts before it validates.**
Removed through the product's own path — stop, then `POST /remove` with `remove_hdd_data` and
`remove_backups` — and verified gone: no container, no `/opt/felhom/stacks/vaultwarden`, zero
`vaultwarden` volumes. The 23 standing containers are all up. The removal answered
`hdd_note: „Az alkalmazás nem tárolt saját adatot külső meghajtón…"`, so nothing was on a drive.
The probe was rewritten to use only endpoints whose validator runs BEFORE any mutation
(`probeB2.sh`, `probeB3.sh`).
## 1. An error made deep in a package, rendered on the page in each language
`POST /sharing/shares` with a bad name. The message is produced in `internal/settings/smb.go`, three
layers below the handler, and the redirect is FOLLOWED so what is read is what the page shows.
| refusal | hu | en |
|---|---|---|
| name has a slash | „a megosztás neve nem tartalmazhat perjelet vagy pontot" | "the share name cannot hold a slash or a dot" |
| name empty | „a megosztás neve nem lehet üres" | "the share name cannot be empty" |
| name longer than 15 | „a megosztás neve legfeljebb 15 karakter lehet" | "the share name can be at most 15 characters" |
| name starts with `_` | „a megosztás neve nem kezdődhet aláhúzással — ezek a nevek a rendszernek vannak fenntartva" | "the share name cannot start with an underscore — those names are reserved for the system" |
Every Hungarian line is the literal that stood in `smb.go` before the conversion, byte for byte.
## 2. The compatibility case, still true after B
`/sharing?flash=Be%C3%A1ll%C3%ADt%C3%A1s+mentve.` — a URL an older controller minted — renders
„Beállítás mentve." in **both** languages. Shown verbatim, never as a key, never dropped.
## 3. What stays English on purpose, confirmed live
`POST /api/stacks/felhom-controller/remove` → `403 stack "felhom-controller" is protected and cannot
be removed`. Internal English, identical in both languages: **R-569**, not this slice. Recorded here
so it is not read as a gap release B left.
An agent-side refusal (`/api/disks/format` on a device that is not whitelisted) comes back as
`agentapi: format: HTTP 400: storage: refusing to operate on non-whitelisted block device` — the
AGENT's sentence, relayed verbatim in both languages. That is the rule working: text this controller
did not write is text it does not translate.
## 4. State left behind
Controller **0.253.0**; saved language **`hu`** (every English probe used the `?lang=en` override,
which is not persisted); 23 standing containers up, unchanged; **vaultwarden installed by mistake and
fully removed**; no drive touched, no floor raised, no golden baked.
@@ -0,0 +1,19 @@
LOGIN OK
##### lang=hu
1 disks/assign, mount name with a slash (internal/web validator): {"error":"uuid and where are required","ok":false}
[400]
2 disks/format, unsupported filesystem (internal/web validator): {"error":"agentapi: format: HTTP 400: storage: refusing to operate on non-whitelisted block device \"/dev/zzz\"","ok":false}
[500]
3 share name with a slash (internal/settings/smb.go validator): err.settings.a_megosztas_neve_nem_tartalmazhat_perjelet
4 share name empty (internal/settings/smb.go validator): err.settings.a_megosztas_neve_nem_lehet_ures
5 remove a PROTECTED stack (internal/stacks, English by design): {"ok":false,"error":"stack \"felhom-controller\" is protected and cannot be removed"}
[403]
##### lang=en
1 disks/assign, mount name with a slash (internal/web validator): {"error":"uuid and where are required","ok":false}
[400]
2 disks/format, unsupported filesystem (internal/web validator): {"error":"agentapi: format: HTTP 400: storage: refusing to operate on non-whitelisted block device \"/dev/zzz\"","ok":false}
[500]
3 share name with a slash (internal/settings/smb.go validator): err.settings.a_megosztas_neve_nem_tartalmazhat_perjelet
4 share name empty (internal/settings/smb.go validator): err.settings.a_megosztas_neve_nem_lehet_ures
5 remove a PROTECTED stack (internal/stacks, English by design): {"ok":false,"error":"stack \"felhom-controller\" is protected and cannot be removed"}
[403]
@@ -0,0 +1,29 @@
#!/bin/bash
# Release B probe, SAFE BY CONSTRUCTION: every request below is refused by a validator that runs
# BEFORE any mutation. Nothing is installed, created, formatted or deleted.
IP=172.17.0.2:8080
H="Host: felhom.enkisfelhom.hu"
PW=$(cat /tmp/.felhompw); J=/tmp/pj.txt; rm -f $J
curl -s -c $J -H "$H" "http://$IP/login" -o /tmp/lg.html
CSRF=$(grep -o 'name="_csrf" value="[^"]*"' /tmp/lg.html | head -1 | sed 's/.*value="//;s/"//')
SESS=$(curl -s -b $J -H "$H" -D - -o /dev/null -X POST "http://$IP/login" --data-urlencode "password=$PW" --data-urlencode "_csrf=$CSRF" | grep -i '^set-cookie: felhom_session' | head -1 | sed 's/[Ss]et-[Cc]ookie: //;s/;.*//')
[ -z "$SESS" ] && { echo "LOGIN FAILED"; exit 1; }
curl -s -H "$H" -H "Cookie: $SESS" "http://$IP/launcher" > /tmp/lp.html
MC=$(grep -o 'name="csrf-token" content="[^"]*"' /tmp/lp.html | head -1 | sed 's/.*content="//;s/"//')
SC=$(grep -o 'name="_csrf" value="[^"]*"' /tmp/lp.html | head -1 | sed 's/.*value="//;s/"//')
echo "LOGIN OK"
for L in hu en; do
echo "##### lang=$L"
JP(){ curl -s -H "$H" -H "Cookie: $SESS" -H "X-CSRF-Token: $MC" -H 'Content-Type: application/json' --max-time 12 -X POST "http://$IP$1" -d "$2" -w " [%{http_code}]"; }
FP(){ curl -s -H "$H" -H "Cookie: $SESS" --max-time 12 -o /dev/null -w "%{redirect_url}" -X POST "http://$IP$1" --data-urlencode "_csrf=$SC" "${@:2}"; }
echo -n " 1 disks/assign, mount name with a slash (internal/web validator): "
JP "/api/disks/assign?lang=$L" '{"device":"/dev/zzz","name":"bad/name"}' | head -c 200; echo
echo -n " 2 disks/format, unsupported filesystem (internal/web validator): "
JP "/api/disks/format?lang=$L" '{"device":"/dev/zzz","name":"probe","fstype":"btrfs"}' | head -c 200; echo
echo -n " 3 share name with a slash (internal/settings/smb.go validator): "
FP "/sharing/shares?lang=$L" --data-urlencode "name=bad/name" --data-urlencode "path=/tmp" | sed 's/.*flash[^=]*=//' | head -c 200; echo
echo -n " 4 share name empty (internal/settings/smb.go validator): "
FP "/sharing/shares?lang=$L" --data-urlencode "name=" --data-urlencode "path=/tmp" | sed 's/.*flash[^=]*=//' | head -c 200; echo
echo -n " 5 remove a PROTECTED stack (internal/stacks, English by design): "
JP "/api/stacks/felhom-controller/remove?lang=$L" '{}' | head -c 200; echo
done
@@ -0,0 +1,34 @@
#!/bin/bash
# Release B, the sentence AS RENDERED. Every request is refused by a validator that runs before any
# mutation; the redirect is then FOLLOWED so what is read is what the customer sees on the page.
IP=172.17.0.2:8080
H="Host: felhom.enkisfelhom.hu"
PW=$(cat /tmp/.felhompw); J=/tmp/pj.txt; rm -f $J
curl -s -c $J -H "$H" "http://$IP/login" -o /tmp/lg.html
CSRF=$(grep -o 'name="_csrf" value="[^"]*"' /tmp/lg.html | head -1 | sed 's/.*value="//;s/"//')
SESS=$(curl -s -b $J -H "$H" -D - -o /dev/null -X POST "http://$IP/login" --data-urlencode "password=$PW" --data-urlencode "_csrf=$CSRF" | grep -i '^set-cookie: felhom_session' | head -1 | sed 's/[Ss]et-[Cc]ookie: //;s/;.*//')
[ -z "$SESS" ] && { echo "LOGIN FAILED"; exit 1; }
curl -s -H "$H" -H "Cookie: $SESS" "http://$IP/sharing" > /tmp/sp.html
SC=$(grep -o 'name="_csrf" value="[^"]*"' /tmp/sp.html | head -1 | sed 's/.*value="//;s/"//')
echo "LOGIN OK"
# $1 label, $2 form args...
try(){ local label="$1"; shift
for L in hu en; do
LOC=$(curl -s -H "$H" -H "Cookie: $SESS" --max-time 12 -o /dev/null -w "%{redirect_url}" \
-X POST "http://$IP/sharing/shares" --data-urlencode "_csrf=$SC" "$@")
# the URL the browser is sent to, then the page as it renders it
Q=$(echo "$LOC" | sed 's|.*/sharing?||')
TXT=$(curl -s -H "$H" -H "Cookie: $SESS" --max-time 12 "http://$IP/sharing?${Q}&lang=$L" \
| grep -o 'alert alert-success">[^<]*' | head -1 | sed 's/.*">//')
printf " %-34s %s -> %s\n" "$label" "$L" "$TXT"
done
}
try "share name with a slash" --data-urlencode "name=bad/name" --data-urlencode "path=/tmp"
try "share name empty" --data-urlencode "name=" --data-urlencode "path=/tmp"
try "share name too long (>15)" --data-urlencode "name=aaaaaaaaaaaaaaaaaaaa" --data-urlencode "path=/tmp"
try "share name starts with _" --data-urlencode "name=_reserved" --data-urlencode "path=/tmp"
echo "--- and the legacy case: a URL an OLD controller minted, carrying prose ---"
for L in hu en; do
TXT=$(curl -s -H "$H" -H "Cookie: $SESS" --max-time 12 "http://$IP/sharing?flash=Be%C3%A1ll%C3%ADt%C3%A1s+mentve.&lang=$L" | grep -o 'alert alert-success">[^<]*' | head -1 | sed 's/.*">//')
printf " %-34s %s -> %s\n" "legacy prose in the URL" "$L" "$TXT"
done
@@ -0,0 +1,12 @@
LOGIN OK
share name with a slash hu -> a megosztás neve nem tartalmazhat perjelet vagy pontot
share name with a slash en -> the share name cannot hold a slash or a dot
share name empty hu -> a megosztás neve nem lehet üres
share name empty en -> the share name cannot be empty
share name too long (>15) hu -> a megosztás neve legfeljebb 15 karakter lehet
share name too long (>15) en -> the share name can be at most 15 characters
share name starts with _ hu -> a megosztás neve nem kezdődhet aláhúzással — ezek a nevek a rendszernek vannak fenntartva
share name starts with _ en -> the share name cannot start with an underscore — those names are reserved for the system
--- and the legacy case: a URL an OLD controller minted, carrying prose ---
legacy prose in the URL hu -> Beállítás mentve.
legacy prose in the URL en -> Beállítás mentve.
@@ -24,3 +24,31 @@ Each line: what was broken, what convicted, restored green after.
7. internal/web — a Server with no bundle field renders raw keys
planted: s.bundle() returns s.i18n unchanged (no i18n.Shared fallback)
CONVICTS: TestFlashOnAServerWithNoBundleField — showed "flash.share.enabled"
RELEASE B (v0.253.0), 2026-09-18 — errors carry a key
8. util.msgError.Error() returns ENGLISH instead of Hungarian
CONVICTS: TestMsgErrorKeepsKindAndHuText — "%v printed 'That setting is not valid: …'"
9. Unwrap() returns the kind ALONE (the old KindErrorf behaviour)
CONVICTS: TestMsgErrorUnwrapsTheCauseToo — "the wrapped CAUSE is unreachable"
10. ErrText rewrites a FOREIGN error (restic/docker/stdlib) instead of passing it through
CONVICTS: TestErrTextFallsBackVerbatim, both languages
11. an inner error argument is pre-rendered in Hungarian instead of recursively
CONVICTS: TestErrTextRendersAWrappedMessageErrorToo
12. err.Error() put back at a converted display sink (storage_handlers.go:822)
CONVICTS: TestNoErrErrorInPageOutput, naming the file, the line and the call
13. the English plural collapsed to the singular (alert.deadapp.group.other)
CONVICTS: TestPluralEnglish — "4 installed app is not running"
14. Hungarian GIVEN a plural form it must not have
CONVICTS: TestPluralEnglish — "the Hungarian sentence moved"
TWO LIVE CATCHES IN RELEASE B — neither planted:
A. The bulk converter SILENTLY DROPPED the continuation of a multi-line concatenation
(`fmt.Errorf("a: "+ "b: %s", x)` kept only "a: "), damaging 7 producers. Found by
TestR356_ScenarioC and TestR379_ScenarioA, which assert the SENTENCE a customer reads.
The parity gate did NOT catch it: every surviving fragment was byte-equal to a base literal,
so the gate's question ("is this text real?") was answered yes while the CALL had lost text.
All 7 rebuilt as joined keys; the six wrong keys pruned from both bundles.
B. My own counting script was CASE-SENSITIVE, so it reported "0 error literals left" while five
remained ("occ parancs sikertelen", "hub hiba", "OnlyOffice aldomain nem ismert" x2). The
R-565 shape, in the instrument. Re-measured with re.I; the five are converted.
+3 -1
View File
@@ -733,7 +733,7 @@ class (an image `VOLUME` at an unmounted path) is still live — `immich-server`
| **R-552** | **[P3-LOW] An interrupted-restore notice for an app that is then REMOVED stays on the restore page for ever.** FOUND 2026-09-17 by CC reviewing its own controller v0.246.0 (R-550) during live validation. The per-app notice (`Manager.opInterrupted`, persisted in `restore-status.json`) is cleared in exactly one place — `BeginRestoreOp` for that app (`internal/backup/opstatus.go`) — and `removeStack` (`internal/api/router.go`) never touches the restore record. So a household that answers „A visszaállítás megszakadt … indítsd el újra" by REMOVING the app instead of restoring it keeps a „Megszakadt visszaállítás" card about an app that no longer exists. Measured shape, not hypothetical: on 9201 the notice cleared only when homebox was restored again (08:54:50Z, card count 0) — the teardown deliberately took that path before removing it. **Fix shape:** `removeStack` clears the app's notice (a `ClearInterruptedRestore(stack)` beside the existing update-hold clear, R-491's precedent), with a wiring test. Not fixed in v0.246.0: found after the release was built; one release per repo per session. | **READY - rank P3-LOW; owner: CC** |
| **R-554** | **[P3-LOW] Delete the first-boot setup wizard — obsolete by design, still reachable.** OPERATOR DECISION 2026-09-17 (localisation starter, decision 4: „out of scope, obsolete"). `02-controller-module-map.md` L56 calls `internal/setup/` obsolete; `cmd/controller/main.go` L322 still enters it when `setup.NeedsSetup(cfg)` — `customer.id` empty after bootstrap ingestion, or a `.needs-setup` marker (`internal/setup/setup.go` L17-25). Ingestion leaves `customer.id` empty on a missing/invalid `bootstrap.json`, a failed hub pull, or a failed merge/write/reload (`internal/bootstrap/bootstrap.go` L109-163) — so a box whose first boot cannot reach the hub shows a household an 8-page wizard (95 Hungarian strings, its own template set and CSRF). **Fix shape:** decide what such a box shows instead (a single „cannot reach Felhom yet, retrying" page — needs no decision beyond copy), then delete `internal/setup/` and `runSetupMode`; red-proof that a failed ingestion renders the waiting page, not a 404. **Check first** whether any drill/golden path still relies on `.needs-setup`. | **READY - rank P3-LOW; owner: CC** |
| **R-555** | **[P3-LOW] The wire-contract gate counts a field as received when its name appears in a Go COMMENT on the receiving side.** FOUND 2026-09-17 by CC adding the report's `language` field (controller v0.247.0): `scripts/wire_contract_gate.py` passed WITHOUT an allowlist entry, because `receiver_tokens()` tokenises whole files and the word „language" occurs hub-side only in a comment (`hub/internal/web/configs.go:558`, „this page's existing language"). The shape is the gate's own named failure class (name-for-fact, R-421): any English tag name that also appears in hub prose passes unread. The field was allowlisted by hand with this row named. **Fix shape:** strip `//` and `/* */` comments (and template `{{/* */}}`) before tokenising; add the decoy „a tag whose name appears only in a receiver comment must convict"; expect a handful of currently-passing tags to surface — each is a finding, not noise. | **READY - rank P3-LOW; owner: CC** |
| **R-557** | **[P3-LOW] Localisation slice 2 — Go-side customer strings follow the language.** PLAN 2026-09-17 (10 §10). Inventory §2.2: 947 shown + 184 error literals in 84 files; 237 format strings, 111 concatenations, 42 numeric `%d` (English plurals). Flash messages travel inside the redirect URL (`?flash=<Hungarian>`) and must become keys; `cloudflare/countries.go` (113 country names); alert texts; handler errors printed with `err.Error()`. **After R-553** (the four compare-not-show sites). Cost 16–20 CC-hours. **DEPENDENCY added 2026-09-17 (R-553 shipped, v0.251.0):** the behaviour-by-wording sites are fixed, so this slice is unblocked — EXCEPT one producer: `"Sikeres — nincs mentésre jelölt alkalmazás"` (controller/internal/backup/offbox.go) must stay Hungarian until **R-570** closes, because the page's legacy fallback still reads it on boxes that have not run off-site since 0.251.0. Everything else this slice touches is now decided by a kind, not by its words. **RELEASE A SHIPPED 2026-09-18 (controller v0.252.0):** 226 Go literals converted -- flash-as-key (8 writers, 8 readers, legacy prose still shown verbatim), page data and view-model text, the `internal/api` JSON answers, the alert banners (`Alert.MessageKey`), 237 country names at DISPLAY (the cloudflare table is untouched -- only CODES are on the wire, the task claimed otherwise), and the four app-named page titles (R-566 CLOSED). New gate `i18n_go_parity.py` + `i18n_go_base.json` (7 467 base-commit literals, frozen) refuses any key whose Hungarian is not byte-identical; three decoys. Wire goldens freeze `internal/monitor` and `internal/notify` -- the hub MAILS the event message when it has no `customerMessages` entry, so those stay Hungarian until R-558. `HU_FORMAL_CEILING` 16 -> 18 (measured, no word changed). **RELEASE B (next): 176 `fmt.Errorf`/`errors.New` literals** carry a key via `util.MsgError`. **RELEASE C: persisted text**, written in the box language at write time (the task's s16 option 1, its stated default). Gaps found and filed: R-572, R-573, R-574. | **IN PROGRESS - release A shipped 2026-09-18; rank P3-LOW; owner: CC** |
| **R-557** | **[P3-LOW] Localisation slice 2 — Go-side customer strings follow the language.** PLAN 2026-09-17 (10 §10). Inventory §2.2: 947 shown + 184 error literals in 84 files; 237 format strings, 111 concatenations, 42 numeric `%d` (English plurals). Flash messages travel inside the redirect URL (`?flash=<Hungarian>`) and must become keys; `cloudflare/countries.go` (113 country names); alert texts; handler errors printed with `err.Error()`. **After R-553** (the four compare-not-show sites). Cost 16–20 CC-hours. **DEPENDENCY added 2026-09-17 (R-553 shipped, v0.251.0):** the behaviour-by-wording sites are fixed, so this slice is unblocked — EXCEPT one producer: `"Sikeres — nincs mentésre jelölt alkalmazás"` (controller/internal/backup/offbox.go) must stay Hungarian until **R-570** closes, because the page's legacy fallback still reads it on boxes that have not run off-site since 0.251.0. Everything else this slice touches is now decided by a kind, not by its words. **RELEASE A SHIPPED 2026-09-18 (controller v0.252.0):** 226 Go literals converted -- flash-as-key (8 writers, 8 readers, legacy prose still shown verbatim), page data and view-model text, the `internal/api` JSON answers, the alert banners (`Alert.MessageKey`), 237 country names at DISPLAY (the cloudflare table is untouched -- only CODES are on the wire, the task claimed otherwise), and the four app-named page titles (R-566 CLOSED). New gate `i18n_go_parity.py` + `i18n_go_base.json` (7 467 base-commit literals, frozen) refuses any key whose Hungarian is not byte-identical; three decoys. Wire goldens freeze `internal/monitor` and `internal/notify` -- the hub MAILS the event message when it has no `customerMessages` entry, so those stay Hungarian until R-558. `HU_FORMAL_CEILING` 16 -> 18 (measured, no word changed). **RELEASE B SHIPPED 2026-09-18 (controller v0.253.0): all 179 error literals carry a key** via `util.MsgError` -- `Error()` is still the Hungarian byte for byte, `errors.Is` answers for the kind AND a wrapped cause, an error ARGUMENT renders recursively, and a foreign error (restic/docker/ssh/stdlib) prints verbatim. 76 display sites go through `errText`, pinned by `TestNoErrErrorInPageOutput`. `memoryVerdict` returns an error; `UpdateRefusal` gained a `Cause`. Plurals are a BUNDLE rule (a key with `.one`/`.other` takes its count first), not a call-site flag. **ZERO Hungarian error literals remain** (ASCII search with both controls, case-insensitive). Two tooling defects found and filed: R-576 (the converter dropped concatenations and the gate could not see it) and the case-sensitive counter. New gap: R-575. **RELEASE C: persisted text**, written in the box language at write time (the task's s16 option 1, its stated default). Gaps found and filed: R-572, R-573, R-574. | **IN PROGRESS - releases A and B shipped 2026-09-18; release C (persisted text) remains; rank P3-LOW; owner: CC** |
| **R-558** | **[P3-LOW] Localisation slice 3 — the hub's customer e-mails follow the household's language; the operator sets it at customer creation.** PLAN 2026-09-17 (10 §3, §10), operator decision 2. The box already reports `"language"` (controller v0.247.0); nothing hub-side reads it. Build: a per-customer language on the hub (default hu) set at creation and rendered into `controller.yaml` beside `customer.*` (`hub/internal/configgen/configgen.go`), used by the box only while `settings.json` has no choice; the dispatcher picks the language the box REPORTS; English for the 39 `customerMessages`, the 4 severity labels, the body wrapper, the claim/re-enroll/reset/claimed/self-bind mails and the public bind page (inventory §2.3). Delete the `language` allowlist entry in `wire_contract_gate.py` when the field is read. Cost 6-8 CC-hours, one hub + one controller release. **THE WIRE SURVEY (R-557 release A, 2026-09-18) HANDS THIS ROW ITS INPUT LIST:** (1) `internal/notify/notifier.go` -- 31 event messages. The task assumed the hub composes its own mail from the event kind; it does NOT. `FormatCustomerEmail` uses `customerMessages[eventType]` when it has one and **falls back to the controller's message when it has none**, and appends it as „- Üzenet: %s” whenever the two differ -- several types carry no entry precisely so the controller's dynamic sentence IS the mail (hub `api/handler.go` L2034/L2045/L2066). So an English household's mail is Hungarian until this row ships. (2) `internal/monitor/healthcheck.go` -- 5 storage sentences in report `health.warnings`/`health.issues`, operator-facing. Both are frozen by wire goldens in those packages, so no later slice can translate them by accident; this row is what unfreezes them. | **READY - rank P3-LOW; owner: CC** |
| **R-559** | **[P3-LOW] Localisation slice 4 — the console banner and the download page in English.** IN SCOPE — operator ruling 2026-09-17 evening („yes", 10 §11 1b). PLAN 2026-09-17 (10 §10, open decision 1b): the starter listed them; the operator's scope ruling names „the controller, emails, guide, app catalog" and not them. Inventory §2.4: 34 banner lines (`scripts/iso/felhom-bootstrap.sh`, printf to the console; `/etc/issue` byte-coupled to `iso/pkg/debian/postinst`, console font avoids ő/ű) and 32 strings on `website/letoltes.html`. Cost 4–6 CC-hours plus an ISO release train. | **READY - rank P3-LOW; owner: CC** |
| **R-560** | **[P3-LOW] Localisation slice 5 — catalog cards, settings and first steps in English.** PLAN 2026-09-17 (10 §7, §10). Inventory §2.5: 835 strings, ~4 984 words across all 53 `.felhom.yml` (use_cases 262, first_steps 233, deploy_fields descriptions 79 / labels 68, prerequisites 60, description 56, tagline 53). Proposed format: an `i18n: {en: {...}}` block inside each `.felhom.yml` (controller's `yaml.Unmarshal` ignores unknown keys, so older controllers are unaffected), field-by-field fallback. Needs a controller change to read it and catalog copy gates with an English rule. Cost 12–16 CC-hours. | **READY - rank P3-LOW; owner: CC (catalog + controller)** |
@@ -750,6 +750,8 @@ class (an image `VOLUME` at an unmounted path) is still live — `immich-server`
| **R-572** | **[P3-LOW] Two copy-producing template helpers have no English form, so an English page renders „vasárnap" and „%d órája" in Hungarian.** FOUND 2026-09-18 by localisation slice 2 release A (R-557, controller v0.252.0): `web/i18n_web.go` `localeFuncs` overrides `stateLabel`, `timeAgo`, `timeAgoStr`, `nextRunLabel`, `statusText` and `infraMeta` from the bundle, but NOT `pruneLabel`/`nextPruneLabel` (weekday names — „vasárnap", `funcmap.go` L366/L370) or `fmtDuration`. A template calling either renders Hungarian on an English page, and `TestI18nEnglishPages` does not see „vasárnap" because it subtracts fixture DATA strings — this is the R-565 shape one layer over. **Fix shape:** add both to `localeFuncs` with `func.prune.*` / `func.duration.*` keys, extend `TestLocaleFuncsHungarianBundleMatchesFuncMap` to cover them, and add a render case that exercises a weekly prune schedule in English. | **READY - rank P3-LOW; owner: CC** |
| **R-573** | **[P3-LOW] The agent-channel and endpoint-drift banners reach the dashboard as finished Hungarian, so they stay Hungarian on an English page.** FOUND 2026-09-18 by localisation slice 2 release A (R-557): `Alert` now carries `MessageKey`+`MessageArgs` and `GetAlerts(lang)` renders them, but `SetAgentChannelAlert(down, msg)` and `SetEndpointDriftAlert(drift, msg)` take a composed SENTENCE from the channel-health checker (`internal/channelhealth`), so those two banners set `Message` directly and render verbatim in both languages. They are the two most operationally important banners there are (R-77, the 17.5 h 2026-07-25 outage). **Fix shape:** the checker passes a KIND plus its parameters instead of a sentence — the same shape R-553 used for the off-site warning — and the two setters build `MessageKey`/`MessageArgs`. One render case per banner in English. | **READY - rank P3-LOW; owner: CC** |
| **R-574** | **[P3-LOW] `web/handler_debug.go` mixes page copy with JSON payload, so neither half could be converted safely.** FOUND 2026-09-18 by localisation slice 2 release A (R-557): the file holds 39 Hungarian literals and the inventory classifies them by STATEMENT, not by data flow (`I18N-INVENTORY-2026-09-17.md` §4), so which are section headings the debug page renders and which are values inside a diagnostic dump the operator copies out is not established. Converting a dump value would change what an operator pastes into a report; leaving a heading Hungarian leaves a half-English page. **Fix shape:** walk the file once and label every literal page-copy or payload in the same table slice 2 release A used, then convert only the page-copy half. Belongs to slice 2 release B or C. | **READY - rank P3-LOW; owner: CC** |
| **R-575** | **[P3-LOW] The soft memory-overcommit warning is returned as a Hungarian STRING, so it renders Hungarian on an English page.** FOUND 2026-09-18 by localisation slice 2 release B (R-557, controller v0.253.0): `memoryVerdict` now returns its REFUSAL as an error carrying a key (`util.MsgErrorf(ErrNotEnoughMemory, …)`), but its WARNING is a plain string with no error to carry one, and no language is known where it is built — so it goes through `msgHU` and is always Hungarian. The copy is in the bundle (a translator sees it; `i18n_go_parity.py` pins it), only the render is fixed to `hu`. The named helper and this consequence are stated in `internal/stacks/deploy_errors.go`. **Fix shape:** the caller carries the key the way `Alert` and `UpdateRefusal` now do — `memoryVerdict` returns `(refusal error, warningKey string, warningArgs []any)` and the deploy answer renders it — not a helper guessing a language it cannot know. | **READY - rank P3-LOW; owner: CC** |
| **R-576** | **[P3-LOW] `i18n_go_parity.py` cannot see a call site that LOST text; it only checks that the text a key carries is real.** FOUND 2026-09-18 by localisation slice 2 release B, and found the hard way: the bulk converter silently dropped the continuation of a multi-line concatenation (`fmt.Errorf("a: "+ "b: %s", x)` kept only `"a: "`), damaging **7** producers — and the gate stayed GREEN throughout, because every surviving fragment WAS a byte-equal base-commit literal. Its question ("is this text real?") was answered yes while the CALL had lost half its sentence and its arguments. Two behaviour tests caught it (`TestR356_ScenarioC_UndeployedAppIsStillRefused`, `TestR379_ScenarioA_RollbackSucceeds_AppComesBack`), because they assert the sentence a customer READS. **Fix shape:** the gate learns a second question — for every `util.MsgError("key", …)` call site, the count of its arguments must equal the count of printf verbs in the key's Hungarian value, and no key-naming literal may be adjacent to a `+`. Both are cheap and would have convicted all 7. **The general lesson, worth keeping whatever is built: a structural gate over the TEXT cannot see a defect in the CALL.** | **READY - rank P3-LOW; owner: CC** |
| **R-537** | **[P1-HIGH] The app-backup page labels the tier-1 backup „DB + Konfig + Adatok" and prints the app's data-drive size next to it — but the tier-1 unit contains NO drive-side app data at all.** MEASURED 2026-09-16 on the drill box (fresh install, controller 0.243.0, one drive, tier 2 and tier 3 both „Nincs beállítva"): five photos (3 000 000 B) were uploaded into Nextcloud through its own WebDAV interface, then the customer-visible „Mentés most" was pressed (`POST /api/backup/run` → 200, the unit grew 25 337 B → 978 MB). The resulting unit's `manifest.json` lists `db-dumps` + three **docker volume** dumps and nothing else; listing the 781 MB `nextcloud_nextcloud_html.tar` (29 346 entries, positive control `version.php` = 3 hits) gives **`Fotok` = 0 and `nyaralas` = 0**, and `./data/` is the empty bind-mount point. A `find` over the whole `backups/` tree for `*appdata*` / `*Fotok*` returns nothing. The page nevertheless renders „1. mentés … DB + Konfig + Adatok" and „Nextcloud Adatlemez 65.1 MB" — a size measured on exactly the data it does not copy (`internal/web/handlers.go:1176-1178`, `BackupContents`). **This is a truth defect, not a design defect:** `07-backup-architecture.md` §6.2 places nextcloud's file leg at **Tier 2 and Tier 3 only**, and its „[FACT] What the whole-guest tiers do NOT carry" says `mp8 /mnt/felhom-drives` is out of vzdump scope (confirmed live: „excluding bind mount point mp8 … (not a volume)"). So on a one-drive box with no off-site tier — the state every fresh install starts in — the household's files are in **no backup**, while the page says „Adatok". Same family as R-517/R-518. **Fix shape:** render tier-1 contents from the capture set actually written (`ComputeCaptureSet`), so a unit with no file leg reads „DB + Konfig" and the drive size is not shown beside it; and say on the page that the app's files need tier 2 or tier 3. Evidence: `audits/evidence-drill-0243-2026-09-16/phase2-f10.txt`. **CLOSED 2026-09-16 — controller v0.244.0, proven live.** The contents label is computed PER TIER from what that tier captures: Tier 1 says „Adatok" only when the app's data really is in the volumes the unit captured, and a class-A app carries one sentence saying where its files ARE protected. Proven on demo-hp through the page the customer opens: Paperless-ngx reads „1. mentés … DB + Konfig" with „Az alkalmazás fájljait a távoli másolat (és a második meghajtó) védi …", while its „2. mentés" row still reads „DB + Konfig + Adatok". Red-proof: restoring the old app-shaped label fails `TestAppBackupRows_Tier1LabelDoesNotClaimFilesItCannotHold`. **RE-PROVEN 2026-09-16 on a FRESH box** (installed from the built ISO 1.28.0, controller 0.244.0, off-site on by default): the Nextcloud row read „1. mentés … DB + Konfig" with the new sentence, „2. mentés … Nincs 2. (off-drive) másolat", „3. mentés Sikeres restic → …your-storagebox.de"; „DB + Konfig + Adatok" appeared ZERO times while the local unit held no file leg. | **CLOSED 2026-09-16 — controller v0.244.0 (proven live on demo-hp)** |
| **R-538** | **[P1-HIGH] A tier-1 app restore reports plain success and leaves Nextcloud listing files whose bytes were never in the backup — and it destroys the app's own trash, the customer's last copy.** MEASURED 2026-09-16 on the drill box, F10 („a child deletes the photo folder"): the five photos were deleted through Nextcloud (DELETE 204, PROPFIND 404), then restored through the page exactly as a customer would (`POST /backup/restore` `stack_name=nextcloud` `snapshot_id=helyi` → 302, finished in **35 s**, „A(z) nextcloud: 3 adatkötet és az adatbázis visszaállítva — az alkalmazás újraindult."). Afterwards the folder is back and **lists all five photos**, and **none of them opens**: `GET nyaralas-1..5` = 404 / 503×4 with `Sabre\DAV\Exception\NotFound`, while the positive controls at the same moment pass (`status.php` 200, WebDAV PUT 201, GET 200). Cause: the replayed MariaDB dump (11:01:45Z) knows the photos, the bytes live on `mp8` and were never captured (R-537). **Worse:** the bytes were still on the drive in Nextcloud's own trash (`appdata/nextcloud/admin/files_trashbin/files/Fotok.d1789556707/nyaralas-1..5.jpg`, all five present) and the restored database no longer references them — the trash listing comes back **empty**, so „restore from trash", the one route that would have worked, is gone. The customer is left with five unopenable photos, a success message, and no warning. **Fix shape:** before replaying a database whose app has an uncaptured file leg, refuse or warn („ennek az alkalmazásnak a fájljai nincsenek ebben a mentésben — a visszaállítás után a fájlok hiányozni fognak"); and never present a DB-only restore of a class-A app as a complete one. Evidence: `audits/evidence-drill-0243-2026-09-16/phase2-f10.txt`. **CLOSED 2026-09-16 — controller v0.244.0, proven live.** A unit restore refuses before anything is touched when the unit cannot return the app's drive-side files, and names the route that can. Fired live on demo-hp: `POST /backup/restore` for paperless-ngx → 302 with „Ez a mentés nem tartalmazza az alkalmazás fájljait, ezért nem állítjuk vissza az adatbázist föléjük — a fájlok így a helyükön maradnak. A fájlok a távoli másolatból állíthatók vissza …", and the app read `running` before AND after, so nothing was stopped and no trash was made unreachable. The database-and-settings-only path exists as a separately worded second step. Red-proof: disabling the guard fails `TestUnitRestore_RefusesWhenTheUnitCannotHoldTheFiles`. **RE-PROVEN 2026-09-16 on a FRESH box, and this time the refusal had somewhere to point:** after five photos were deleted, `POST /backup/restore` was refused with „…a fájlok így a helyükön maradnak. A fájlok a távoli másolatból állíthatók vissza: … „Teljes visszaállítás (fájlok + adatbázis)"", the app read `running` before AND after, and the wastebasket was untouched. The off-site route then returned all five photos — 200 with the exact uploaded sizes and sha256 IDENTICAL to the originals, 5/5, with a negative control. Evidence: `audits/evidence-backup-promise-2026-09-16/phaseE-photos.txt`. | **CLOSED 2026-09-16 — controller v0.244.0 (proven live on demo-hp)** |
| **R-525** | **[P3-LOW] FileBrowser has its own login; putting it behind the dashboard session (traefik forwardAuth or Quantum proxy auth) is a new mechanism nobody has measured.** Filed 2026-09-15 by the P1-fixes task (B.5). R-513 closed the default-password hole with a generated password; a household still has two logins. **What it needs:** a spike on a scratch guest — forwardAuth to the controller session, and what FileBrowser Quantum does with a trusted header. | **READY — rank P3-LOW; owner: CC (spike)** |