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.
+53
View File
@@ -1,3 +1,56 @@
## v0.119.0 — English households get English words for their codes (2026-09-21, R-597)
The 2026-09-20 English drill received a setup code of **three Hungarian words with accents**
(`képző-szkítia-ásatás`) inside an otherwise English e-mail. It can be pasted; it cannot be read to
anyone over the phone, and it cannot be retyped by someone whose keyboard has no accents.
- **A second embedded list, `internal/configgen/english.txt`** — the EFF "large" diceware list
(7776 words, CC BY 3.0 US, https://www.eff.org/dice). It is **the same file felhom-agent already
embeds** to mint the customer recovery code, copied rather than imported because the two binaries
share no module. Provenance and licence are recorded in the source.
- **`RandomPassphraseFor(lang, use)`** chooses the list **and** the word count together, so the two
cannot be picked apart and a caller cannot defeat the floor by passing an English list with a
Hungarian count. `Use` is `SetupCode` or `OwnerPassphrase`.
- **The rule: per use, the English code carries at least as many bits as the Hungarian one.** The
English list is smaller (7772 vs 29 609 words after filtering; 12.92 vs 14.85 bits/word), so
English takes one more word:
| use | hu | en |
|---|---|---|
| setup / reset code | 3 words, 44.56 bits | **4 words, 51.70 bits** |
| owner passphrase | 5 words, 74.27 bits | **6 words, 77.54 bits** |
`TestEnglishIsNeverWeakerThanHungarian` computes **both sides from the embedded lists' real
lengths** and the shipped table — not a constant against itself. Red-proofed by setting the
English setup code to 3 words; it named the 38.77-vs-44.56 gap.
- **All four callers pass a language**: the claim engine (`CustomerLanguage`), the regenerate-password
handler (`CustomerLanguage`), the create-customer form (**the form's own field** — there is no
stored customer yet, so `CustomerLanguage` would answer Hungarian for every English household
created), and the config-invented-from-a-report path (`i18n.Default`, matching the `Language` that
same struct sets two lines below).
- **The English bind-page hint is now count-free** ("The word phrase you received from your operator
during setup"). It said "five words", which stops being true for an English household the moment
their passphrase is six — and would also have been wrong for every English household whose
passphrase was issued before this release. Hungarian is unchanged, and „öt szó" stays correct.
- **Hungarian is untouched**: same list, same counts, same words. `TestHungarianCodesUnchanged` pins
the counts AND the list length, so a list swap cannot move the Hungarian floor quietly.
**Three claims in the task that were wrong, found at source:**
1. **The hub does not mint the recovery code.** `felhom-agent` does, in `internal/escrow`, and it has
drawn from this same EFF list since it was written — so the recovery code has **always been
English**, ten words, ≈129 bits. No work was needed and none was done: adding a row for it here
would have created a second definition of a secret this repo does not own.
2. **No claim mail states a word count.** They say `Setup code: %s`. The only count wording in the
product is on the bind page, and that is what changed.
3. **The proposed "read it over the phone" filter** — drop any word differing from another by one
letter within its first six — was **measured before adoption** and **not adopted**: it removes
**5270 of 7772 words**, 68% of the list, taking it from 12.92 to 11.29 bits/word. It would also
have made this list stricter than the one the product already uses for the code a household writes
on paper during a disaster. What it was reaching for is kept as an assertion rather than a filter:
`TestEnglishListIsTranscribable` pins that every word is 3-9 lower-case ASCII letters with no
digit and no separator. Recorded as a decision (CC, operator may reverse) in the source.
## v0.118.1 — the test mail follows the language too (2026-09-18, R-558)
Found during v0.118.0's own live proof, which is the only reason it was found: I went to press "send
+18 -8
View File
@@ -16,10 +16,15 @@ import (
"golang.org/x/crypto/bcrypt"
)
// codeWords is the claim/reset code length: 3 Hungarian words (~44 bits with the ~29K list) —
// dictatable over the phone, and the controller's 5-attempt/15-min lockout makes online guessing
// infeasible.
const codeWords = 3
// The claim/reset code length is no longer a constant here: it is per language, and it lives in ONE
// place — configgen.wordCounts, read through configgen.WordCountFor (R-597, v0.119.0).
//
// It used to be `const codeWords = 3` with the note "3 Hungarian words (~44 bits with the ~29K
// list) — dictatable over the phone". Every word of that is still true for a Hungarian household,
// and their code is unchanged. It was never true for an English one: they got the same three
// Hungarian words, accents and all, inside an English e-mail. The English code is four words from
// the EFF list (~51.7 bits — MORE than the Hungarian 44.6, never less), because the English list is
// smaller. The controller's 5-attempt/15-minute lockout is unchanged and language-blind.
// maxResetPerDay is the hub-side cap on controller-forwarded reset requests (per customer).
const maxResetPerDay = 3
@@ -67,9 +72,14 @@ func (e *Engine) logf(f string, a ...any) {
}
}
// newCode generates a fresh code and its bcrypt hash.
func newCode() (code, hash string, err error) {
code, err = configgen.RandomPassphrase(codeWords)
// newCode generates a fresh code and its bcrypt hash, in the household's language.
//
// The BOX is untouched by this: it stores and compares a bcrypt hash of whatever was minted, with no
// notion of which list the words came from (controller internal/web/claim.go). So an English code is
// accepted by exactly the same path a Hungarian one is, including the TTL, the single-use
// generation and the lockout.
func newCode(lang string) (code, hash string, err error) {
code, err = configgen.RandomPassphraseFor(lang, configgen.UseSetupCode)
if err != nil {
return "", "", fmt.Errorf("claim: generating code: %w", err)
}
@@ -84,7 +94,7 @@ func newCode() (code, hash string, err error) {
// keeps the rotated hash (the gate stays armed) and is LOUD: the operator sees emailed_at unset
// on the customer page and can resend. Returns the new generation.
func (e *Engine) rotateAndSend(cc *store.CustomerConfig, kind EmailKind) (int, error) {
code, hash, err := newCode()
code, hash, err := newCode(e.Store.CustomerLanguage(cc.CustomerID))
if err != nil {
return 0, err
}
+99
View File
@@ -0,0 +1,99 @@
package claim
import (
"strings"
"testing"
)
// R-597 — THE WIRING, not the generator.
//
// configgen's own tests prove it CAN mint an English code. These prove the engine ASKS it to: the
// mechanism-vs-consequence distinction this project has been bitten by (R-97b). A perfectly correct
// generator that nobody calls with the household's language leaves the English drill exactly where
// it was.
// isASCIILower is the property that actually matters to a household: every letter is on their
// keyboard. A Hungarian code from the ~29K list carries accents on almost every draw.
func isASCIILower(s string) bool {
for _, r := range s {
if (r < 'a' || r > 'z') && r != '-' {
return false
}
}
return true
}
// An English household's setup code is made of English words — asserted on the code the MAILER
// received, which is the string that reaches the customer's inbox.
func TestSetupCodeFollowsTheHouseholdLanguage(t *testing.T) {
for _, tc := range []struct {
lang string
wantWords int
wantASCII bool
}{
{"en", 4, true},
{"hu", 3, false},
} {
e, st, m := newTestEngine(t)
cc := cust()
cc.Language = tc.lang
if err := st.SaveCustomerConfig(cc); err != nil {
t.Fatalf("[%s] SaveCustomerConfig: %v", tc.lang, err)
}
if _, err := e.EnsureIssued(cc); err != nil {
t.Fatalf("[%s] EnsureIssued: %v", tc.lang, err)
}
if len(m.sends) != 1 {
t.Fatalf("[%s] expected one mail, got %v", tc.lang, m.sends)
}
words := strings.Split(m.lastCode, "-")
if len(words) != tc.wantWords {
t.Errorf("[%s] the code has %d words, want %d (code shape only — the code itself is never "+
"logged): %d segments", tc.lang, len(words), tc.wantWords, len(words))
}
if tc.wantASCII && !isASCIILower(m.lastCode) {
t.Errorf("[%s] the mailed setup code is not plain ASCII — an English household cannot type "+
"it. This is exactly what the 2026-09-20 drill received.", tc.lang)
}
}
}
// A household created as Hungarian whose BOX later reports English: the next code follows the
// language the hub would write the mail in, which is CustomerLanguage's order (reported → created →
// Hungarian). Documented in 05-hub-architecture.md; pinned here so the two cannot drift.
func TestANewCodeFollowsTheSameOrderAsTheMail(t *testing.T) {
e, st, m := newTestEngine(t)
cc := cust()
cc.Language = "hu"
if err := st.SaveCustomerConfig(cc); err != nil {
t.Fatal(err)
}
if _, err := e.EnsureIssued(cc); err != nil {
t.Fatal(err)
}
if n := len(strings.Split(m.lastCode, "-")); n != 3 {
t.Fatalf("the created-as-Hungarian code has %d words, want 3", n)
}
// The box now reports that the household switched their dashboard to English.
if err := st.SaveReport("c1", []byte(`{"language":"en"}`)); err != nil {
t.Fatalf("SaveReport: %v", err)
}
if got := st.CustomerLanguage("c1"); got != "en" {
t.Fatalf("CustomerLanguage is %q after an English report, want \"en\" — the fixture is wrong, "+
"not the code under test", got)
}
// RequestReset is the real "Forgot password" path — the one the drill's missing step walks.
if err := e.RequestReset(cc); err != nil {
t.Fatalf("RequestReset: %v", err)
}
if !isASCIILower(m.lastCode) {
t.Errorf("after the household switched to English, the NEXT code is still Hungarian — the code " +
"and the mail that carries it now disagree about the language")
}
if n := len(strings.Split(m.lastCode, "-")); n != 4 {
t.Errorf("the English code has %d words, want 4", n)
}
// Codes ALREADY ISSUED are untouched: rotation mints a new one, it does not retranslate an old
// one. Nothing to assert beyond the generation moving, which the engine's own tests cover.
}
File diff suppressed because it is too large Load Diff
+175 -14
View File
@@ -3,44 +3,205 @@ package configgen
import (
"crypto/rand"
_ "embed"
"fmt"
"math"
"math/big"
"strings"
"gitea.dooplex.hu/admin/felhom-hub/internal/i18n"
)
//go:embed hungarian.txt
var hungarianWords string
// wordList is populated from the embedded hungarian.txt at init time.
// english.txt is the EFF "large" diceware wordlist (7776 words), CC BY 3.0 US, published by the
// Electronic Frontier Foundation at https://www.eff.org/dice — the standard list for passphrases a
// human has to transcribe. It is the SAME FILE felhom-agent already embeds at
// internal/escrow/eff_large_wordlist.txt to mint the customer recovery code, copied rather than
// imported because the two binaries share no module.
//
// R-597: an English-speaking household was given a setup code of three HUNGARIAN words with accents
// inside an otherwise English e-mail. They can paste it; they cannot read it to anyone, and they
// cannot retype it. This list is how the hub answers them in their own language.
//
//go:embed english.txt
var englishWords string
// wordList is the Hungarian list, populated from the embedded hungarian.txt at init time.
var wordList []string
// englishList is the EFF list minus every word containing the separator, so a generated code always
// segments back into exactly the number of words drawn. Populated at init.
var englishList []string
// passphraseSep joins the words of a generated passphrase.
const passphraseSep = "-"
func init() {
seen := make(map[string]struct{}, 30000)
for _, line := range strings.Split(hungarianWords, "\n") {
w := strings.TrimSpace(line)
if w == "" {
continue
wordList = dedupe(splitWords(hungarianWords))
englishList = joinSafe(dedupe(splitWords(englishWords)))
}
func splitWords(raw string) []string {
var out []string
for _, line := range strings.Split(raw, "\n") {
if w := strings.TrimSpace(line); w != "" {
out = append(out, w)
}
}
return out
}
func dedupe(in []string) []string {
seen := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
for _, w := range in {
if _, dup := seen[w]; dup {
continue
}
seen[w] = struct{}{}
wordList = append(wordList, w)
out = append(out, w)
}
return out
}
// RandomPassphrase generates a human-friendly passphrase from Hungarian words.
// Format: "szó-szó-szó-szó-szó" (words separated by dashes).
// With a ~29K word list, 4 words gives ~59 bits of entropy, 5 words ~74 bits.
// Easy to read, type, and dictate by Hungarian-speaking customers.
// joinSafe drops every word containing passphraseSep. In the EFF large list this removes exactly
// four entries — drop-down, felt-tip, t-shirt, yo-yo — of 7776, costing ~0.0007 bits/word.
//
// THIS IS THE ONLY FILTER, AND THAT IS A DECISION, not an omission (CC, 2026-09-21; operator may
// reverse). The closing task proposed a second one: drop any word that differs from another by one
// letter at the same position within its first six letters, "the read-it-over-the-phone rule". It
// was measured before being adopted and it removes 5270 of 7772 words — 68% of the list, taking it
// from 12.92 to 11.29 bits/word. Three reasons not to pay that:
//
// 1. It would make this list stricter than the one the product already uses for the RECOVERY CODE
// — the one secret a household writes on paper and reads back during a disaster. felhom-agent
// draws that from this same list with this same single filter. A stricter rule for the setup
// code, which is pasted out of an e-mail and expires in 72 hours, is incoherent.
// 2. Spelling distance is not dictation distance. The confusions that matter over a telephone are
// phonetic, and the EFF list was assembled by people solving exactly that problem.
// 3. Every bit it removes has to be bought back with more words, and a longer code is itself a
// transcription risk.
//
// What the rule was reaching for is real, and it is kept as an assertion instead of a filter:
// TestEnglishListIsTranscribable pins that no word carries a digit or a separator and that every
// word is 3-9 lower-case ASCII letters.
func joinSafe(words []string) []string {
out := make([]string, 0, len(words))
for _, w := range words {
if strings.Contains(w, passphraseSep) {
continue
}
out = append(out, w)
}
return out
}
// Use names what a generated passphrase is FOR. The word count depends on it, because the three uses
// have different lifetimes and different consequences, and because the entropy floor is per use.
type Use string
const (
// UseSetupCode is the claim/reset code mailed to the household. 72-hour TTL, single use,
// rate-limited and locked out by the box after five wrong attempts.
UseSetupCode Use = "setup_code"
// UseOwnerPassphrase is the long-lived "Owner passphrase" handed over out of band and typed on
// the self-bind page. It is compared exactly (NormalizePassphrase folds only case and spacing),
// which is why an English household must not be given Hungarian words with accents.
UseOwnerPassphrase Use = "owner_passphrase"
)
// wordCounts is the word count per (use, language).
//
// THE RULE IS: for each use, the English code carries AT LEAST as many bits as the Hungarian one.
// The English list is smaller (7772 vs 29609 words, 12.92 vs 14.85 bits/word), so English needs one
// more word for both uses. Nothing here may be lowered without lowering the Hungarian first, and
// TestEnglishIsNeverWeakerThanHungarian computes both sides from the embedded lists and this table —
// it does not compare a constant with itself.
//
// setup code hu 3 = 44.56 bits en 4 = 51.70 bits
// owner passphrase hu 5 = 74.27 bits en 6 = 77.54 bits
//
// The RECOVERY CODE is deliberately absent: it is not minted here. felhom-agent mints it on the box
// (internal/escrow, GenerateRecoveryCode), it has always been ten EFF words, and it was already
// English before R-597 existed. Adding a row for it here would invent a second definition of a
// secret this repo does not own.
var wordCounts = map[Use]map[string]int{
UseSetupCode: {"hu": 3, "en": 4},
UseOwnerPassphrase: {"hu": 5, "en": 6},
}
// listFor returns the word list for a language. Anything that is not a supported language falls back
// to Hungarian — the same direction every other default in this repo takes.
func listFor(lang string) []string {
if lang == "en" {
return englishList
}
return wordList
}
// WordCountFor is the number of words RandomPassphraseFor will draw. Exported so a caller that has
// to describe the code ("the four words in this e-mail") reads the number from the same table the
// generator uses, rather than writing it down a second time.
func WordCountFor(lang string, use Use) int {
byLang, ok := wordCounts[use]
if !ok {
return 0
}
if n, ok := byLang[lang]; ok {
return n
}
return byLang[i18n.Default]
}
// EntropyBitsFor is the approximate entropy of a generated passphrase, for audit and for the test
// that pins the floor. Never the passphrase itself.
func EntropyBitsFor(lang string, use Use) float64 {
n := WordCountFor(lang, use)
list := listFor(lang)
if n <= 0 || len(list) < 2 {
return 0
}
return float64(n) * math.Log2(float64(len(list)))
}
// RandomPassphraseFor generates a passphrase in the household's language for a named use.
//
// The language decides BOTH the word list and the word count; the two cannot be chosen separately,
// which is what keeps the entropy floor from being defeated by a caller passing an English list and
// a Hungarian count.
func RandomPassphraseFor(lang string, use Use) (string, error) {
n := WordCountFor(lang, use)
if n <= 0 {
return "", fmt.Errorf("configgen: unknown passphrase use %q", use)
}
return drawWords(listFor(lang), n)
}
// RandomPassphrase generates a passphrase of wordCount Hungarian words.
//
// Kept for callers that are language-blind by nature. Every customer-facing caller has moved to
// RandomPassphraseFor; this one no longer chooses what a household reads.
func RandomPassphrase(wordCount int) (string, error) {
return drawWords(wordList, wordCount)
}
// drawWords picks wordCount words uniformly from list (crypto/rand via big.Int — no modulo bias).
func drawWords(list []string, wordCount int) (string, error) {
if len(list) < 2 {
return "", fmt.Errorf("configgen: wordlist not loaded (%d words)", len(list))
}
if wordCount <= 0 {
return "", fmt.Errorf("configgen: word count must be positive, got %d", wordCount)
}
words := make([]string, wordCount)
max := big.NewInt(int64(len(wordList)))
max := big.NewInt(int64(len(list)))
for i := range words {
idx, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
words[i] = wordList[idx.Int64()]
words[i] = list[idx.Int64()]
}
return strings.Join(words, "-"), nil
return strings.Join(words, passphraseSep), nil
}
@@ -0,0 +1,168 @@
package configgen
import (
"math"
"strings"
"testing"
)
// R-597 — ENGLISH WORDS FOR ENGLISH HOUSEHOLDS, AND NEVER A WEAKER CODE.
//
// The 2026-09-20 English drill received a setup code of three Hungarian words with accents inside an
// otherwise English e-mail (`képző-szkítia-ásatás`). It can be pasted; it cannot be read aloud, and
// it cannot be retyped by someone who does not have the accents on their keyboard.
//
// The English list is smaller than the Hungarian one, so "translate the code" is not free: fewer
// bits per word. These tests exist so the fix cannot quietly buy readability with security.
// S3 — for every use, the English code carries at least as many bits as the Hungarian one.
//
// NOT A CONSTANT-FOR-MEASUREMENT DECOY: both sides are computed from the embedded lists' actual
// lengths and the shipped count table. Shrink english.txt, or drop a word count in wordCounts, and
// this fails. (Red-proofed by setting UseSetupCode's "en" to 3 — see the session REPORT.)
func TestEnglishIsNeverWeakerThanHungarian(t *testing.T) {
if len(wordList) < 2 || len(englishList) < 2 {
t.Fatalf("a wordlist did not load: hu=%d en=%d", len(wordList), len(englishList))
}
for _, use := range []Use{UseSetupCode, UseOwnerPassphrase} {
hu := EntropyBitsFor("hu", use)
en := EntropyBitsFor("en", use)
if hu <= 0 || en <= 0 {
t.Fatalf("%s: entropy came out zero (hu=%.2f en=%.2f) — the table or a list is missing", use, hu, en)
}
if en < hu {
t.Errorf("%s: the ENGLISH code is WEAKER than the Hungarian one — en %d words = %.2f bits, "+
"hu %d words = %.2f bits. An English household must not be given a code that is easier "+
"to guess in exchange for being readable.",
use, WordCountFor("en", use), en, WordCountFor("hu", use), hu)
}
t.Logf("%-18s hu %d words = %6.2f bits | en %d words = %6.2f bits (%.2f bits/word hu, %.2f en)",
use, WordCountFor("hu", use), hu, WordCountFor("en", use), en,
math.Log2(float64(len(wordList))), math.Log2(float64(len(englishList))))
}
}
// The Hungarian side is UNCHANGED. Not "still fine" — identical: same list, same counts, so every
// Hungarian household's code is exactly what it was before v0.119.0.
func TestHungarianCodesUnchanged(t *testing.T) {
if got := WordCountFor("hu", UseSetupCode); got != 3 {
t.Errorf("the Hungarian setup code is now %d words, was 3", got)
}
if got := WordCountFor("hu", UseOwnerPassphrase); got != 5 {
t.Errorf("the Hungarian owner passphrase is now %d words, was 5", got)
}
// The Hungarian list itself: the file has 29634 lines with 25 duplicates. Pinned so a list swap
// cannot move the Hungarian entropy floor without saying so.
if len(wordList) != 29609 {
t.Errorf("the Hungarian list is %d words, was 29609 — the entropy floor moved", len(wordList))
}
// And a Hungarian code must still be drawn from the Hungarian list.
code, err := RandomPassphraseFor("hu", UseSetupCode)
if err != nil {
t.Fatal(err)
}
hu := make(map[string]struct{}, len(wordList))
for _, w := range wordList {
hu[w] = struct{}{}
}
for _, w := range strings.Split(code, passphraseSep) {
if _, ok := hu[w]; !ok {
t.Errorf("a Hungarian setup code contains %q, which is not in the Hungarian list", w)
}
}
}
// An English code is drawn from the English list, has the right number of words, and segments back
// into exactly that many — the joinSafe guarantee.
func TestEnglishCodeIsEnglishAndSegments(t *testing.T) {
en := make(map[string]struct{}, len(englishList))
for _, w := range englishList {
en[w] = struct{}{}
}
for _, use := range []Use{UseSetupCode, UseOwnerPassphrase} {
want := WordCountFor("en", use)
for i := 0; i < 200; i++ {
code, err := RandomPassphraseFor("en", use)
if err != nil {
t.Fatalf("%s: %v", use, err)
}
parts := strings.Split(code, passphraseSep)
if len(parts) != want {
t.Fatalf("%s: code %q segments into %d words, want %d — a word carrying the separator "+
"slipped past joinSafe", use, code, len(parts), want)
}
for _, w := range parts {
if _, ok := en[w]; !ok {
t.Fatalf("%s: code contains %q, which is not in the English list", use, w)
}
}
}
}
}
// What the discarded "phone rule" was actually reaching for, kept as an assertion instead of a
// filter (see joinSafe's note). A word that carries a digit, an accent, a capital or a separator is
// the thing that genuinely breaks transcription and retyping.
func TestEnglishListIsTranscribable(t *testing.T) {
if len(englishList) != 7772 {
t.Errorf("the English list is %d words, expected 7772 (EFF large, minus the four hyphenated "+
"entries) — the entropy floor moved", len(englishList))
}
for _, w := range englishList {
if len(w) < 3 || len(w) > 9 {
t.Errorf("%q is %d characters — outside the 3-9 range a person can hold in their head", w, len(w))
}
for _, r := range w {
if r < 'a' || r > 'z' {
t.Errorf("%q contains %q — an English code must be lower-case ASCII letters only, so it "+
"can be typed on any keyboard, which is the whole reason this list exists", w, r)
break
}
}
}
}
// The ENTIRE POINT of an English code is that it survives a round trip through the box's comparison,
// which lower-cases and re-joins. A household who types their code with spaces, or in capitals, must
// be let in.
func TestEnglishCodeSurvivesNormalisation(t *testing.T) {
code, err := RandomPassphraseFor("en", UseSetupCode)
if err != nil {
t.Fatal(err)
}
for _, typed := range []string{
code,
strings.ToUpper(code),
strings.ReplaceAll(code, passphraseSep, " "),
" " + strings.ReplaceAll(code, passphraseSep, " ") + " ",
} {
if got := NormalizePassphrase(typed); got != code {
t.Errorf("typing %q normalises to %q, want %q", typed, got, code)
}
}
}
// An unsupported or empty language must land on Hungarian — the list AND the count together. A
// caller that got the list right and the count wrong would produce a code weaker than either.
func TestUnknownLanguageFallsBackToHungarianWholesale(t *testing.T) {
for _, lang := range []string{"", "de", "EN-GB", "xx"} {
if got, want := WordCountFor(lang, UseSetupCode), WordCountFor("hu", UseSetupCode); got != want {
t.Errorf("language %q: %d words, want the Hungarian %d", lang, got, want)
}
code, err := RandomPassphraseFor(lang, UseSetupCode)
if err != nil {
t.Fatalf("language %q: %v", lang, err)
}
if n := len(strings.Split(code, passphraseSep)); n != WordCountFor("hu", UseSetupCode) {
t.Errorf("language %q produced a %d-word code", lang, n)
}
}
}
// An unknown USE must be an ERROR, never a silently short code. This is the direction that matters:
// a typo'd Use returning a one-word passphrase would be a catastrophic silent weakening.
func TestUnknownUseIsRefused(t *testing.T) {
if code, err := RandomPassphraseFor("en", Use("retrieval_key")); err == nil {
t.Errorf("an unknown use produced a passphrase %q instead of an error", code)
}
}
+2 -2
View File
@@ -65,8 +65,8 @@
"bind.placeholder.pairing": "ABC-234",
"bind.hint.pairing": "It appears on the box's monitor, after the install.",
"bind.label.passphrase": "Owner passphrase",
"bind.placeholder.passphrase": "five words, with hyphens or spaces",
"bind.hint.passphrase": "The five-word phrase you received during setup. It proves the account is yours.",
"bind.placeholder.passphrase": "the words, with hyphens or spaces",
"bind.hint.passphrase": "The word phrase you received from your operator during setup. It proves the account is yours.",
"bind.submit": "Link the box",
"bind.note": "For safety the link locks after 5 failed attempts. If that happens, contact support.",
"bind.success.lead": "Linked successfully.",
+16 -5
View File
@@ -708,8 +708,14 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) {
return
}
// Generate credentials
retrievalPassword, err := configgen.RandomPassphrase(5)
// Generate credentials.
//
// R-597: the Owner passphrase follows the language the operator is choosing ON THIS FORM, not
// the one in the store — there is no stored customer yet, and CustomerLanguage would answer
// Hungarian for every English household created here. The store's own createdLanguage applies
// the same default to an absent or unknown value, so the two agree.
createLang := i18n.Normalize(strings.TrimSpace(r.FormValue("language")))
retrievalPassword, err := configgen.RandomPassphraseFor(createLang, configgen.UseOwnerPassphrase)
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
@@ -1032,7 +1038,10 @@ func (s *Server) handleConfigPreview(w http.ResponseWriter, r *http.Request, cus
// handleConfigRegenPassword regenerates the retrieval password.
func (s *Server) handleConfigRegenPassword(w http.ResponseWriter, r *http.Request, customerID string) {
newPassword, err := configgen.RandomPassphrase(5)
// R-597: a regenerated Owner passphrase follows the household's language exactly as their mails
// do — CustomerLanguage's order is last-reported → created-with → Hungarian, so a household that
// switched their own dashboard to English gets English words on the next regeneration.
newPassword, err := configgen.RandomPassphraseFor(s.store.CustomerLanguage(customerID), configgen.UseOwnerPassphrase)
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
@@ -1445,8 +1454,10 @@ func (s *Server) handleCreateConfigFromReport(w http.ResponseWriter, r *http.Req
name = customer.CustomerName
}
// Generate credentials
retrievalPassword, _ := configgen.RandomPassphrase(5)
// Generate credentials. The config below states Language: i18n.Default for this path (no
// operator is present), so the passphrase is drawn for the SAME language — reading it from the
// one line that decides it, rather than restating the choice.
retrievalPassword, _ := configgen.RandomPassphraseFor(i18n.Default, configgen.UseOwnerPassphrase)
apiKey, _ := configgen.RandomHex(32)
cfg := &store.CustomerConfig{
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""guide_quote_gate -- the English guide may only quote what the screen actually says (R-596/R-598).
Run from anywhere: python3 scripts/guide_quote_gate.py
Exit 0 clean * 1 convicted * 2 inconclusive (a file it needs is missing).
WHY THIS EXISTS. `documentation/runbooks/VOLUNTEER-first-hour.en.md` is what a volunteer tester
follows instead of the Hungarian guide. Three of its lines QUOTE messages the dashboard prints:
the claim page's wrong-code and lockout answers, and the Backup page's system-disk warning. Those
three sentences live in the CONTROLLER's English bundle, in a different repo, and nothing bound
them together -- so the guide would have gone on quoting Hungarian for as long as nobody re-walked
it. That is exactly how it read for a day: the 2026-09-20 drill's guide quoted the Hungarian
because the Hungarian was what shipped, and when v0.259.0 changed the screen the guide became wrong
in the other direction.
WHAT IT CHECKS. Each entry below names a bundle KEY and asserts its English value appears verbatim
in the guide. A reworded key, or a reworded guide, convicts and prints both sides.
WHAT IT DOES NOT CHECK, deliberately: that the guide is complete, or that any OTHER sentence in it
is accurate. It binds the three quotes that are quotes. Everything else in that document is prose a
person has to re-walk, and pretending otherwise would be the label without the fact.
SCOPE IS A FACT. The controller clone may not sit beside this one (CI checks out one repo). An
absent sibling is INCONCLUSIVE (exit 2), never a silent pass -- the failure this project keeps
closing is a check that reports green because it could not look.
THE DECOY (R-421). scripts/test_guide_quote_gate.py builds a guide that MENTIONS every key by name
in prose -- the label without the fact -- and asserts this gate still convicts, because it compares
the VALUE, not the key.
"""
from __future__ import annotations
import io
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
WORKSPACE = os.path.dirname(REPO)
GUIDE = os.path.join(REPO, "documentation", "runbooks", "VOLUNTEER-first-hour.en.md")
EN_BUNDLE = os.path.join(
WORKSPACE, "felhom-controller", "controller", "internal", "i18n", "locales", "en.json"
)
# key -> why the guide quotes it. The reason is part of the record: a future session deciding to
# drop a quote should have to argue with the reason, not just with the list.
QUOTED = {
"claim.msg.bad_code":
"section 13 -- the answer to a mistyped setup code. This is the sentence the 2026-09-20 "
"English drill stopped on; a tester who reads a different one cannot tell a typo from a "
"dead code, which is the whole point of the section.",
"claim.msg.too_many":
"section 13 -- the lockout answer after five wrong codes. The guide promises 15 minutes; "
"if the message ever says something else, the guide is telling a tester to wait wrongly.",
"claim.msg.password_too_short":
"section 13 -- the minimum-password answer. Quoted with its number filled in, so the guide "
"and claimMinPassword cannot disagree silently.",
"backup.target.degraded":
"section 9 -- the warning that says the backup does NOT survive a disk failure. It is a "
"promise about whether the tester's files are safe.",
}
# Keys whose English carries a printf verb: the guide quotes the rendered form. value -> rendered.
RENDER = {
"claim.msg.password_too_short": lambda v: v % 12,
}
def main() -> int:
if not os.path.exists(GUIDE):
print("guide-quote: INCONCLUSIVE -- no %s" % os.path.relpath(GUIDE, REPO), file=sys.stderr)
return 2
if not os.path.exists(EN_BUNDLE):
print(
"guide-quote: INCONCLUSIVE -- the felhom-controller clone is not beside this one "
"(looked for %s). Not a pass: this gate cannot see what the screen says." % EN_BUNDLE,
file=sys.stderr,
)
return 2
guide = io.open(GUIDE, encoding="utf-8").read()
bundle = json.load(io.open(EN_BUNDLE, encoding="utf-8"))
problems = []
for key, why in sorted(QUOTED.items()):
if key not in bundle:
problems.append("MISSING KEY %s\n en.json does not know it.\n %s" % (key, why))
continue
want = bundle[key]
if key in RENDER:
want = RENDER[key](want)
if want not in guide:
problems.append(
"NOT QUOTED %s\n"
" the screen says: %r\n"
" the guide does not contain that sentence.\n"
" %s" % (key, want, why)
)
print("guide-quote: %d quoted messages checked against the controller's English bundle" % len(QUOTED))
if problems:
print("\nguide-quote gate CONVICTS (%d):" % len(problems))
for p in problems:
print(" " + p)
print(
"\nThe English guide and the English dashboard disagree. Fix whichever is wrong --\n"
"but a tester follows the guide, so a guide that quotes a sentence nobody sees is the\n"
"more expensive of the two."
)
return 1
print("guide-quote gate OK: every quoted message is what the screen says.")
return 0
if __name__ == "__main__":
sys.exit(main())
+6
View File
@@ -17,6 +17,7 @@ Gates, in order (all must pass; **non-zero exit on any failure**):
7. golden-currency a released controller has a golden carrying it (R-242)
8. wire-contract every emitted field is decodable by its receiver (G-1)
9. hub-copy the hub's customer-facing words, against the retired-name list (R-324)
9b. guide-quote the English volunteer guide, against the controller's English bundle (R-596)
10. due-checks a dated check in OPEN-ITEMS.md that has come due (R-341)
11. one-register open work living outside OPEN-ITEMS.md (R-369)
12. closed-register a CLOSED row whose verdict still reads open, or an id in both (R-405)
@@ -125,6 +126,11 @@ GATES = [
# R-324 — the hub composes every customer e-mail and renders the binding pages, and until
# 2026-08-13 no guard in either repo had ever looked at them. Fast: pure file reads.
("hub-copy", os.path.join(SCRIPTS, "hub_copy_gate.py"), [], True, False),
# R-596/R-598 — the English guide QUOTES three dashboard messages that live in the controller's
# bundle, in another repo. Nothing bound them, so the guide quoted Hungarian for as long as the
# Hungarian shipped, and would have quoted it after the fix too. INCONCLUSIVE (exit 2, reported
# not swallowed) when the controller clone is absent. Fast: two file reads.
("guide-quote", os.path.join(SCRIPTS, "guide_quote_gate.py"), [], True, False),
# R-341 — dated checks in the register were prose that nothing read. Fast: stdlib file read.
("due-checks", os.path.join(SCRIPTS, "due_checks_gate.py"), [], True, False),
# R-369 — two files held open work and only one called itself the source of truth, so a READY
+25
View File
@@ -45,6 +45,12 @@ COVERS = {
"golden-currency": "R-410: an empty directory with a perfect name, checked by what it COUNTED",
"closed-register": "a verdict cell reading open, and a row with no state cell at all",
"decoy-coverage": "a gate registered in a runner with no decoy and no exemption (its red-proof)",
"guide-quote": ("R-596: SEVEN cases in scripts/test_guide_quote_gate.py, run from here so "
"this suite stays the single entry point. The load-bearing one is "
"name-for-fact: a guide that lists every key in a table and quotes none of "
"their sentences. Also: a prefix of the real sentence, the OLD Hungarian "
"quote, a reworded bundle, a deleted key, and the scope case — an absent "
"controller clone must be INCONCLUSIVE, never a pass"),
"hub-copy": ("R-558: an English retrieval promise in the NEW bundle (the sentences moved "
"out of templates.go, so the surface list had to move with them), the same "
"in Hungarian, and an INNOCENT control using the identical verbs without the "
@@ -286,6 +292,25 @@ elif "decoy-red-proof" not in _out:
else:
print(" ok %-20s a new gate with no decoy is convicted BY NAME" % "decoy-coverage")
# ── guide-quote (R-596) ──────────────────────────────────────────────────────────────────────────
#
# Its decoys build whole fake workspaces (a guide plus a sibling controller clone), which does not
# fit the plant/restore shape above — so they live in their own file and are RUN from here. The
# decoy-coverage gate reads COVERS in this file, so this is the seam that keeps that entry honest:
# if the separate suite stops passing, this one fails, and the COVERS line stops being a label.
ran += 1
_gq = subprocess.run([sys.executable, os.path.join("scripts", "test_guide_quote_gate.py")],
cwd=ROOT, capture_output=True, text=True)
if _gq.returncode == 2:
fails.append("guide-quote: its decoy suite could not run (no controller clone beside this one) — "
"INCONCLUSIVE is not coverage\n%s" % (_gq.stdout + _gq.stderr)[-500:])
elif _gq.returncode != 0:
fails.append("guide-quote: its decoy suite FAILED — a decoy did not convict\n%s"
% (_gq.stdout + _gq.stderr)[-800:])
else:
_n = _gq.stdout.strip().splitlines()[-1] if _gq.stdout.strip() else "?"
print(" ok %-20s %s" % ("guide-quote", _n))
print()
if fails:
for f in fails:
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Decoys for guide_quote_gate (R-421). Run: python3 scripts/test_guide_quote_gate.py
A decoy is the LABEL without the FACT. The shape this gate is most at risk of is
**name-for-fact**: matching the KEY NAME where the fact is the key's VALUE. A guide that lists
`claim.msg.bad_code` in a table of "messages covered" would satisfy a name-matching gate and tell a
tester nothing, because a tester reads sentences, not keys.
The second shape checked here is **declaration-for-reachability** in its sibling form: a gate that
reports OK when it could not read the bundle at all. Scope is a fact -- an absent controller clone
must be INCONCLUSIVE (2), never a pass.
Each case runs the real gate against a built tree and asserts the exit code.
"""
from __future__ import annotations
import io
import json
import os
import shutil
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
WORKSPACE = os.path.dirname(REPO)
REAL_BUNDLE = os.path.join(
WORKSPACE, "felhom-controller", "controller", "internal", "i18n", "locales", "en.json"
)
GATE_SRC = os.path.join(HERE, "guide_quote_gate.py")
def build(tmp, guide_text, bundle=None, with_bundle=True):
"""Lay out a fake workspace: <tmp>/felhom.eu/{scripts,documentation/...} + the sibling clone."""
repo = os.path.join(tmp, "felhom.eu")
scripts = os.path.join(repo, "scripts")
runbooks = os.path.join(repo, "documentation", "runbooks")
os.makedirs(scripts)
os.makedirs(runbooks)
shutil.copy(GATE_SRC, os.path.join(scripts, "guide_quote_gate.py"))
io.open(os.path.join(runbooks, "VOLUNTEER-first-hour.en.md"), "w", encoding="utf-8").write(guide_text)
if with_bundle:
locales = os.path.join(tmp, "felhom-controller", "controller", "internal", "i18n", "locales")
os.makedirs(locales)
if bundle is None:
bundle = json.load(io.open(REAL_BUNDLE, encoding="utf-8"))
io.open(os.path.join(locales, "en.json"), "w", encoding="utf-8").write(
json.dumps(bundle, ensure_ascii=False, indent=2)
)
return os.path.join(scripts, "guide_quote_gate.py")
def run(gate):
p = subprocess.run([sys.executable, gate], capture_output=True, text=True)
return p.returncode, p.stdout + p.stderr
def real_bundle():
return json.load(io.open(REAL_BUNDLE, encoding="utf-8"))
def honest_guide(b):
return (
"# guide\n\n"
"A wrong code answers **\"%s\"**, and after five tries **\"%s\"**.\n"
"A short password answers **\"%s\"**.\n\n> %s\n"
% (
b["claim.msg.bad_code"],
b["claim.msg.too_many"],
b["claim.msg.password_too_short"] % 12,
b["backup.target.degraded"],
)
)
CASES = []
def case(name):
def deco(fn):
CASES.append((name, fn))
return fn
return deco
@case("control: an honest guide passes")
def _control(tmp):
gate = build(tmp, honest_guide(real_bundle()))
rc, out = run(gate)
return rc == 0, "rc=%d\n%s" % (rc, out)
@case("DECOY name-for-fact: the guide NAMES every key and quotes none")
def _names(tmp):
text = (
"# guide\n\nMessages covered by this guide:\n\n"
"| key | section |\n|---|---|\n"
"| claim.msg.bad_code | 13 |\n"
"| claim.msg.too_many | 13 |\n"
"| claim.msg.password_too_short | 13 |\n"
"| backup.target.degraded | 9 |\n\n"
"All four messages are shown in English.\n"
)
gate = build(tmp, text)
rc, out = run(gate)
return rc == 1 and "NOT QUOTED" in out, "rc=%d\n%s" % (rc, out)
@case("DECOY substring: the guide quotes a PREFIX of the real sentence")
def _prefix(tmp):
b = real_bundle()
text = honest_guide(b).replace(b["backup.target.degraded"], b["backup.target.degraded"][:40])
gate = build(tmp, text)
rc, out = run(gate)
return rc == 1 and "backup.target.degraded" in out, "rc=%d\n%s" % (rc, out)
@case("DECOY the OLD Hungarian quote: the drill-era guide must convict")
def _hungarian(tmp):
b = real_bundle()
text = honest_guide(b).replace(b["claim.msg.bad_code"], "Hibás vagy lejárt kód")
gate = build(tmp, text)
rc, out = run(gate)
return rc == 1 and "claim.msg.bad_code" in out, "rc=%d\n%s" % (rc, out)
@case("DECOY reworded screen: the bundle changes and the guide does not")
def _reworded(tmp):
b = real_bundle()
text = honest_guide(b)
b2 = dict(b)
b2["claim.msg.too_many"] = "Too many attempts - try again later."
gate = build(tmp, text, bundle=b2)
rc, out = run(gate)
return rc == 1 and "claim.msg.too_many" in out, "rc=%d\n%s" % (rc, out)
@case("SCOPE: no controller clone is INCONCLUSIVE (2), never a pass")
def _noclone(tmp):
gate = build(tmp, honest_guide(real_bundle()), with_bundle=False)
rc, out = run(gate)
return rc == 2 and "INCONCLUSIVE" in out, "rc=%d\n%s" % (rc, out)
@case("SCOPE: a key deleted from the bundle convicts, it does not vanish")
def _deleted(tmp):
b = real_bundle()
text = honest_guide(b)
b2 = dict(b)
del b2["claim.msg.bad_code"]
gate = build(tmp, text, bundle=b2)
rc, out = run(gate)
return rc == 1 and "MISSING KEY" in out, "rc=%d\n%s" % (rc, out)
def main():
if not os.path.exists(REAL_BUNDLE):
print("SKIP: the felhom-controller clone is not beside this one", file=sys.stderr)
return 2
failed = 0
for name, fn in CASES:
tmp = tempfile.mkdtemp(prefix="gqg-")
try:
ok, detail = fn(tmp)
finally:
shutil.rmtree(tmp, ignore_errors=True)
print((" PASS " if ok else " FAIL ") + name)
if not ok:
failed += 1
print(" " + detail.replace("\n", "\n "))
print("\nguide-quote decoys: %d/%d" % (len(CASES) - failed, len(CASES)))
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())