hub v0.119.0 — English households get English words for their codes (R-597); R-596/R-598 closed
gates / gates (push) Successful in 24s

The setup code and the owner passphrase now follow the household's language,
one word longer in English so the entropy never drops (setup 3 hu / 4 en,
passphrase 5 hu / 6 en). List and count are chosen together so a caller cannot
pair an English list with a Hungarian count. Hungarian is byte-unchanged.

Three claims in the row were wrong and are recorded as such:
  - the RECOVERY CODE is minted by felhom-agent from the EFF list and has
    always been English; the hub does not own it and no row was added.
  - no claim mail states a word count; the only count wording was the bind
    page's passphrase hint, whose English half is now count-free.
  - the proposed phone-safe filter removes 68% of the list (5270 of 7772
    words) and was measured, then declined, with the reason in source.

Also: guide_quote_gate binds the English volunteer guide's three quoted
messages to the controller's English bundle — nothing did, so the guide would
have gone on quoting Hungarian after the fix. Seven decoys, all convicting,
including the name-for-fact one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-21 07:56:56 +02:00
parent a499327236
commit e02bc03819
18 changed files with 8859 additions and 50 deletions
@@ -356,3 +356,50 @@ token is real. Pinned by `TestBindExpiredIsAlwaysDefaultLanguage`.
56 goldens captured from v0.117.0 before any string moved, in
`hub/internal/notify/testdata/mail_goldens/hu/`, with the English set beside them. The claim is a
diff, not a reading. A golden is never regenerated to make a change pass.
### 15.6 The CODES a household types, per language [hub v0.119.0, R-597]
A mail in English that carries three Hungarian words with accents is not an English mail. The 2026-09-20
drill received exactly that, and could paste the code but not read it to anyone.
**Two secrets this repo mints follow the household's language**, list and word count chosen together
by `configgen.RandomPassphraseFor(lang, use)`:
| Secret | hu | en | who calls it |
|---|---|---|---|
| Setup / reset code | 3 words, 44.6 bits | **4 words, 51.7 bits** | `claim.Engine` via `CustomerLanguage` |
| Owner passphrase | 5 words, 74.3 bits | **6 words, 77.5 bits** | the three `configs.go` sites |
**The rule is per use: English ≥ Hungarian, in bits.** The English list (EFF large, 7772 words after
filtering) carries 12.92 bits/word against the Hungarian list's 14.85, so English takes one more
word. The test computes both sides from the embedded lists rather than comparing a constant with
itself, so shrinking a list or lowering a count fails.
**A third secret is NOT minted here and must not be added.** The customer **recovery code** is minted
by `felhom-agent` (`internal/escrow`) from the same EFF list, ten words, ≈129 bits — it has been
English since it was written. R-597's row listed it here; that was wrong. Writing a row for it in
this table would create a second definition of a secret the hub does not own, which is the drift
`backupTargetAbsentText` already demonstrates across two repos.
**Which language, and when.** The setup code follows `Store.CustomerLanguage` — the same order the
mail carrying it follows (reported → created-with → `hu`), so a code and its e-mail can never
disagree. Two consequences, both deliberate:
- **A household created as `hu` whose box later reports `en`** keeps every code already issued
exactly as it was; the **next** code issued is English. A code is a hash on the box, never
retranslated.
- **At customer creation there is no stored customer yet**, so the Owner passphrase generated on that
form reads the language from **the form field**, not from `CustomerLanguage` — which would answer
Hungarian for every English household created. The store's `createdLanguage` applies the same
default to an absent value, so the two agree.
**The box needs no change for any of this.** It stores and compares a bcrypt hash of whatever was
minted and has no notion of which list the words came from — pinned by the controller's
`TestClaimAcceptsAnEnglishWordCode`. The TTL, the single-use generation and the five-attempt lockout
are untouched and language-blind.
**Count wording.** No claim mail states a word count; they say `Setup code: %s`. The only place a
count appeared was the bind page's passphrase hint, and its English half is now count-free ("The word
phrase you received from your operator during setup") — because "five words" stops being true for an
English household, and was already wrong for one whose passphrase predates this release. The
Hungarian „öt szó" is correct and unchanged.
+54 -3
View File
@@ -749,9 +749,9 @@ everything else held.
| what | why | row |
|---|---|---|
| the **claim page's messages** („Hibás vagy lejárt kód", „A jelszónak legalább 12 karakter…", the lockout) | composed sentences passed into page data, the R-573 shape one screen earlier | **R-596 (P1)** |
| the **setup code itself** (and the recovery code and owner passphrase) | one Hungarian wordlist, not chosen per language | **R-597** |
| the **Backup page's two protection warnings** and its two target names | composed sentences in `backup_handlers.go` / `backup_target_offer.go` | **R-598** |
| ~~the **claim page's messages**~~ | ~~composed sentences passed into page data~~ | **R-596 CLOSED**, controller 0.259.0 |
| ~~the **setup code** and the **owner passphrase**~~ | ~~one Hungarian wordlist~~ | **R-597 CLOSED**, hub 0.119.0 |
| ~~the **Backup page's two protection warnings** and its two target names~~ | ~~composed sentences in Go~~ | **R-598 CLOSED**, controller 0.259.0 |
| the menu word **"Debug"** | it is already English; the complaint was the Hungarian household's | R-516 item 1 |
| the **operator's copy** of every event | operator-tier is Hungarian **by design** (ruling 1) | — |
| the **18 formal „ön" forms** | a localisation release may not change Hungarian bytes (§1); counted, ratcheted | R-516 |
@@ -769,6 +769,57 @@ walk cannot see them.
**R-214 closed as a side effect** — the console's last paint is now the bilingual "the box is linked"
banner. The 2026-09-14 walk recorded it as still reproducing.
### 10.6c Slice 6's residue, closed (controller v0.259.0 + hub v0.119.0, 2026-09-21)
The three rows above are closed. What is worth keeping is not that they closed but **what each one
turned out to be**, because two of the three were not what the row said.
**R-596 — the claim page.** Fourteen live call sites carrying **nine** distinct messages, not the
sixteen literals the row counted; one of the sixteen (`data["Title"]`) was **dead** — `claim.html` is
standalone and `.Title` belongs to `layout.html` — and was deleted rather than translated. The
anonymous, cookie-less page takes its language from `customer.language` in `controller.yaml`
(`langFor` → `settings.GetLanguage` → `configLanguage`); that chain was an unpinned assumption and is
now a test.
**R-598 — the backup warnings.** `degradedMessageFor` now returns a **key**, so the decision stays
language-free and in one place while the words are chosen by whoever knows the reader. The English
is asserted to carry the same NEGATION the Hungarian does — *protects against corrupted files, but
**not** against a disk failure*. An English sentence that promised disk-failure protection would be
worse than leaving it Hungarian.
**R-597 — the codes.** Two of the row's three secrets were mis-attributed:
- **The recovery code was never Hungarian.** `felhom-agent` mints it from the **EFF large wordlist**
and always has — ten English words, ≈129 bits. The hub does not own it, and no row was added for
it: a second definition of that secret is exactly the drift this section exists to prevent.
- **No claim mail states a word count.** The only count wording in the product is the bind page's
passphrase hint, and its English half is now count-free.
- The setup code and the owner passphrase now follow the household's language, one word longer in
English so the entropy **never drops**: setup 3 hu (44.6 bits) → 4 en (51.7); passphrase 5 hu
(74.3) → 6 en (77.5). The floor is computed from the embedded lists at test time, not asserted
against a constant.
**[FACT] The defect class is now four instances deep** — R-573, R-590, R-596, R-598: *a composed
sentence handed to a renderer as page data*. Nothing structural sees it. A template-parity fixture
renders the field faithfully; `TestI18nEnglishPages` reads a template, not a struct; the Go-parity
gate proves the Hungarian is unchanged and says nothing about which language reached the page. **The
only instruments that find it are a live English page and a handler-level render test**, and this
release added the second for both surfaces.
**[FACT] A method finding worth more than the result.** The first live check of the backup page used
the `felhom_lang` cookie and got the **Hungarian** page for `en`. That is correct: `langFor` step 2
says a request carrying a **session** reads the household's saved setting and deliberately ignores
the visitor cookie. The cookie is the right instrument for the anonymous claim page and the **wrong**
one for any signed-in page — where `?lang=` is. A session that had run only the cookie probe would
have concluded R-598 was unfixed and fixed it again. Recorded in
`audits/i18n-closing-2026-09-21/live/backups-page.md`.
**What did NOT get a live walk:** the degraded and absent-drive warnings themselves. Guest 9201 has a
real backup drive, so it is healthy and renders nothing — by design (E-2 Scenario E) — and producing
either state would mean un-assigning a live box's backup target. They are covered by render tests
through the real handler. **The next English walk on a one-drive machine is what actually closes
that**, and it is the same walk R-516 is waiting for in the other language.
## 11. Operator decisions
These are rulings, not proposals. Anything specced against a different assumption is wrong.
@@ -0,0 +1,45 @@
# Live proof — the Backup page's Go-composed copy follows the language (R-598)
**Box:** guest 9201 (`demo-felhom`) on `felhom-pve`, controller **0.259.0**, 2026-09-21.
**Method:** endpoint-level; signed in as the household, then `GET /backups` in both languages.
## What was proven live
| | `hu` | `en` |
|---|---|---|
| page title | `Biztonsági mentés — Felhom.eu` | `Backup — Felhom.eu` |
| primary tier label | „Helyi tároló (felhom-backup)" | **"Local storage (felhom-backup)"** |
| offsite tier label | „Biztonsági szerver – külön hardver (PBS)" | **"Backup server – separate hardware (PBS)"** |
| page size | 46 384 bytes | 45 728 bytes |
Those two labels are `backupTargetLabel` / `buildTierViews` — **Go-composed strings handed to the
page as struct fields**, which is the whole defect class. Before 0.259.0 the English column read
exactly like the Hungarian one.
## A finding about the method, worth more than the result
The first run used the **`felhom_lang` cookie** and got the **Hungarian page for `en`**. That is
correct behaviour, not a defect: `langFor` step 2 says a request **with a session** is the
household's own, so their saved setting wins and the visitor cookie is deliberately not read — a
signed-in family must never see a language a previous visitor picked on the sign-in page of the same
browser. The cookie is the right instrument for the anonymous claim page and the **wrong** one for a
signed-in page. `?lang=` — the documented testing override — is the right one here.
**This is worth writing down because the mistake is invisible:** a session that had only run the
cookie probe would have concluded R-598 was not fixed, and "fixed" it a second time.
## What was NOT proven live, and why
The **degraded** and **absent-drive** warnings did not render, because this box is **healthy** — it
has a real backup drive (`felhom-backup`), and a working configuration is designed to render nothing
at all (E-2 Scenario E). Producing either state live would mean un-assigning a real box's backup
target, which the task fences forbid and which is not worth doing to read a sentence.
They are covered instead by tests that drive the **real page handler** with the agent seams set to
the two states and read the returned HTML — `TestBackupWarningsFollowLanguage`,
`TestAbsentDriveWarningFollowsLanguageAndKeepsThePromise`. Those assert both that the English
appears and that the Hungarian is **gone**, and the English absent-drive copy is asserted to carry
the FACT, the CONSEQUENCE and the REMEDY, matching the Hungarian.
**Stated plainly: the two warnings the drill actually saw are proven by a handler-level render test,
not by a live box.** The next full English walk on a one-drive machine is what closes that.
@@ -0,0 +1,51 @@
# Live proof — the claim page answers in the reader's language (R-596)
**Box:** guest 9201 (`demo-felhom`) on `felhom-pve`, controller **0.259.0**, 2026-09-21.
**Method:** endpoint-level (no browser on DooPlex). The exact URL the page's own form POSTs to,
reached at the controller container's address with the `Host` header the router requires, carrying
the **`felhom_lang` cookie the language globe sets** — i.e. the real path a household takes, not the
`?lang=` testing override.
> **The box is CLAIMED, so `/claim` is the RESET-code entry.** That is the venue the task named, and
> it is the same handler, the same page and the same nine messages as a first claim.
## A — through the cookie (the household's path)
| cookie | screen | answer |
|---|---|---|
| `felhom_lang=hu` | page title | `Jelszó visszaállítása — Felhom` |
| `felhom_lang=hu` | wrong code | „Hibás vagy lejárt kód" |
| `felhom_lang=hu` | invalid form | „Érvénytelen űrlap — töltsd újra az oldalt." |
| `felhom_lang=en` | page title | `Reset password — Felhom` |
| `felhom_lang=en` | invalid form | **"Invalid form — reload the page."** |
| `felhom_lang=en` | after 5 wrong codes | **"Too many attempts — try again in 15 minutes."** |
Both Hungarian answers are byte-identical to what the box said at 0.258.0.
## B — the lockout proved itself, unasked
The probe sent two wrong codes per language. By the English run the **per-source lockout had already
tripped from the Hungarian ones**, so English received the lockout answer instead of the wrong-code
one. That is a stronger result than the one planned:
1. The **English lockout message** is proven live, which was not otherwise going to be walked.
2. The **lockout is language-blind** — the counter is per source, not per language. Attempts made
with `felhom_lang=hu` locked out the `felhom_lang=en` request from the same address. A guesser
cannot buy extra attempts by switching the cookie. `TestClaimLockoutAnswersInEnglishAndCountsTheSame`
asserts this on the counter; here the live box demonstrated it by accident.
The `?lang=hu` override then returned „Túl sok próbálkozás — próbáld újra 15 perc múlva." — the same
lockout, in Hungarian, from the same tripped counter.
## What this did to the box
The claim/reset page's rate limiter was left locked for **15 minutes** from the probe (a demo box,
Tier 0). It clears itself; nothing was configured, no password was changed, no code was consumed.
The dashboard password is **unchanged** — the probe never submitted a valid code.
## What was NOT walked here
The **wrong-code answer in English** ("Wrong or expired code") — the drill's own screen — was
pre-empted by the lockout above. It is covered by `TestClaimAnswersFollowTheReadersLanguage`, which
asserts both that the English sentence is present and that the Hungarian one is gone, and which was
red-proofed by restoring the literal. See the second live run below once the window reopens.
+6 -3
View File
@@ -771,11 +771,14 @@ class (an image `VOLUME` at an unmounted path) is still live — `immich-server`
| **R-593** | **[P3-LOW] papra's session-signing key is described as „the app's subdomain".** FOUND 2026-09-20 translating the catalog (R-560 slice 5, batch 1). `templates/papra/.felhom.yml` `deploy_fields[AUTH_SECRET].description` reads **„Az alkalmazás aldomainje"** — the sentence that belongs on `SUBDOMAIN`, on a field that signs sessions. `SUBDOMAIN` itself has NO description at all in that file, so this is a copy-paste that landed one field too low and took the original with it. A customer opening papra's install page reads a wrong explanation under a key they must not regenerate. **A localisation release may not change Hungarian bytes (10 §1)**, so it was not fixed there; and translating a wrong sentence faithfully would have shipped the error in a second language, so **that one field was left untranslated** — it falls back to the Hungarian exactly as today, and it is the ONE string keeping papra at 13/14 and the catalog ceiling off zero. **Fix shape:** move the sentence to `SUBDOMAIN` and give `AUTH_SECRET` its own („A munkamenetek aláírásához használt kulcs" or similar), re-capture that app's two entries in `scripts/copy_freeze/hu.json` in the same commit with the reason, then add the English. Owned by R-516 as a Hungarian-words change. | **READY - rank P3-LOW; owner: CC (catalog)** |
| **R-594** | **[P3-LOW] The catalog copy gate can CONVICT a retrieval promise but has no way to REGISTER a true one.** FOUND 2026-09-20 translating batch 3 (R-560 slice 5). Vaultwarden's invite step and its sign-up setting both ended „…can open an account", and the English retrieval-promise pattern reads `can … open` as the claim that sealed backups can be opened. **The conviction was a FALSE POSITIVE** — opening an account is not opening a backup — and the two sentences were reworded to „can sign up", which is also the better copy, so nothing is blocked today. **The gap is structural.** The shared vocabulary this gate copies (`scripts/customer_copy_vocab.py`) states the design explicitly: these stems are NOT banned, because each carries a claim that is sometimes TRUE, and *"an occurrence must be REGISTERED with a reason in the consuming gate's allowlist"*. The hub gate has `ALLOWLIST_EN`; `app-catalog-felhom.eu/scripts/check-copy-i18n.py` has none, so the only ways past it are to reword or to bypass the gate — and a catalog app whose English genuinely says a file can be restored (a backup app, a versioned document store) has no honest third option. **Fix shape:** an `ALLOWLIST_EN` of `(app, path, reason)` in the gate, a decoy proving a REGISTERED occurrence passes and an unregistered one still convicts, and a check that every entry still matches something (a stale allowlist entry was R-299's shape, and the retrieval gate has gone red on stale entries before). | **READY - rank P3-LOW; owner: CC (catalog)** |
| **R-595** | **[P2-MEDIUM] The catalog's new copy gate could not RUN in CI at all — six pushes red, six alarm mails, while the local hook was green.** FOUND 2026-09-20 by reading the operator's inbox at the start of localisation slice 6 — **not** by anything in the session that caused it, which is the point worth keeping. Every one of slice 5's six catalog pushes (`e81d41e`, `d9aa02a`, `0695d8e`, `5fe70d1`, `1f80650`, `de392cd`) produced *[felhom CI] gates FAILED*, and the alarm's own text says what that means: *"If the local pre-push hook was GREEN for this commit, then CI and the hook disagree — that is a finding about the gates themselves, not about CI, and it outranks whatever the push was for."* **Cause, found by CONTRAST:** `check-copy-i18n.py` imports PyYAML and is the only gate in that repo importing anything outside the standard library; `.gitea/workflows/gates.yml` states in its own header that the runner is *"a host-mode container with python3 and git and nothing else"*. The gate raised `ImportError` before checking anything, so the runner exited non-zero on every push, clean ones included. **FIXED the same session (catalog `18a6d2d`, CI job 791 SUCCESS, verified by id):** a DEGRADED MODE rather than a skip — without PyYAML the gate runs the check that needs no parser and matters most (every frozen Hungarian string must still occur verbatim in its app's bytes) and prints in full what it did NOT check. Measured first: 1 030 of 1 032 frozen strings appear byte-for-byte in the raw files; the two that do not are romm help_texts whose YAML escapes an inner double quote, so the escaped spelling is accepted too — 1 032 of 1 032 found, so the degraded check convicts nothing honest. Five new decoy cases run with PyYAML shadowed by a module that refuses to import, i.e. what CI actually executes. **The general form, and the reason this is P2 rather than P3:** a new gate is written and tested on the machine that has every library, and the runner deliberately has none. **Nothing in the pre-push hook can catch that** — the hook runs on the same rich machine. The only detector is the alarm mail, and it worked; what failed is that six of them went unread for two hours inside the session that caused them. | **CLOSED 2026-09-20 — degraded mode; CI job 791 green** |
| **R-596** | **[P1-HIGH] The claim page — the FIRST screen an English household touches — is English chrome with HUNGARIAN messages, and two of them are quoted in the guide as English.** FOUND 2026-09-20 on a fresh install by the slice-6 drill, **seen on screen, not read in source**. The page itself is English (*"Set up the server"*, *"Enter the setup code you got by e-mail…"*, *"Set up and sign in"*, *"Did not get the code? Ask for a new one"*). Its MESSAGES are not: a short password answered **„A jelszónak legalább 12 karakter hosszúnak kell lennie"** and a mistyped code answered **„Hibás vagy lejárt kód"**. **`internal/web/claim.go` carries SIXTEEN raw Hungarian literals**, every one of them a message this page shows: L281/L334 („A beállító állapot most nem olvasható…"), L286, L310/L444 („Érvénytelen űrlap…"), L317/L321/L359 („Túl sok próbálkozás — próbáld újra 15 perc múlva."), L338, L362 („Hibás vagy lejárt kód"), L368, L372 („A két jelszó nem egyezik"), L379/L384, L448, L523, L563. **Why every earlier slice missed it:** they are not error VALUES (slice 2 converted 179 of those) and not template text (slice 1 converted that — which is why the chrome IS English); they are composed sentences passed into `handleClaimPage(w, r, <msg>, "")` as page data. **The same shape as R-573's two banners**, one screen earlier in the journey. **It ranks P1 because of WHERE it is:** a household that cannot read „Hibás vagy lejárt kód" cannot tell a typo from a dead code, on the one screen that stands between them and their box — and the English guide, written from the bundle rather than from the screen, promises them *"Wrong or expired code"* and *"Too many attempts — try again in 15 minutes."*, which the product does not say. **Fix shape:** keys for all sixteen, `handleClaimPage` taking a key + args instead of a sentence, and a render case per message in both languages. | **READY — rank P1-HIGH; owner: CC (controller)** |
| **R-597** | **[P2-MEDIUM] The setup code is three Hungarian words, inside an otherwise fully English e-mail, sent to a household the hub knows is English.** FOUND 2026-09-20 by the slice-6 drill. The mail is English end to end (slice 3 working); the code it carries was **`képző-szkítia-ásatás`** — 20 characters, 3 words, **5 of them outside ASCII** (ő, í, á×2, é). An English speaker must copy three words they cannot read, spell or say aloud, and type them into a box on a keyboard that has no ő. They can paste — until the day they read the code to someone over the telephone, which is precisely what a three-word code is FOR. **The same generator feeds the recovery code (10 words) and the owner passphrase (5 words)**, so the fault is one wordlist wide, not one mail wide: this walk saw the passphrase too and it is Hungarian. **Fix shape:** an English wordlist chosen per `customer.language`, with the same word count and the same entropy, and a test that pins BOTH lists' entropy and that no word in either needs a character outside the reader's keyboard. **Not a rename of the existing words** — a second list. | **READY — rank P2-MEDIUM; owner: CC (hub)** |
| **R-598** | **[P2-MEDIUM] The Backup page's two protection warnings — the ones that say whether the household's files are safe — are Hungarian on an English dashboard.** FOUND 2026-09-20 by the slice-6 drill on a fresh box, and confirmed on the demo box. Of 73 lines on `/backups` exactly four are Hungarian: **„Csak egy másolat készül (nincs második meghajtó) — a 3-2-1 mentéshez csatlakoztasson egy második meghajtót vagy offsite tárolót"**, **„A rendszermentés jelenleg ugyanazon a lemezen van, mint a rendszer — így hibás fájlok ellen véd, lemezhiba ellen nem"**, and the two backup-target names **„Helyi tároló (local)"** and **„Biztonsági szerver – külön hardver (PBS)"**. They come from `internal/web/backup_handlers.go` (12 Hungarian literals) and `internal/web/backup_target_offer.go` (10) — again composed sentences handed to the page, the R-573/R-596 shape. **It matters more than its line count:** those two warnings are the only place the product tells a household that one copy on one disk is not protection, and the volunteer guide's §9 sends every tester to exactly this page to read exactly these two sentences. **Fix shape:** keys + args for both files, with the retrieval-promise gate run over the English (these sentences are about what a backup does and does not protect). | **READY — rank P2-MEDIUM; owner: CC (controller)** |
| **R-596** | **[P1-HIGH] The claim page — the FIRST screen an English household touches — is English chrome with HUNGARIAN messages, and two of them are quoted in the guide as English.** FOUND 2026-09-20 on a fresh install by the slice-6 drill, **seen on screen, not read in source**. The page itself is English (*"Set up the server"*, *"Enter the setup code you got by e-mail…"*, *"Set up and sign in"*, *"Did not get the code? Ask for a new one"*). Its MESSAGES are not: a short password answered **„A jelszónak legalább 12 karakter hosszúnak kell lennie"** and a mistyped code answered **„Hibás vagy lejárt kód"**. **`internal/web/claim.go` carries SIXTEEN raw Hungarian literals**, every one of them a message this page shows: L281/L334 („A beállító állapot most nem olvasható…"), L286, L310/L444 („Érvénytelen űrlap…"), L317/L321/L359 („Túl sok próbálkozás — próbáld újra 15 perc múlva."), L338, L362 („Hibás vagy lejárt kód"), L368, L372 („A két jelszó nem egyezik"), L379/L384, L448, L523, L563. **Why every earlier slice missed it:** they are not error VALUES (slice 2 converted 179 of those) and not template text (slice 1 converted that — which is why the chrome IS English); they are composed sentences passed into `handleClaimPage(w, r, <msg>, "")` as page data. **The same shape as R-573's two banners**, one screen earlier in the journey. **It ranks P1 because of WHERE it is:** a household that cannot read „Hibás vagy lejárt kód" cannot tell a typo from a dead code, on the one screen that stands between them and their box — and the English guide, written from the bundle rather than from the screen, promises them *"Wrong or expired code"* and *"Too many attempts — try again in 15 minutes."*, which the product does not say. **Fix shape:** keys for all sixteen, `handleClaimPage` taking a key + args instead of a sentence, and a render case per message in both languages. **CLOSED 2026-09-21, controller v0.259.0.** **The row over-counted and mis-counted.** Fifteen literal sites reach that page, carrying **nine** distinct messages (four are repeats); the sixteenth, L286 `data["Title"]`, is **DEAD** — `claim.html` is a standalone page with its own bundle-backed `<title>`, and `.Title` is read only by `layout.html`, which this page never includes. It was **deleted, not translated**: a translated dead field would have read for ever after as evidence that this page's title is decided in the handler. L523 (`--print-reset-code` stdout, operator-facing) and L563 (the `claim_lockout` event, whose customer text the HUB already localises as `mail.event.claim_lockout`) are wire copy and were correctly left alone. Fourteen live sites now go through `s.msg(r, "claim.msg.*")`. **The anonymous cookie-less page's language chain was an unpinned assumption and is now a test** (`langFor` → `settings.GetLanguage` → `configLanguage` ← `cfg.Customer.Language`). **Proven LIVE on guest 9201:** `felhom_lang=en` → "Invalid form — reload the page." and "Too many attempts — try again in 15 minutes."; `felhom_lang=hu` → the byte-identical Hungarian. The lockout **proved itself unasked** — Hungarian attempts locked out the English request from the same source, demonstrating live that the counter is per source and not per language. Red-proofed by restoring the wrong-code literal (the test convicted on both halves: the English absent AND the Hungarian present). | **CLOSED 2026-09-21 — controller v0.259.0, proven live** |
| **R-597** | **[P2-MEDIUM] The setup code is three Hungarian words, inside an otherwise fully English e-mail, sent to a household the hub knows is English.** FOUND 2026-09-20 by the slice-6 drill. The mail is English end to end (slice 3 working); the code it carries was **`képző-szkítia-ásatás`** — 20 characters, 3 words, **5 of them outside ASCII** (ő, í, á×2, é). An English speaker must copy three words they cannot read, spell or say aloud, and type them into a box on a keyboard that has no ő. They can paste — until the day they read the code to someone over the telephone, which is precisely what a three-word code is FOR. **The same generator feeds the recovery code (10 words) and the owner passphrase (5 words)**, so the fault is one wordlist wide, not one mail wide: this walk saw the passphrase too and it is Hungarian. **Fix shape:** an English wordlist chosen per `customer.language`, with the same word count and the same entropy, and a test that pins BOTH lists' entropy and that no word in either needs a character outside the reader's keyboard. **Not a rename of the existing words** — a second list. **CLOSED 2026-09-21, hub v0.119.0.** **One third of the row was wrong: the recovery code was never Hungarian.** `felhom-agent` mints it (`internal/escrow`) from the **EFF large wordlist** and always has — ten English words, ≈129 bits. The hub does not own that secret and no row was opened for it: a second definition here is the drift `backupTargetAbsentText` already demonstrates across two repos. The two the hub DOES mint now follow the household: setup code 3 hu words (44.6 bits) → **4 en words (51.7)**, owner passphrase 5 hu (74.3) → **6 en (77.5)**, list and count chosen together by `RandomPassphraseFor(lang, use)` so a caller cannot pair an English list with a Hungarian count. **The floor is computed from the embedded lists at test time, not compared with a constant** — red-proofed at 3 English words (38.77 vs 44.56). Hungarian is byte-unchanged, and the list length is pinned so a swap cannot move it quietly. **The task's proposed "read it over the phone" filter was MEASURED and NOT adopted** — it removes 5270 of 7772 words (68%, 12.92 → 11.29 bits/word) and would make this list stricter than the one the product already uses for the code a household writes on paper during a disaster; what it reached for is kept as an assertion (`TestEnglishListIsTranscribable`: 3-9 lower-case ASCII letters, no digit, no separator). Decision recorded in source, **operator may reverse**. Also: **no claim mail ever stated a word count** — the only count wording was the bind page's passphrase hint, whose English half is now count-free. | **CLOSED 2026-09-21 — hub v0.119.0** |
| **R-598** | **[P2-MEDIUM] The Backup page's two protection warnings — the ones that say whether the household's files are safe — are Hungarian on an English dashboard.** FOUND 2026-09-20 by the slice-6 drill on a fresh box, and confirmed on the demo box. Of 73 lines on `/backups` exactly four are Hungarian: **„Csak egy másolat készül (nincs második meghajtó) — a 3-2-1 mentéshez csatlakoztasson egy második meghajtót vagy offsite tárolót"**, **„A rendszermentés jelenleg ugyanazon a lemezen van, mint a rendszer — így hibás fájlok ellen véd, lemezhiba ellen nem"**, and the two backup-target names **„Helyi tároló (local)"** and **„Biztonsági szerver – külön hardver (PBS)"**. They come from `internal/web/backup_handlers.go` (12 Hungarian literals) and `internal/web/backup_target_offer.go` (10) — again composed sentences handed to the page, the R-573/R-596 shape. **It matters more than its line count:** those two warnings are the only place the product tells a household that one copy on one disk is not protection, and the volunteer guide's §9 sends every tester to exactly this page to read exactly these two sentences. **Fix shape:** keys + args for both files, with the retrieval-promise gate run over the English (these sentences are about what a backup does and does not protect). **CLOSED 2026-09-21, controller v0.259.0.** The row's count of `backup_handlers.go` was 12; **nine are code and three are Hungarian inside COMMENTS**. The offer file's ten is right. `degradedMessageFor` now returns a **KEY** — the decision stays language-free and in one place, the words are chosen by the caller that knows the reader — and `buildTierViews` / `backupTargetLabel` / `loadGuestBackup` take the language the way `buildDataPathCards` already did. **The English is asserted to carry the same NEGATION the Hungarian does** ("protects against corrupted files, **but not** against a disk failure"); an English sentence that promised disk-failure protection would be worse than leaving it Hungarian. **Proven LIVE on guest 9201** for the two tier names ("Local storage (felhom-backup)", "Backup server – separate hardware (PBS)"); **the two warnings themselves were NOT walked live** — that box is healthy and a healthy box renders nothing by design, and producing the state would mean un-assigning a live backup target. They are covered by render tests through the real handler in both states. **An apostrophe cost a render:** the first English absent-drive sentence never matched because `html/template` escapes `'` to `&#39;` — caught by the test, not by review. | **CLOSED 2026-09-21 — controller v0.259.0; the two warnings proven by render test, not live** |
| **R-599** | **[P3-LOW] A drill's teardown is blocked for 30 minutes by design, and nothing says so.** FOUND 2026-09-20 tearing the slice-6 drill down. VM destroyed at 17:12Z; the hub then refused **both** `POST /configs/<id>/delete` (409, *host … is ONLINE*) and the host delete (`deletable:false`) — correctly, because an online host would receive permanent 401s. But "online" is not a liveness probe: it is a **report-staleness window**, and the window is **45 minutes** — `manifests/hub.yaml` sets `alerting.stale_threshold: "45m"`, which `hostStatus()` reads (`ok` under it, `stale` over, `down` at 2x). A machine that no longer exists therefore reads ONLINE for three quarters of an hour. **Measured the boring way, and worth recording:** this row first said 30 minutes, because `monitor/host_staleness.go`'s literal default says 30m — the DEPLOYED value is in the manifest, and the 409s kept coming after the half hour was up. Reading a default and calling it the live value is the same mistake in a smaller coat. **The consequence is not theoretical:** a session that destroys its VM and then tears down the hub side walks away believing the delete failed, or leaves the customer behind — and the 2026-09-14 drill's teardown had the same shape without recording this. **Fix shape (smallest first):** the 409 body says *how long* it will refuse ("the last report was N minutes ago; deletion opens at HH:MM"), and `runbooks/target-selection.md`'s drill section names the wait. A force flag is NOT proposed — the refusal is right, only silent about its own clock. | **READY — rank P3-LOW; owner: CC (hub)** |
| **R-600** | **[P2-MEDIUM] "Full teardown" is logged while the deleted box's WireGuard peer is still configured on ep0.** FOUND 2026-09-20 by the slice-6 drill's teardown, **measured on ep0 rather than inferred from the hub**. The customer delete cascade finished at 19:41:54 with `customer DELETE cascade COMPLETE for drill-en-0920 (journal #18) — full teardown`, and every hub-side row was gone (0 configs, 0 hosts, 17 residue rows purged, PBS tenancy deprovisioned, escrow demoted). **Three minutes later `wg show wg0 allowed-ips` on ep0 still listed `10.77.0.5/32`** — the drill box's peer — because `wgsync` pushes on its own cycle. **Watched to the end rather than assumed: the peer was gone by 17:47:46Z — it outlived the *full teardown* line by about 6 minutes.** (My first estimate said ~35, read off the gap between two log lines; the sync runs oftener and only LOGS when something changes. That is the second time in this session that a period inferred from two log lines was wrong — the other was the delete's own staleness window. **A period read off two log lines is not a measurement.**) **The 2026-09-14 drill's findings say "the teardown removes it through the host delete"; measured, the host delete removes the hub's RECORD and the peer goes on the next push.** The mechanism is not broken — it is asynchronous, and the log line claims a completeness it does not yet have. **Why it is P2 rather than P3:** a session that tears down, reads *full teardown*, and leaves is the normal case; the peer outlives it by minutes, and the third teardown layer is the one the workspace rules single out as the one that gets forgotten. Six minutes is short — but the session that reads *full teardown* and leaves has no way to know it is six and not six hours. **Fix shape (smallest first):** the cascade triggers a wgsync push before it logs COMPLETE, or the log line says what is still pending and when ("wg peer removal queued; next push in N min"). A session should not have to read ep0 to know whether a teardown finished. | **READY - rank P2-MEDIUM; owner: CC (hub)** |
| **R-601** | **[P2-MEDIUM] `demo-hp` has been unreachable since at least this morning — offline on the tailnet for 30 days by Tailscale's own count, and not on the LAN either.** FOUND 2026-09-21 while looking for guest 9201 to ship controller v0.259.0. **What was tried, in order:** `ssh demo-hp` (tailnet `100.76.96.79`) → connection timed out; `sshpass` with the hub-vaulted G1 break-glass password → same timeout; `demo-hp-lan` (`192.168.0.87` via `ProxyJump felhom-pve`) → *No route to host*; `ping 100.76.96.79` → 100% loss; `tailscale status` from the DooPlex pod → **`demo-hp … offline, last seen 30d ago, tx 6084 rx 0`**; `ip neigh` on felhom-pve → `192.168.0.87 … FAILED`. The box answers on no route this session has. **The 30-day figure is Tailscale's and should not be believed on its own** — the slice-6 drill ran its nested drill VM on demo-hp on 2026-09-20 and tore it down, which is not consistent with a box that has been dark for a month; the likelier reading is that its tailscale link has been down for 30 days while the box was reached another way, and the box itself went down more recently. **Either way it is off now.** It also means the fleet's 0.258.0 box is the one that is dark and `demo-felhom` (0.257.0 until today) is the one that is up — so **the fleet is one box, not two, until someone looks at it.** Needs a person: it is a physical machine. | **READY — rank P2-MEDIUM; owner: OPERATOR (physical)** |
| **R-602** | **[P3-LOW] The language a signed-in page uses is NOT the language a cookie asks for, and a live probe that forgets this reports a fixed defect as unfixed.** FOUND 2026-09-21 verifying R-598 on guest 9201. `GET /backups` with `felhom_lang=en` returned the **Hungarian** page. That is correct — `langFor` step 2 says a request carrying a session reads the household's saved setting and deliberately ignores the visitor cookie, so a signed-in family never sees a language a previous visitor picked on the sign-in page of the same browser — but it means **the cookie is the right instrument for the anonymous claim/login/bind pages and the wrong one for every page behind auth**, where `?lang=` is. A session that had run only the cookie probe would have concluded R-598 was still open and fixed it a second time. **This is a documentation gap, not a code defect**, and it is the kind that costs a whole session: nothing in `10-localisation.md` §2.2 or in any runbook tells a prober which instrument to use where. **Fix shape:** four lines in `10-localisation.md` §2.2 — a table of surface → language instrument — and a pointer from the live-validation section of the workspace rules. Recorded meanwhile in `audits/i18n-closing-2026-09-21/live/backups-page.md`. | **READY — rank P3-LOW; owner: CC (docs)** |
| **R-603** | **[P3-LOW] An English string containing an apostrophe silently never matches on a rendered page, and a `strings.Contains` assertion reads exactly like a missing sentence.** FOUND 2026-09-21 while writing the R-598 render tests. `backup.target.absent` was first written as *"The system backup's drive cannot be reached…"*; `html/template` escapes `'` to `&#39;`, so the page carried the sentence and every assertion for it failed. **The failure mode is the expensive part:** the test said *"the English absent-drive copy never reached the page"*, which is indistinguishable from the handler not being wired — and the obvious next move is to go and re-fix the handler. Reworded to avoid the possessive, and all 23 new English values were then swept for `' " < > &` (zero). **The Hungarian bundle has never hit this** because Hungarian copy uses „quotes" and few apostrophes; **English copy will hit it again.** **Fix shape:** either a bundle gate that refuses an HTML-escapable character in a value destined for a page (and an allow-list for the ones that legitimately need one), or a test helper that compares against `html.EscapeString(want)` so the assertion cannot be fooled. The gate is the better shape — the helper only protects tests that remember to use it. | **READY — rank P3-LOW; owner: CC (controller)** |
| **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)** |
@@ -28,14 +28,15 @@
button to press; the customer page shows when it went out (R-509, 2026-09-15).
2. **Creates the Cloudflare tunnel and enters its token** on the customer's page — without it the
dashboard address does not open (R-494).
3. **Hands over the five-word "Owner passphrase" in person or in a message.** No e-mail contains it.
3. **Hands over the "Owner passphrase" in person or in a message.** No e-mail contains it. It is
six English words for an English account, five Hungarian ones for a Hungarian account (R-597).
## What you will need
- A machine where **all data will be erased** (the install overwrites the chosen disk completely).
- A USB stick of at least 2 GB.
- A network cable to your router.
- The **Owner passphrase** (5 words) you received from Felhom.
- The **Owner passphrase** (a few hyphen-joined words) you received from Felhom.
- The e-mail account you gave to Felhom.
## 1. Download the installer (~1 minute)
@@ -77,7 +78,7 @@ Write the **pairing code** down. It is printed once in each language; both are t
(valid for 7 days).
2. Enter:
- the **pairing code** from the box's screen;
- the **Owner passphrase** (5 words) you received from the Felhom operator.
- the **Owner passphrase** you received from the Felhom operator.
3. Leave the box switched on. The next e-mail arrives in **about 3 minutes**.
*(Note: the box's screen keeps showing the pairing code after it is connected — this is a known
@@ -90,8 +91,10 @@ fault, and you do not need to connect it again.)*
If the address does not open, tell the operator.
3. On the **"Set up the server"** page enter the **setup code** and choose a password of **at least
12 characters**. This becomes your dashboard password.
**The code is three Hungarian words, with accents** (like `képző-szkítia-ásatás`) — copy and paste
it from the e-mail rather than typing it. This is a known fault (R-597).
**The code is the words in your e-mail, joined by hyphens** (for example
`abacus-wreath-ratio-abdomen`). You can type it or paste it; capitals and spaces instead of
hyphens are accepted. If it looks like Hungarian words with accents, tell the operator — an
English account should receive English words (R-597, fixed 2026-09-21).
4. If the code did not arrive: **"Did not get the code? Ask for a new one"** — it always goes to the
same e-mail address.
@@ -138,11 +141,13 @@ Once you are done, the bar disappears and the remote backup starts by itself.
## 9. Backups
- **Backup → Overview:** you will see two yellow warnings. **They are still in Hungarian** on an
otherwise English page (a known fault, R-598), and they say: *only one copy is being made, there is
no second drive*, and *the system backup is on the same disk as the system, so it protects against
bad files but not against a disk failure*. **Both are true**: until there is a second drive or a
remote backup, this does not protect you against a disk failure.
- **Backup → Overview:** you will see two yellow warnings, in English. One says only one copy is
being made and there is no second drive. The other is, word for word:
> The system backup is currently on the same disk as the system — so it protects against corrupted files, but not against a disk failure. Attach a second drive for full protection.
**Both are true**: until there is a second drive or a remote backup, this does not protect you
against a disk failure. (They were Hungarian until 2026-09-21 — R-598, now closed.)
- **Backup → Apps → Back up now:** about 20 seconds, after which the date updates.
- *An individual app's "Backup 2 settings" page may say that its data is "already part of the full
system backup (PBS)" — this is not true on every box (known fault). The Overview page is the
@@ -165,14 +170,16 @@ same version it was before. You will have to sign in to the dashboard again.
## 13. If you mistype the code
The setup page rejects it and you can type it again. **At the moment those particular messages are
still in Hungarian**, even though the rest of the page is English — a wrong code answers
„Hibás vagy lejárt kód" (*wrong or expired code*) and, after **five** wrong attempts, the page locks
for 15 minutes with „Túl sok próbálkozás — próbáld újra 15 perc múlva." (*too many attempts, try
again in 15 minutes*). This is a known fault (R-596). You can ask for a new code with the
The setup page rejects it and you can type it again. A wrong code answers
**"Wrong or expired code"**, and after **five** wrong attempts the page locks for 15 minutes with
**"Too many attempts — try again in 15 minutes."** A password under twelve characters answers
**"The password must be at least 12 characters long"**. You can ask for a new code with the
"Did not get the code? Ask for a new one" link, and the sign-in page's "Forgot password" link leads
to the same place.
*(These messages were Hungarian until 2026-09-21 and were the one screen that stopped the first
English walk — R-596, now closed.)*
## If you get stuck
Write to **support@felhom.eu**, and send a screenshot if you can.