From 95c10954ae9fdab990c59b78be43fa960bc84a6f Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 18 Sep 2026 14:31:49 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20localisation=20slice=202=20CLOSED=20(co?= =?UTF-8?q?ntroller=20v0.254.0)=20=E2=80=94=20R-577,=20R-578,=20and=20a=20?= =?UTF-8?q?probe=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10-localisation.md §10.3: the saved notes follow the box language at write time, with the one-night consequence stated rather than hidden; the globe, and the table of WHO reads which page and where its globe posts — getting that wrong makes the button do nothing, which it did on /recovery until the live probe found it. Decision 6 superseded a second time; decision 8 (a claim carries the visitor's language) recorded. Decision 5 of §11's anonymous-surface line: changing what a VISITOR reads is within what an anonymous request may do; changing anything the household owns is not, and POST /lang can do only the first. R-578 — the deadlock, and why it is a row rather than a fixed bug: UpdateOffboxStatus holds the settings write lock while running its callback, boxLang() wants the read lock, sync.RWMutex is not reentrant. On a real box an off-site run would have hung FOREVER holding that lock. The symptom was a test suite going from 8 minutes to a 25-minute timeout. Fixed and guarded, but the guard covers one package and three helper names; the class needs a gate. R-577 — a guest share visitor still has no way to pick a language, and the household's setting is the wrong default for a stranger. Deliberately left, pinned by a test, and the operator's to decide because it is a promise the share feature makes. .claude/rules/live-probes.md, unconditional: never send a deploy request for an app that is not installed, not even expecting a refusal — the endpoint accepts first and validates later. Two sessions made that mistake in two days, the second WITH a prompt line forbidding it. A prompt is read once; a rule file is loaded every session. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- .claude/rules/live-probes.md | 48 ++++++++++++ STATUS.md | 73 ++++++++---------- documentation/architecture/10-localisation.md | 69 ++++++++++++++++- .../i18n-slice2-2026-09-18/C/live/README.md | 74 +++++++++++++++++++ .../C/live/after-0.254.0.txt | 17 +++++ .../C/live/before-0.253.0.txt | 15 ++++ .../i18n-slice2-2026-09-18/C/live/probeC.sh | 31 ++++++++ .../C/parity-exception-diff.txt | 46 ++++++++++++ .../i18n-slice2-2026-09-18/redproofs.txt | 40 ++++++++++ documentation/backlog/OPEN-ITEMS.md | 4 +- 10 files changed, 374 insertions(+), 43 deletions(-) create mode 100644 .claude/rules/live-probes.md create mode 100644 documentation/audits/i18n-slice2-2026-09-18/C/live/README.md create mode 100644 documentation/audits/i18n-slice2-2026-09-18/C/live/after-0.254.0.txt create mode 100644 documentation/audits/i18n-slice2-2026-09-18/C/live/before-0.253.0.txt create mode 100644 documentation/audits/i18n-slice2-2026-09-18/C/live/probeC.sh create mode 100644 documentation/audits/i18n-slice2-2026-09-18/C/parity-exception-diff.txt diff --git a/.claude/rules/live-probes.md b/.claude/rules/live-probes.md new file mode 100644 index 00000000..42b848cc --- /dev/null +++ b/.claude/rules/live-probes.md @@ -0,0 +1,48 @@ +--- +unconditional: true +# Deliberately always-loading. This rule exists because TWO sessions made the same destructive +# mistake on a live box, the second one WITH a prompt line telling it not to. A path-scoped rule +# would load when you edit the handler; the mistake is made when you probe it, from anywhere. +--- + +# Live probes — what a probe may touch on a real box + +> One rule, earned twice in two days by two different sessions, both of which had a prompt line +> telling them not to. A prompt is read once; a rule file is loaded every session, which is the whole +> reason this file exists. + +## Never send a deploy request for an app that is not installed — not even expecting a refusal + +**`POST /api/stacks//deploy` ACCEPTS FIRST AND VALIDATES LATER.** It answers `202 Telepítés +elindítva` and runs the validation inside a goroutine, so a probe that expects a refusal gets a 202 — +and if the app happens to need no required field, it is now installed on the box. + +- 2026-09-17: a session probing the required-field refusal picked an app that needed no field. It + installed. Recorded in `STATUS.md`. +- 2026-09-18: a session that had read that record, and had a prompt line forbidding it, did the same + thing with `vaultwarden`. Recorded in + `documentation/audits/i18n-slice2-2026-09-18/B/live/README.md`. + +The lesson that sticks is narrower than "pick a different app": **the deploy endpoint cannot be used +to probe a refusal at all.** + +**Instead, use a request that is refused BEFORE anything is created:** + +| you want to see | use | +|---|---| +| a deploy-path refusal | an app that is ALREADY installed → `409 already deployed` | +| a not-found path | a name that exists nowhere → `404` | +| a validator's sentence | `POST /sharing/shares` with a bad name, `POST /api/disks/assign` with a bad mount point — both refuse before they write | +| a protected-resource refusal | `POST /api/stacks/felhom-controller/remove` → `403` | + +## If a probe does create something, remove it through the product + +Not by hand, and not by `docker rm`: stop it, then `POST /api/stacks//remove` with +`remove_hdd_data` and `remove_backups`, and then **verify** — no container, no `/opt/felhom/stacks/`, +no volume. Say in the report that it happened. A tidy-up nobody is told about is how the next session +learns nothing. + +## The general shape + +**Before sending anything to a live box, ask which side of the write the refusal happens on.** A +refusal that comes after the write is not a refusal you can probe — it is a change you are making. diff --git a/STATUS.md b/STATUS.md index aff9354b..db077d92 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,55 +1,46 @@ # STATUS — what works, what's broken, what's next -**Updated 2026-09-18 (evening) — the sentences the program writes now follow the language, error messages included.** +**Updated 2026-09-18 (night) — the language work is finished, and the switch is now a globe.** -> **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. +> **Ready for a volunteer: yes.** A Hungarian household sees what it saw yesterday, apart from one +> deliberate change: the two small "Magyar / English" links at the bottom of the menu are now a globe. -**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. +**Decisions I took.** One you were asked for, taken on its own stated default: **if someone switches +the claim page to English and then claims the box, the box becomes English.** They chose it, and the +first screen they see should be in it. You can reverse it; nothing else depends on it. -**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.** +**What I did today.** Three releases, and the language job is done. -**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. +1. The sentences the program writes into the pages. +2. The error messages — about 180 of them, written deep inside the program. +3. Today's last piece: **the notes the box saves overnight**, and **the globe**. -**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. +**The globe.** Two text links at the bottom of the menu asked you to recognise two words as links, +and they wrapped. Now there is one globe — the symbol everyone already reads as "language". Click it +and a small list opens: Magyar, English, with the current one ticked. **The sign-in page and the claim +page have it too**, which matters: someone who cannot read Hungarian could not previously find their +way out of Hungarian before signing in. Their choice is kept in their own browser only — it never +changes what the household has chosen, and a signed-in household never picks up a stranger's choice. -**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. +**One thing to expect, and it is the choice you were offered.** The notes saved overnight are written +in the box's language at the moment they are written. If you switch language, last night's note stays +in the old language until the next night rewrites it. -**What is left.** The notes saved to disk overnight — one more release. Plus a handful of small gaps, -each with a number. +**What broke, and what I did about it.** **I introduced a freeze.** The code that writes the overnight +note asked the box "what language are you?" at a moment when that question could not be answered — +and it would have hung there **forever, holding a lock the rest of the box needs**. On a real machine +an overnight cloud backup would have stopped and taken everything else with it. The test run caught it +by taking 25 minutes instead of 8. It is fixed, and there is now a check that names the exact line in +a second instead of hanging. I also found and fixed a second one before it shipped: the recovery +screen's globe would have looked like it worked and done nothing. -**Rows.** One closed, five opened across the day. The register went from **264** to **269** open rows. +**Rows.** One closed (the whole language job), two opened. The register went from **269** to **271**. -**The floor is raised, as you asked.** The fleet minimum went from 0.250.0 to **0.253.0**. The N100 -demo box took it by itself in about twenty seconds and is healthy, with its other four apps still -running; the HP demo box already had it. **The two boxes that are switched off did not get it** — Peti's -has been off for 65 days on a much older version, and Tester 1 for a day. They will take it on their own -whenever they come back, which is how a floor always works, and neither has been tried on this version. - -**Needs you.** Nothing. If you do nothing: the two sleeping boxes update themselves when they wake. +**Needs you — one decision.** **Raise the floor to 0.254.0?** +- **If you do:** every box gets the globe on its next check-in, and the saved notes start following + the language. The two sleeping boxes take it whenever they wake. +- **If you do nothing:** households keep the two text links. Nothing breaks, and nothing is at risk. +- I would raise it — this release changes what every household sees, and seeing it is the point. --- diff --git a/documentation/architecture/10-localisation.md b/documentation/architecture/10-localisation.md index a25be854..970d8aed 100644 --- a/documentation/architecture/10-localisation.md +++ b/documentation/architecture/10-localisation.md @@ -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; 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** | +| 2 (done, v0.252.0–v0.254.0) | R-557 | Go-side customer strings; flash-in-URL → keys; country names; alert texts; the 179 error messages; the saved notes; the globe | a page's server messages follow the language | **CLOSED 2026-09-18** | | 3 | R-558 | hub: per-customer language at creation; `configgen` renders it; `customerMessages` (39), the 5 lifecycle mails and the self-bind page in English; dispatcher reads the reported language | a household's e-mails arrive in its language | 6–8 h, one hub + one controller release | | 4 | R-559 | console banner (34 lines, console-font limits) and the download page | an English household meets English from the first boot screen (in scope — ruling 1b) | 4–6 h + an ISO train | | 5 | R-560 | catalog: §7 format, controller reads it, 835 strings / ~5 000 words, catalog copy gates per language | an app card, its settings and its first steps in English | 12–16 h | @@ -394,6 +394,68 @@ with both controls. string with no error to carry a key and no language where it is built, so it renders Hungarian on an English page. The copy is in the bundle; only the render is fixed. Named in the code, filed as a row. +### 10.3 Slice 2 release C — the saved notes, and a globe (v0.254.0). SLICE 2 CLOSED. + +**[DESIGN] A note a background run SAVES is written in the BOX's language at write time** (operator +ruling, §16 option 1). ~70 producers. **The consequence, recorded because it is the cost of the +choice:** a household that switches language sees the previous run's note in the old language until +the next run rewrites it — usually the next night. The alternative (store a code, render live) needs a +dozen new persisted fields and a legacy path for each: the R-570 shape a dozen times over. +`EndRestoreOp` now receives no Hungarian literal from anywhere. + +**[FACT] A deadlock, introduced and caught by the suite hanging (R-578).** `UpdateOffboxStatus` holds +the settings WRITE lock while running its callback; `boxLang()` reads the language through the READ +lock; `sync.RWMutex` is not reentrant. A note rendered inside that callback deadlocks **holding the +settings lock**, which wedges everything else on the box that touches `settings.json`. The only +symptom was `go test` going from 8 minutes to a 25-minute timeout. **The general rule this establishes: +a helper that takes a lock must never be called from inside a callback that holds one** — and a hang +is the worst symptom to diagnose, which is why R-578 asks for a gate rather than one test in one package. + +**[DESIGN] Decision 6, superseded a second time: the switch is a GLOBE, and a visitor's language is +their own.** One answerable sentence: *how does someone who cannot read Hungarian find the way out of +Hungarian?* Options: (1) keep two text links („Magyar"/„English") in the sidebar footer — cost: they +wrap at the sidebar's width, and finding them means recognising two words as links; (2) one globe, the +symbol every web user already reads as "language". **Chosen (2)**, `
`/`` so the menu +needs no script and a screen reader announces it. Language names inside are shown in their own +language and are never translated. Drawn inline rather than in the icon sprite, because the sprite +lives only in `layout.html` and the pages outside the dashboard chrome have their own shell. + +**[DESIGN] Who the page is FOR decides where its globe posts, and getting that wrong makes the button +do nothing.** + +| page | reader | globe posts to | their choice lives in | +|---|---|---|---| +| every dashboard page | the household, signed in | `/settings/language` (session CSRF) | `settings.json` | +| `/recovery` — an AUTHENTICATED route | the household, signed in | `/settings/language` | `settings.json` | +| `/login`, `/claim` | a visitor, no session | `/lang` (no CSRF) | the `felhom_lang` cookie, their browser | +| the two guest share pages, the catch-all | a stranger / nobody | **no globe** | — (R-577, the operator's) | + +`langFor`'s order is fixed: `?lang=` → **the household's setting when a session exists** → the cookie +when there is none → the setting → `hu`. **A signed-in household never reads the cookie**, so they +cannot inherit a language a previous visitor picked in the same browser. The recovery row above was +measured live before it was right: an anonymous form there sets a cookie that `langFor` then ignores, +and the button appears to do nothing. + +**[DESIGN] `POST /lang` is CSRF-exempt, for a reason narrow enough to check.** The only achievable +effect of a forged request is to change the language of the page the victim's own browser shows them. +It writes one display-only cookie, reads nothing, touches no setting, and `safeBackPath` refuses a +protocol-relative `//host` as well as an absolute URL — *"starts with `/`"* alone is not the test, +because a browser reads `//evil.example` as another origin. **If that handler ever gains a second +effect it needs CSRF that day.** That is the anonymous surface `04-control-plane-authorization.md` +governs: changing what a visitor reads is within it, changing anything the household owns is not. + +**[DESIGN] §16, the operator's default, taken: a successful CLAIM carries the visitor's language into +the household's setting.** Someone who switched the claim page to English and then claimed the box +chose English. Only on success, and only there — the one moment an anonymous visitor becomes the +household. + +**[FACT] The parity exceptions, measured rather than asserted.** 106 fixtures, a real (LCS) diff +against the fixtures as they stood at v0.253.0: **3 change shapes** (the footer; the recovery globe; +the login/claim globe) and **5 byte-identical**, which are exactly the pages that must not change. +`audits/i18n-slice2-2026-09-18/C/parity-exception-diff.txt`. **A second measurement error worth +keeping: the first attempt compared LINE BY INDEX, and an insertion shifts every line below it — it +reported 60 520 changed lines and measured nothing. A line-index compare is not a diff.** + --- ## 11. Operator decisions @@ -414,6 +476,11 @@ These are rulings, not proposals. Anything specced against a different assumptio 5. **Mechanism (b2)** — §2.1. 6. ~~**Switch hidden while only three pages are English** — §3.~~ **Superseded 2026-09-17** by slice 1 release C (v0.250.0): every template converted, switch shown to every household (§3). + **Superseded again 2026-09-18** by slice 2 release C (v0.254.0): the switch is a GLOBE, it is on the + sign-in and claim pages too, and a visitor's choice lives in their own browser (§10.3). + +8. **A successful claim carries the visitor's language into the household's setting** (§16 default, + taken 2026-09-18). Only on success; every other anonymous request leaves `settings.json` alone. ### 2026-09-17 (evening) — the two open questions, ruled diff --git a/documentation/audits/i18n-slice2-2026-09-18/C/live/README.md b/documentation/audits/i18n-slice2-2026-09-18/C/live/README.md new file mode 100644 index 00000000..e59734cc --- /dev/null +++ b/documentation/audits/i18n-slice2-2026-09-18/C/live/README.md @@ -0,0 +1,74 @@ +# Live validation — controller v0.254.0 on demo-hp guest 9201 (2026-09-18, release C) + +**Method: endpoint-level, stated as such.** No browser on DooPlex. Nothing was installed, created, +formatted or deleted, and **the deploy endpoint was not touched at all** — the new rule +(`.claude/rules/live-probes.md`) written after yesterday's mistake. The password was passed as a file +and deleted from host and guest afterwards. + +## 1. A visitor's language is their own (no session) — before and after + +| probe | 0.253.0 | 0.254.0 | +|---|---|---| +| globe on `/login` | **0** | **1** | +| `POST /lang lang=en back=/login` | 302 (no such route → the login redirect) | **303, `felhom_lang=en` set** | +| `/login` with that cookie | `` | **``**, and "Forgot password" present | +| **`settings.json` `language` after it** | `"hu"` | **`"hu"` — unchanged.** An anonymous request cannot write what the household owns | +| `POST /lang lang=xx` | 302 | **400**, no cookie | +| `POST /lang back=//evil.example/x` | the login redirect | **`/`** — a protocol-relative URL is another origin | +| globe on `/claim` | 0 | **1** | + +## 2. A signed-in household reads their own setting, never the cookie + +`/launcher` with a session **and** the `felhom_lang=en` cookie → **``**. The cookie is +not read once there is a session; that is the row that keeps a household from inheriting a language a +previous visitor picked in the same browser. + +## 3. The dashboard globe, end to end + +`POST /settings/language lang=en` (session CSRF) → 302 → `settings.json` `"language": "en"` → +`/launcher`, `/backups`, `/backups/remote` all `` → switched back → `"hu"`. + +The footer renders in order — version, globe, sign-out — and the old two-text-link switch is gone +(`lang-switch-btn` count 1 → **0**): + +``` + + +[5 fixtures] IDENTICAL — not one byte moved: catchall_app.html, catchall_unknown.html, launcher_share_password.html, launcher_shared_apps.html, launcher_shared_empty.html + +DISTINCT CHANGE SHAPES: 3. +The release declares TWO blocks — the dashboard footer, and the globe on the pages outside the +dashboard chrome. The second has two forms, because WHO reads the page decides where its globe +posts: /recovery is an AUTHENTICATED route so its reader is the household and writes their +setting; /login and /claim are met with no session and write the visitor's own cookie. The five +untouched fixtures are exactly the pages that must not change: both guest share pages and the +catch-all (R-577). diff --git a/documentation/audits/i18n-slice2-2026-09-18/redproofs.txt b/documentation/audits/i18n-slice2-2026-09-18/redproofs.txt index cc8ed579..950b8915 100644 --- a/documentation/audits/i18n-slice2-2026-09-18/redproofs.txt +++ b/documentation/audits/i18n-slice2-2026-09-18/redproofs.txt @@ -52,3 +52,43 @@ A. The bulk converter SILENTLY DROPPED the continuation of a multi-line concaten 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. + +RELEASE C (v0.254.0), 2026-09-18 — saved notes, and a globe for the language switch + +15. langFor drops the `!s.hasSession(r)` guard (a signed-in household inherits a visitor's cookie) + CONVICTS: TestLangForPrecedence/SESSION_→_the_household's_setting,_cookie_NOT_read + — langFor = "en", want "hu" +16. safeBackPath allows a protocol-relative URL (an open redirect off the box) + CONVICTS: TestLangCookieHandler/back_may_only_be_a_same-origin_path + — back="//evil.example/x" → Location "//evil.example/x", want "/" +17. the R-570 producer is translated (the one saved sentence release C may not touch) + CONVICTS: TestR570SentenceStaysHungarian, BOTH arms — the literal is gone AND a bundle key + appeared, each named separately +18. one byte changed inside the FOOTER parity-exception block (lang-globe-menu → -menu2) + CONVICTS: TestI18nParity on all 101 re-captured fixtures +19. one byte changed inside the SHELL parity-exception block (shell-lang → shell-lang2) + CONVICTS: TestI18nParity naming login at line 11 + +TWO LIVE CATCHES IN RELEASE C — neither planted: +C. The parity HARNESS rendered the three visitor shells through addLanguageData, the DASHBOARD path. + 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, and independently by + TestI18nDirectRenderPagesFollowLanguage, which renders through the REAL executeTemplateLang. + The harness now branches on i18nDirectTemplates. +D. The first "is the change only the two declared blocks?" measurement compared LINE BY INDEX, and an + insertion shifts every line below it — so it reported 60 520 changed lines and measured nothing. + Redone as a real (LCS) diff: exactly TWO change shapes across 106 fixtures, and 5 fixtures + byte-identical (the two guest share pages and the catch-all — the three that must not change). + +RELEASE C, second round — after the recovery-page finding + +E. LIVE CATCH, not planted: /recovery is in the AUTHENTICATED route table, so its reader is the + HOUSEHOLD — but release C's first draft gave it the anonymous globe, which sets a cookie langFor + deliberately ignores once there is a session. The button would have done NOTHING. Found by the + live probe reporting `/recovery globes: 0` (302 to /login) and then reading the route table. + Fixed: executeTemplateLang branches on hasSession — household form with CSRF, or visitor form + without. Two tests and the parity harness now carry the same branch. +F. The per-session CSRF token cannot be a fixture value. Blanked on both sides of every parity + comparison, exactly as relative ages already were; what stays pinned is that the field is THERE + and WHICH form it sits in — which is the half that says whether the globe writes the household's + setting or the visitor's cookie. diff --git a/documentation/backlog/OPEN-ITEMS.md b/documentation/backlog/OPEN-ITEMS.md index df8aeeb2..87ad7cac 100644 --- a/documentation/backlog/OPEN-ITEMS.md +++ b/documentation/backlog/OPEN-ITEMS.md @@ -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=`) 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-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=`) 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 SHIPPED 2026-09-18 (controller v0.254.0) -- SLICE 2 CLOSED.** ~70 saved-note producers write in the BOX language at write time (s16 option 1; a household that switches sees the previous run's note in the old language until the next run). `EndRestoreOp` takes no Hungarian literal from anywhere. The switch became a GLOBE on the dashboard and on the pages outside the dashboard chrome; a visitor with no session gets a display-only `felhom_lang` cookie that `langFor` reads ONLY when there is no session, and a successful CLAIM carries it into the setting (s16). Two parity blocks, measured with a real diff: 3 change shapes across 106 fixtures (footer; the recovery globe posting to the household switch; the login/claim globe posting to /lang), 5 byte-identical. **A DEADLOCK was introduced and caught by the suite hanging (R-578).** Gaps filed: R-572..R-578. | **CLOSED 2026-09-18 - controller v0.252.0 + v0.253.0 + v0.254.0** | | **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)** | @@ -752,6 +752,8 @@ class (an image `VOLUME` at an unmounted path) is still live — `immich-server` | **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-577** | **[P3-LOW] A guest SHARE visitor has no way to pick a language, and the household's setting is the wrong default for them.** FOUND 2026-09-18 by localisation slice 2 release C (R-557, controller v0.254.0): every other page a person can reach now carries a language globe — the dashboard (the household's setting), and the sign-in and claim pages (the visitor's own cookie). The two guest share pages (`launcher_shared`, `launcher_share_password`) deliberately do NOT, and `TestGuestSharePagesHaveNoGlobe` pins that so it stays a decision rather than an oversight. **Why it is the operator's and not CC's:** a share visitor is a stranger the household sent a link to, and what language they are shown is a promise the SHARE FEATURE makes, not an implementation detail. The `felhom_lang` cookie already built would fit them exactly (display-only, their own browser, never the household's setting). **Fix shape, if the operator says yes:** add `{{template "lang_globe" .}}` to both shells with the anonymous form, and one render case per page per language. | **READY - rank P3-LOW; owner: operator (the decision), CC (the change)** | +| **R-578** | **[P3-LOW] A helper that takes the settings lock must never be called from inside a settings callback — there is no gate, only one test in one package.** FOUND 2026-09-18 the hard way, by localisation slice 2 release C introducing exactly that: `UpdateOffboxStatus` holds the settings WRITE lock while it runs its callback, `boxLang()` reads the language through the READ lock, and `sync.RWMutex` is not reentrant — so the off-site run's final status write DEADLOCKED, **holding the settings lock**, which would wedge everything else on that box that touches `settings.json`. The only symptom was `go test ./internal/backup/` going from 8 minutes to a 25-minute timeout. Fixed by hoisting the language resolution; `TestNoteHelpersAreNotCalledUnderTheSettingsLock` (internal/backup) now names the file and line in a second. **What is still open:** that test covers `internal/backup` only, and it knows only the `note`/`noteErr`/`boxLang` helpers. Any other settings-reading helper, in any other package, can make the same mistake with nothing to catch it but a hang. **Fix shape:** promote it to a gate over every package, keyed on "a call to a method that reads settings, inside a literal passed to a `settings.Update*` function"; or give `Settings` a re-entrant read path and remove the class. | **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)** |