docs: localisation slice 2 CLOSED (controller v0.254.0) — R-577, R-578, and a probe rule
gates / gates (push) Successful in 23s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-18 14:31:49 +02:00
parent ed0b0f5c92
commit 95c10954ae
10 changed files with 374 additions and 43 deletions
+48
View File
@@ -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/<name>/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/<name>/remove` with
`remove_hdd_data` and `remove_backups`, and then **verify** — no container, no `/opt/felhom/stacks/<name>`,
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.
+32 -41
View File
@@ -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.
---
+68 -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; 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)**, `<details>`/`<summary>` 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
@@ -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 | `<html lang="hu">` | **`<html lang="en">`**, 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 → **`<html lang="hu">`**. 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 `<html lang="en">` → 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**):
```
<div class="sidebar-footer">
<span class="version">0.254.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" …globe…/></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language">…<button … class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language">…<button … class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
```
## 4. The saved note, and the §16 consequence, seen live
With the box switched to English, the stored tier-2 note stayed as it was written:
`"last_warning": "A belső SSD-n csak a konfiguráció, adatbázis és a köte…"`. **That is the operator's
choice working, not a bug:** a saved note is written in the box's language at write time and shown
verbatim afterwards, so a household that switches sees the previous run's note in the old language
until the next run rewrites it.
## 5. What could NOT be proven live, and why
**The `/recovery` globe.** `recoveryPageHandler` redirects to `/backups/remote` unless
`recoveryOffer()` is true — the page exists only while a box is in a lost-machine situation, and
demo-hp is not. It 302s with or without a session. So its globe is proven by the render tests
(`TestGlobeOnAnonymousShells`, `TestI18nDirectRenderPagesFollowLanguage`) and by its 7 parity
fixtures, **not live**. Said plainly rather than left to be assumed from the other rows.
That page is also where release C's own defect was: it is an AUTHENTICATED route, so its globe must
write the household's SETTING; the first draft gave it the anonymous form, which sets a cookie
`langFor` ignores once there is a session — the button would have done nothing. Found by this probe
reporting no globe on `/recovery` and then reading the route table.
**The `<details>` menu opening in a browser.** `claude-in-chrome` is not available on DooPlex. The
markup is asserted; whether it looks and behaves right on screen is a manual click-through.
## 6. State left behind
Controller **0.254.0**; saved language **`hu`**; the visitor cookie was set only inside the probe's
own curl jar and is gone with it. 23 standing containers up, unchanged. Nothing installed, nothing
removed, no drive touched, no floor raised, no golden baked. **Provisioned nothing.**
@@ -0,0 +1,17 @@
##### A — NO SESSION: the visitor's own language
settings.json language before: "language": "hu"
/login with no cookie -> html lang=<html lang="hu"
globe present on /login -> 1
POST /lang lang=en back=/login -> 303 felhom_lang en
/login WITH the cookie -> html lang=<html lang="en"
and an English phrase -> 1
settings.json language after: "language": "hu"
POST /lang lang=xx -> 400
POST /lang back=//evil -> /
/claim and /recovery globes: /claim=1 /recovery=0
##### B — WITH A SESSION: the household's own setting, cookie NOT read
/launcher with session + the en cookie -> html lang=<html lang="hu"
the footer: version, globe, sign-out in order ->
the OLD text links are gone -> lang-switch-btn count = 0
##### C — the saved notes, as they stand on this box
"last_warning": "A belső SSD-n csak a konfiguráció, adatbázis és a kötelező adatok férnek
@@ -0,0 +1,15 @@
##### A — NO SESSION: the visitor's own language
settings.json language before: (unreadable)
/login with no cookie -> html lang=<html lang="hu"
globe present on /login -> 0
POST /lang lang=en back=/login -> 302 /login WITH the cookie -> html lang=<html lang="hu"
and an English phrase -> 0
settings.json language after: (unreadable)
POST /lang lang=xx -> 302
POST /lang back=//evil -> /login?next=%2Flang
/claim and /recovery globes: /claim=0 /recovery=0
##### B — WITH A SESSION: the household's own setting, cookie NOT read
/launcher with session + the en cookie -> html lang=<html lang="hu"
the footer: version, globe, sign-out in order ->
the OLD text links are gone -> lang-switch-btn count = 1
##### C — the saved notes, as they stand on this box
@@ -0,0 +1,31 @@
#!/bin/bash
# Release C probe. Every request below either reads a page or sets a display cookie. Nothing is
# installed, created, formatted or deleted; no deploy endpoint is touched at all (.claude/rules/live-probes.md).
IP=172.17.0.2:8080
H="Host: felhom.enkisfelhom.hu"
PW=$(cat /tmp/.felhompw); J=/tmp/pj.txt; rm -f $J /tmp/vj.txt
echo "##### A — NO SESSION: the visitor's own language"
echo -n " settings.json language before: "; grep -o '"language": *"[a-z]*"' /var/lib/felhom/docker/volumes/felhom-controller-data/_data/data/settings.json 2>/dev/null || echo "(no language key yet = hu)"
echo -n " /login with no cookie -> html lang="; curl -s -H "$H" "http://$IP/login" | grep -o '<html lang="[a-z]*"' | head -1
echo -n " globe present on /login -> "; curl -s -H "$H" "http://$IP/login" | grep -c 'class="shell-lang"'
echo -n " POST /lang lang=en back=/login -> "; curl -s -c /tmp/vj.txt -H "$H" -o /dev/null -w "%{http_code} " -X POST "http://$IP/lang" --data-urlencode "lang=en" --data-urlencode "back=/login"; grep -o 'felhom_lang[[:space:]]*[a-z]*' /tmp/vj.txt | head -1
echo -n " /login WITH the cookie -> html lang="; curl -s -b /tmp/vj.txt -H "$H" "http://$IP/login" | grep -o '<html lang="[a-z]*"' | head -1
echo -n " and an English phrase -> "; curl -s -b /tmp/vj.txt -H "$H" "http://$IP/login" | grep -c 'Forgot password'
echo -n " settings.json language after: "; grep -o '"language": *"[a-z]*"' /var/lib/felhom/docker/volumes/felhom-controller-data/_data/data/settings.json 2>/dev/null || echo "(no language key yet = hu)"
echo -n " POST /lang lang=xx -> "; curl -s -H "$H" -o /dev/null -w "%{http_code}\n" -X POST "http://$IP/lang" --data-urlencode "lang=xx" --data-urlencode "back=/login"
echo -n " POST /lang back=//evil -> "; curl -s -H "$H" -o /dev/null -w "%{redirect_url}\n" -X POST "http://$IP/lang" --data-urlencode "lang=en" --data-urlencode "back=//evil.example/x" 2>/dev/null | sed 's|http://[^/]*||'
echo -n " /claim and /recovery globes: "; for P in /claim /recovery; do echo -n "$P=$(curl -s -H "$H" "http://$IP$P" | grep -c 'class=\"shell-lang\"') "; done; echo
echo "##### B — WITH A SESSION: the household's own setting, cookie NOT read"
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; }
LANGC=$(grep -o 'felhom_lang[[:space:]]*[a-z]*' /tmp/vj.txt | head -1 | awk '{print $2}')
echo -n " /launcher with session + the en cookie -> html lang="
curl -s -H "$H" -H "Cookie: $SESS; felhom_lang=$LANGC" "http://$IP/launcher" | grep -o '<html lang="[a-z]*"' | head -1
echo -n " the footer: version, globe, sign-out in order -> "
curl -s -H "$H" -H "Cookie: $SESS" "http://$IP/launcher" | grep -o 'class="sidebar-footer".*logout-link' | grep -o 'class="version"\|class="lang-globe"\|class="logout-link"' | tr '\n' ' '; echo
echo -n " the OLD text links are gone -> lang-switch-btn count = "
curl -s -H "$H" -H "Cookie: $SESS" "http://$IP/launcher" | grep -c 'lang-switch-btn'
echo "##### C — the saved notes, as they stand on this box"
grep -o '"last_warning": *"[^"]\{0,80\}' /var/lib/felhom/docker/volumes/felhom-controller-data/_data/data/settings.json 2>/dev/null | head -2; grep -o '"last_error": *"[^"]\{0,80\}' /var/lib/felhom/docker/volumes/felhom-controller-data/_data/data/settings.json 2>/dev/null | head -2
@@ -0,0 +1,46 @@
PARITY EXCEPTIONS — controller v0.254.0 (R-557 slice 2 release C)
Every Hungarian fixture rendered NOW, against the fixture as it stood at eb6aa58 (v0.253.0) —
itself captured from the UNCONVERTED templates and never regenerated to make a conversion pass.
A real diff (LCS), not a line-index compare: the globe ADDS lines, and a line-index compare calls
every line below an insertion changed, which measures nothing (redproofs.txt, catch D).
The per-session CSRF token is blanked on both sides — it is random per session and can never be
a fixture value; what is still pinned is that the field is THERE and WHICH form it sits in.
[89 fixtures] e.g. app_export.html, app_import_bundles.html, app_import_empty.html
- <span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-sw
+ <span class="version">0.247.0</span><details class="lang-globe">
+ <summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx=
+ <ul class="lang-globe-menu">
+ <li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe
+ <li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe
+ </ul>
+</details>
[7 fixtures] e.g. recovery_locked_can.html, recovery_locked_cannot.html, recovery_locked_confirm.html
+ <div class="shell-lang"><details class="lang-globe">
+ <summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx=
+ <ul class="lang-globe-menu">
+ <li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe
+ <li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe
+ </ul>
+</details></div>
[5 fixtures] e.g. claim_reset_code.html, claim_reset_nocode.html, claim_setup_code.html
+ <div class="shell-lang"><details class="lang-globe">
+ <summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx=
+ <ul class="lang-globe-menu">
+ <li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></l
+ <li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
+ </ul>
+</details></div>
[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).
@@ -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.
+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 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=<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 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)** |