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
@@ -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.