docs: controller v0.255.0 — the globe fix on the sign-in-flow pages
gates / gates (push) Successful in 22s

R-579 filed and closed the same day: five shell templates loaded style.css
with no cache-buster, so a browser holding the pre-0.254.0 file rendered the
new globe unstyled; and the globe sat outside the card.

- STATUS.md rewritten for the operator: what was seen, why, the third defect
  found while fixing it (version disclosure on the guest share page, caught by
  TestShareGuest_HeadersTilesNoAdminChrome), and the one decision left —
  raise the fleet floor to 0.255.0, with what happens either way.
- 10-localisation.md §3: the shells' asset tag, and why the two guest pages get
  an opaque tag rather than the version.
- Audit D: the parity diff (91 of 106 fixtures identical, every dashboard page
  among them) and the live endpoint evidence from demo-hp guest 9201.

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 15:12:20 +02:00
parent 3d0eb19a86
commit 4df2cd5174
7 changed files with 154 additions and 37 deletions
@@ -436,6 +436,16 @@ cannot inherit a language a previous visitor picked in the same browser. The rec
measured live before it was right: an anonymous form there sets a cookie that `langFor` then ignores,
and the button appears to do nothing.
**[FACT] Where the globe SITS, and a stale-stylesheet trap that only a screenshot could show
(v0.255.0, R-579).** On the pages outside the dashboard chrome the globe is **inside the card, centred
under the footer**, with the menu opening upward — the shared `.lang-globe-menu` rule, so the two
surfaces cannot drift apart. It was first placed at the corner of the VIEWPORT, which read as a stray
browser control rather than part of the page. And five of those shells requested `style.css` with **no
`?v=`**, so a browser holding a copy from before the globe existed kept serving CSS with no
`.lang-globe` rules and it rendered as a bare, unstyled `<details>`. **Every test passed, because they
all read the markup and the fault was in which CSS file the browser fetched.** `Version` is now set in
`executeTemplateLang`, once, for every shell.
**[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
@@ -0,0 +1,28 @@
# Live validation — controller v0.255.0 on demo-hp guest 9201 (2026-09-18)
**Method: endpoint-level.** No browser on DooPlex. Three GETs, no session needed, nothing written.
The deploy endpoint was not touched (`.claude/rules/live-probes.md`).
```
stylesheet link : <link rel="stylesheet" href="/static/style.css?v=0.255.0">
globe inside card: card@285 footer@1164 globe@1304 -> INSIDE the card, under the footer
GET style.css?v=0.255.0 : 200, 93 551 bytes
the .shell-lang rule it serves:
.shell-lang { display: flex; justify-content: center; margin-top: 1.5rem; }
old absolute rule gone: 0 occurrences
```
Both halves of the defect are answered on the live box: the page now asks for a **versioned** URL, so
a browser holding the pre-0.254.0 file fetches a new one; and the file it fetches carries the **new**
`.shell-lang` rule, with the old viewport-absolute rule gone. The globe's markup sits inside the card
and below the footer.
**What this does NOT prove:** how it LOOKS. `claude-in-chrome` is not available here, so the byte-level
facts above are the whole of what a machine can check. The screenshot that found the bug is the kind
of evidence that settles it — an operator click-through on `https://felhom.enkisfelhom.hu/login`.
**The guest share page** could not be fetched: there is no live share token on this box, so `/s/<x>`
is a 404. Its change is the cache-buster only, and it is an **opaque tag** rather than the version —
pinned by `TestShareGuest_HeadersTilesNoAdminChrome`, which refused the version when it was tried.
**State:** controller 0.255.0, language `hu`, 23 standing containers up, nothing installed or removed.
@@ -0,0 +1,7 @@
stylesheet link : <link rel="stylesheet" href="/static/style.css?v=0.255.0">
globe inside card: card@285 footer@1164 globe@1304 -> INSIDE the card, under the footer
GET style.css?v=0.255.0 : 200 93551 bytes
the .shell-lang rule it serves:
.shell-lang { display: flex; justify-content: center; margin-top: 1.5rem; }
old absolute rule gone: 0
guest share page link : 404 (404 as expected — no live share token)
@@ -0,0 +1,16 @@
#!/bin/bash
# v0.255.0 proof. Read-only: three GETs, no session needed.
IP=172.17.0.2:8080; H="Host: felhom.enkisfelhom.hu"
curl -s -H "$H" "http://$IP/login" -o /tmp/lg5.html
echo -n " stylesheet link : "; grep -o '<link rel="stylesheet"[^>]*>' /tmp/lg5.html
echo -n " globe inside card: "; python3 - <<'PY'
import io
s=io.open('/tmp/lg5.html',encoding='utf-8').read()
c=s.find('class="login-card"'); g=s.find('class="shell-lang"'); f=s.find('class="login-footer"')
print("card@%d footer@%d globe@%d -> %s"%(c,f,g, "INSIDE the card, under the footer" if c<f<g else "WRONG"))
PY
V=$(grep -o 'style.css?v=[^"]*' /tmp/lg5.html | sed 's/.*v=//')
echo -n " GET style.css?v=$V : "; curl -s -o /tmp/css5 -w "%{http_code} %{size_download} bytes\n" -H "$H" "http://$IP/static/style.css?v=$V"
echo " the .shell-lang rule it serves:"; grep -o '\.shell-lang *{[^}]*}' /tmp/css5 | head -2 | sed 's/^/ /'
echo -n " old absolute rule gone: "; grep -c 'shell-lang { position: absolute' /tmp/css5
echo -n " guest share page link : "; curl -s -H "$H" "http://$IP/s/nosuchtoken" -o /tmp/gs.html -w "%{http_code} " ; grep -o '<link rel="stylesheet"[^>]*>' /tmp/gs.html 2>/dev/null || echo "(404 as expected — no live share token)"
@@ -0,0 +1,68 @@
PARITY DIFF — controller v0.255.0, against the fixtures at 2e9d402 (v0.254.0)
A real (LCS) diff; the per-session CSRF token is blanked on both sides. Two things change and
nothing else: the stylesheet gains its cache-buster, and the globe moves INSIDE the card.
[7 fixtures] recovery_locked_can.html, recovery_locked_cannot.html, recovery_locked_confirm.html, recovery_unlocked_apps.html, recovery_unlocked_empty.html, recovery_unlocked_unavailable.html, recovery_unlocked_untagged.html
- <link rel="stylesheet" href="/static/style.css">
+ <link rel="stylesheet" href="/static/style.css?v=0.247.0">
- <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"
- <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="sub
- <li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="sub
- </ul>
-</details></div>
+ <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"
+ <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="sub
+ <li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="sub
+ </ul>
+</details></div>
[4 fixtures] claim_reset_code.html, claim_reset_nocode.html, claim_setup_code.html, claim_setup_nocode.html
- <link rel="stylesheet" href="/static/style.css">
+ <link rel="stylesheet" href="/static/style.css?v=test">
- <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"
- <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
- <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
- </ul>
-</details></div>
+ <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"
+ <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
+ <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
+ </ul>
+</details></div>
[3 fixtures] launcher_share_password.html, launcher_shared_apps.html, launcher_shared_empty.html
- <link rel="stylesheet" href="/static/style.css">
+ <link rel="stylesheet" href="/static/style.css?v=7da11d7a">
[1 fixtures] login.html
- <link rel="stylesheet" href="/static/style.css">
+ <link rel="stylesheet" href="/static/style.css?v=0.250.0">
- <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"
- <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
- <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
- </ul>
-</details></div>
+ <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"
+ <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
+ <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
+ </ul>
+</details></div>
[91 fixtures] IDENTICAL — EVERY dashboard page among them: zero re-captures outside the shells.
DISTINCT CHANGE SHAPES: 4 — the three shells that gained the moved globe AND the buster, and the
two guest share pages, which gained ONLY the buster (they carry no globe) and take an OPAQUE tag
rather than the version: a stranger holding a capability URL is not told which build is running.
+1
View File
@@ -754,6 +754,7 @@ class (an image `VOLUME` at an unmounted path) is still live — `immich-server`
| **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-579** | **[P3-LOW] Five page shells loaded `style.css` with NO cache-buster, so a browser holding an older copy kept being served CSS that did not know about the newest UI.** FOUND 2026-09-18 by the operator's screenshot of the v0.254.0 language globe: it rendered as a bare, unstyled `<details>` — a stray triangle and two plain words outside the card — because `login.html`, `claim.html`, `recovery.html`, `launcher_shared.html` and `launcher_share_password.html` requested `/static/style.css` with no `?v=`, while `layout.html` has used `?v={{.Version}}` since v0.166.0. **`.Version` was also absent from three of those five data maps.** FIXED AND CLOSED in the same session (controller v0.255.0): the parameter on all five, and `Version` set once in `executeTemplateLang` so a new shell cannot miss it; `TestGlobeOnAnonymousShells` now refuses an absent or EMPTY `?v=`. **The general form, which is the part worth keeping: a template that loads a versioned asset WITHOUT its version is invisible to every test that reads markup — the markup is correct and the browser fetches the wrong file.** A gate over "every stylesheet/script link in a template carries `?v=`" would catch the class; not built, because 8 first-boot-wizard templates would fail it and R-554 deletes them. | **CLOSED 2026-09-18 - controller v0.255.0** |
| **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)** |