i18n starter: inventory (script + audit), rows R-553..R-562, language allowlisted in wire-contract gate
gates / gates (push) Successful in 23s

Phase 0 of the localisation starter: i18n_inventory.py counts every customer-visible
Hungarian string; the audit names six further claims in the prompt that live source
disproved. Rows for the compare-not-show sites, wizard deletion, the wire-contract comment
blind spot, and localisation slices 1-6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-17 14:50:29 +02:00
parent 851198af7f
commit 5b7b2b22f1
7 changed files with 28760 additions and 1 deletions
@@ -0,0 +1,220 @@
# I18N INVENTORY — every Hungarian string a household meets, counted (2026-09-17)
**Run class:** spike, Phase 0 of the localisation starter. **Operator:** Claude Code on DooPlex.
**Baselines:** felhom-controller `89dd3e94b1de` (v0.246.0) · felhom-agent `d9864a94bf62` (v0.132.0) ·
felhom.eu `851198af7ff0` (hub v0.117.0) · app-catalog-felhom.eu `94bc5febaca2`.
**Instrument:** `scripts/i18n_inventory.py`, sha256
`be69b48d4d98ac47c0cec531cae63bee8ef009cacbbe2b5c45725cfb2fd40f0c`. Raw output and per-string JSON:
`audits/i18n-2026-09-17/inventory.md`, `inventory.json`.
**What was counted:** the controller at its baseline commit (a clean worktree of `origin/main`, so the
spike's own conversion is NOT in these numbers), the other three repos at their baselines.
**Design this feeds:** `architecture/10-localisation.md`.
---
## 0. Claims in the starter prompt that live source disproved
Named first, as the prompt asked. Four were already named in its §1; these are further ones.
1. **„`fmtTime`, `fmtBytes`, `timeAgo` — none of these exist. The FuncMap has one size helper,
`fmtMB`."** — **Wrong, and it is the claim that matters most for localisation.** All three exist
(`internal/web/funcmap.go` `timeAgo` L265, `fmtTime` L318, `fmtBytes` L403), and so do **nine more
helpers whose output is copy or locale-shaped**: `fmtGB`, `humanBytes`, `timeAgoStr`, `fmtTimeStr`,
`fmtTimeShort`, `fmtDuration`, `nextRunLabel` („ma"/„holnap"), `pruneLabel` („vasárnap"/„naponta"),
`nextPruneLabel`, `statusText`, and `stateLabel` (the app-state words). There are **four** size
helpers (`fmtMB`, `fmtGB`, `fmtBytes`, `humanBytes`), not one. `timeAgo` builds Hungarian with `%d`
(„%d perce") — the plural problem lives there.
2. **„`fmtMB`, used 10× in templates"** — **8×** (`grep -c` over templates, and the script agrees).
3. **„~13 `.Format(` calls in `internal/web/*.go` (`2006-01-02` ×6, `2006-01-02 15:04` ×4, `15:04` ×2,
`01-02 15:04` ×1)"** — at the baseline: `2006-01-02` **×4**, `2006-01-02 15:04` **×3**, `15:04` **×2**,
`01-02 15:04` ×1 = **10** layout literals, plus **8** `time.RFC3339` (machine, locale-free). The two
template layouts are as stated and do disagree (`2006. 01. 02. 15:04` vs `2006-01-02 15:04`).
4. **Template line count „1 613 (afternoon) / ~1 838 (morning)"** — **1 553**, by two independent methods
(this script, and `grep -cP` on the concatenated templates under `LC_ALL=C.UTF-8`). Neither prompt
figure was reproduced. Tried: adding typographic quotes „ ” (1 556), adding dashes and ellipsis
– — … (1 751), counting `style.css` too (1 559). The afternoon figure is **+60 (3.9 %)** and its method
is unknown; the morning figure is closest to the dash-inclusive variant. **Lines are the wrong unit
anyway** — see §1: the string count is 1 867.
5. **Go literal count „1 234 in 99 files (afternoon) / ~1 971 in 125 (morning)"** — **1 141 literals in
84 files** by a Go lexer (comments skipped), first-boot wizard excluded. A `grep` of quoted Hungarian
on non-comment lines gives 1 106 lines in 85 files, so the two methods agree. Counting lines that
hold an accented letter **including comments** gives 1 360 in 107 files; including the wizard and
`cmd/`, 1 375 in 108. The prompt's figures sit between the comment-free and comment-inclusive
counts; **comments explain most of the gap** (Hungarian quoted in Go comments is common here).
6. **Console banner „33 Hungarian lines"** — 33 by accented letter, **34** once ASCII-only Hungarian
words are counted. Not an error; a method difference, stated.
Confirmed as the prompt states: 36 page templates + `style.css`; 33 carry Hungarian; the second
template set and its one helper (`internal/setup/handlers.go` L79 `timeNow`); 8 wizard pages, **86**
Hungarian lines; `customerMessages` in the hub; 53 catalog files with `first_steps` under `app_info`;
no i18n library, no `Accept-Language`, no bundle anywhere.
---
## 1. Controls — the counts below mean something
Hungarian is matched by accented letter (the letters written as `\u` escapes, so the script itself is
ASCII) **or** by one of 46 ASCII-only Hungarian words („Fut", „Nincs", „Hiba", „Csak", „Megszakadt"…),
case-insensitive. Every surface prints a positive control (an ASCII fragment of a Hungarian word known
to be there, whose lines the matcher must flag) and a negative control (a fragment known absent; an
English sentence that must not be flagged).
| surface | positive fragment | lines holding it | flagged | negative hits | English line flagged | verdict |
|---|---|---|---|---|---|---|
| controller templates | `pult` | 20 | 20 | 0 | no | ok |
| controller Go | `ment` | 832 | 278 | 0 | no | ok |
| hub customer Go | `ment` | 35 | 18 | 0 | no | ok |
| installer banner | `Felhom` | 10 | 5 | 0 | no | ok |
| catalog | `Alkalmaz` | 5 | 5 | 0 | no | ok |
| guide | `Felhom` | 14 | 13 | 0 | no | ok |
**Measured limit of the ASCII word list.** The spike's conversion tool first used a 40-word list and
**missed eight Hungarian strings on the three pages** („Megszakadt:", „Elavult", „Csak x86",
„Pi kompatibilis", „Rendszermonitor", „Most nem", „sikertelen", „ismeretlen"); a by-eye review of what
was left found them, and they were added. **An ASCII-only Hungarian word that is not on the list is
still invisible to every count here.** The counts below are therefore floors, not totals.
---
## 2. The tables
### 2.1 Controller page templates (`internal/web/templates/`, 36 files)
**1 867 strings: 1 161 text runs + 88 attribute values + 618 inline-JS string literals.** 1 553 lines
carry an accented letter. A *run* is text up to the next block tag or control action — the unit a
translator needs.
| template | HU lines | text | attr | JS |
|---|---|---|---|---|
| deploy.html | 156 | 87 | 8 | 77 |
| storage.html | 110 | 43 | 9 | 98 |
| debug.html | 92 | 41 | 16 | 79 |
| layout.html (on every page) | 88 | 35 | 1 | 70 |
| backups_remote.html | 77 | 87 | 0 | 10 |
| settings_system.html | 84 | 58 | 0 | 36 |
| backups_apps.html | 80 | 79 | 7 | 6 |
| … 26 more | | | | |
Full per-file table: `audits/i18n-2026-09-17/inventory.md`. **One third of all template copy (618) is
inside `<script>`** — modal bodies and confirm questions built as JS strings, often with HTML inside
and with the sentence split around a `+ name +` concatenation. Most repeated: „Mentés" 10×, „Jelszó"
7×, „← Vissza" 7×, „Törlés", „Hiba", „Beállítás", „Mégsem", „Újraindítás", „Betöltés...", „Frissítés"
6× each.
### 2.2 Controller Go (non-test, wizard excluded)
**1 141 literals in 84 files.** Classified by the statement they sit in:
| kind | count | meaning |
|---|---|---|
| shown | 947 | assigned to page data, flash, alert, event text, struct fields |
| error | 184 | `fmt.Errorf` / `errors.New` — **reaches a page** wherever a handler prints `err.Error()` |
| compared | 7 | matched, not shown — §3 |
| log only | 3 | logs are English by convention; three are not |
Word-order hazards (non-log): **237** format strings with parameters, **111** concatenations, **42**
with a numeric `%d` (English needs a plural form; Hungarian does not inflect after a numeral).
Heaviest files: `web/handlers.go` 115, `cloudflare/countries.go` 113 (country names for the geo
filter), `web/offbox_handlers.go` 70, `web/storage_handlers.go` 45, `web/handler_debug.go` 39,
`backup/offbox_reconstitute.go` 38, `notify/notifier.go` 31. Most repeated: „érvénytelen kérés" 13×,
„A távoli mentési cél nincs beállítva." 9×, „Rendszermonitor" 7×.
**A classification caveat, measured:** a flash message travels to the page **inside the redirect URL**
(`?flash=<Hungarian text>`, e.g. the launcher's `ShareFlash`), so it is rendered in whatever language
the handler that redirected spoke. Slice 2 has to move these to keys, not only translate them.
### 2.3 Hub → customer
**65 literals in 4 files**: `notify/templates.go` 57 (the 39-entry `customerMessages` map, the 4
`severityLabels`, the body wrapper, and the claim / re-enroll / reset / claimed / self-bind mails),
`notify/dispatcher.go` 4, `web/customer_reset.go` 2, `web/selfbind.go` 2 (plus the public bind page's
19 Hungarian lines). 8 are multi-line raw blocks. One message (`app_start_failed`) is in the formal
„ön" register. For scale and **out of scope** (operator pages): all hub Go 83 literals; operator
templates 64 strings.
### 2.4 Installer
Console banner `scripts/iso/felhom-bootstrap.sh`: **34** lines, printed with `printf` blocks to the
console device (pairing, bound, `/etc/issue`, no-network screens). The `/etc/issue` text must stay
byte-identical to `iso/pkg/debian/postinst` and avoid ő/ű (the console font) — a constraint English
does not share but a second language on the same screen does. Download page `website/letoltes.html`:
**32** strings.
### 2.5 Catalog
**835 strings, ~4 984 words, in all 53 `.felhom.yml`.** No multi-language structure exists.
| field | strings |
|---|---|
| `app_info.use_cases[]` | 262 |
| `app_info.first_steps[]` | 233 |
| `deploy_fields[].description` | 79 |
| `deploy_fields[].label` | 68 |
| `app_info.prerequisites[]` | 60 |
| `description` | 56 |
| `app_info.tagline` | 53 |
| other (`label`, `help_text`, `default_creds`, `note`, `group`) | 24 |
The controller parses these with a plain `yaml.Unmarshal` that ignores unknown keys
(`internal/stacks/metadata.go` L317), so a sibling key per language is additive.
### 2.6 Guide
`runbooks/VOLUNTEER-first-hour.md`: 182 lines, **44 paragraphs (42 Hungarian), ~1 579 words.**
### 2.7 First-boot wizard — reported separately, never merged
`internal/setup/templates/` (8 pages): 86 Hungarian lines; **95 strings** (81 text, 12 attr, 2 JS).
Go: 16 literals. **When a household can still see it** (read from source): the controller runs
`bootstrap.MaybeIngest` (`cmd/controller/main.go` L314) and then `setup.NeedsSetup(cfg)` (L322), which
is true when `customer.id` is still empty **or** a `.needs-setup` marker exists in the data dir
(`internal/setup/setup.go` L17-25). Ingestion leaves `customer.id` empty — and the wizard therefore
starts — when `bootstrap.json` is missing, unreadable, not JSON, not schema `felhom.bootstrap/v2`, or
lacks `customer.id` / `hub.url` / `hub.retrieval_password` / the `local_api` block; when the hub pull
fails (auth and not-found at once; unreachable after retries at 2/4/8 s); or when the merge, the
atomic write or the reload fails (`internal/bootstrap/bootstrap.go` L109-163). **Operator decision
2026-09-17: out of scope, obsolete — a separate row to delete it.**
### 2.8 Dates, numbers, sizes
| where | layout | sites |
|---|---|---|
| internal/web Go | `2006-01-02` | 4 |
| internal/web Go | `2006-01-02 15:04` | 3 |
| internal/web Go | `15:04` | 2 |
| internal/web Go | `01-02 15:04` | 1 |
| template | `2006. 01. 02. 15:04` (Hungarian) | 1 |
| template | `2006-01-02 15:04` (ISO) | 1 |
Plus 25 `.Format("` calls elsewhere in the controller, and the helpers in §0.1. Sizes print `GB`/`MB`
with a **decimal point** (`%.1f`) — Hungarian convention is a decimal comma, so the Hungarian pages are
already un-Hungarian here; that is not a localisation regression and the spike does not change it.
---
## 3. Compared, not shown — each is a latent bug
A string matched by its Hungarian wording breaks the moment the wording is translated **or reworded**.
| where | what is matched | consequence if the text changes |
|---|---|---|
| `internal/api/router.go:478` | a deploy error contains „kötelező" or „memória" | a validation failure becomes **500 instead of 400** |
| `internal/backup/offbox.go:186` | a restic error contains „tárhelykeretet" | the off-site **quota** failure is no longer classified as quota |
| `internal/web/alerts.go:208` | a health warning contains „meghajtón" / „adattároló" / „meghajtó" | a disk warning moves from its inline place to the **top banner of every page** |
| `internal/web/handlers.go:927` via `offboxStaleWarningMarker` („nincs mentésre jelölt alkalmazás") | a **persisted** warning string | the „selection changed" line is no longer substituted — and a warning written in one language is compared in another |
| `internal/web/offbox_handlers.go:132` | — | **false positive** of the detector (`!= ""` belongs to `stageErr`, not the literal) |
Templates: **0** compares against Hungarian (a detector decoy with `eq .State "Leállítva"` was flagged,
so the zero is measured). JS: no `===` / `indexOf` / `includes` against a Hungarian literal.
---
## 4. What this inventory does not see
- ASCII-only Hungarian words outside the 46-word list (§1).
- Hungarian arriving at run time from outside the repos: app containers' own UIs, agent error strings
relayed verbatim, hub-pushed text.
- Whether a `shown` Go literal actually reaches a page (classification is by statement, not by data
flow) and whether an `error` literal is printed or swallowed.
- The **formal „ön" register** inside Hungarian copy (R-516) — counted only on the converted pages, by
the new `i18n_missing_gate.py` (6 occurrences).
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,176 @@
script sha256: be69b48d4d98ac47c0cec531cae63bee8ef009cacbbe2b5c45725cfb2fd40f0c
## Controls (ASCII fragment, positive + negative)
| surface | positive fragment | lines holding it | of those flagged | negative fragment hits | ASCII English line flagged | verdict |
|---|---|---|---|---|---|---|
| controller templates | `pult` | 20 | 20 | 0 | False | ok |
| controller Go | `ment` | 832 | 278 | 0 | False | ok |
| hub customer Go | `ment` | 35 | 18 | 0 | False | ok |
| installer banner | `Felhom` | 10 | 5 | 0 | False | ok |
| catalog | `Alkalmaz` | 5 | 5 | 0 | False | ok |
| guide | `Felhom` | 14 | 13 | 0 | False | ok |
## Controller page templates
36 files; 33 carry Hungarian. Lines with an accented letter: **1553**. Strings: **1161 text runs + 88 attribute values + 618 inline-JS literals = 1867**. Template compares against Hungarian: 0. Text runs with a numeric parameter: 7.
| template | HU lines | text | attr | JS | compare |
|---|---|---|---|---|---|
| deploy.html | 156 | 91 | 8 | 77 | 0 |
| storage.html | 110 | 43 | 9 | 105 | 0 |
| debug.html | 92 | 43 | 18 | 84 | 0 |
| layout.html | 88 | 38 | 1 | 70 | 0 |
| backups_remote.html | 77 | 87 | 0 | 10 | 0 |
| settings_system.html | 84 | 58 | 0 | 36 | 0 |
| backups_apps.html | 80 | 80 | 7 | 6 | 0 |
| backups_restore.html | 76 | 50 | 2 | 30 | 0 |
| sharing.html | 68 | 70 | 0 | 8 | 0 |
| recovery.html | 52 | 61 | 1 | 5 | 0 |
| storage_network.html | 66 | 38 | 6 | 20 | 0 |
| backups.html | 47 | 46 | 1 | 13 | 0 |
| backups_escrow.html | 56 | 31 | 0 | 29 | 0 |
| app_info.html | 45 | 37 | 0 | 20 | 0 |
| settings_security.html | 53 | 28 | 4 | 25 | 0 |
| monitoring.html | 48 | 43 | 0 | 13 | 0 |
| dashboard.html | 35 | 40 | 9 | 0 | 0 |
| backups_restore_wizard.html | 40 | 41 | 0 | 6 | 0 |
| storage_init.html | 33 | 18 | 1 | 25 | 0 |
| app_import.html | 36 | 31 | 1 | 9 | 0 |
| stacks.html | 29 | 29 | 7 | 0 | 0 |
| settings_notifications.html | 35 | 34 | 1 | 0 | 0 |
| storage_attach.html | 21 | 17 | 1 | 12 | 0 |
| app_export.html | 24 | 19 | 2 | 8 | 0 |
| launcher.html | 25 | 21 | 4 | 3 | 0 |
| tier2_config.html | 26 | 26 | 0 | 0 | 0 |
| claim.html | 19 | 16 | 3 | 0 | 0 |
| logs.html | 8 | 5 | 0 | 4 | 0 |
| login.html | 7 | 6 | 1 | 0 | 0 |
| launcher_share_password.html | 5 | 4 | 1 | 0 | 0 |
| backups_shared.html | 6 | 4 | 0 | 0 | 0 |
| catchall.html | 3 | 3 | 0 | 0 | 0 |
| launcher_shared.html | 3 | 3 | 0 | 0 | 0 |
| app_row.html | 0 | 0 | 0 | 0 | 0 |
| icons.html | 0 | 0 | 0 | 0 | 0 |
| meta_badge.html | 0 | 0 | 0 | 0 | 0 |
Most repeated (text + attr):
- 10x `Mentés`
- 7x `Jelszó`
- 7x `← Vissza`
- 6x `Törlés`
- 6x `Hiba`
- 6x `Beállítás`
- 6x `Mégsem`
- 6x `Újraindítás`
- 6x `Betöltés...`
- 6x `Frissítés`
## Controller Go (non-test, first-boot wizard excluded)
**1141 literals in 84 files.** shown: 947, error (may reach a page): 184, log only: 3, compared: 7. Format strings with parameters (non-log): 237; numeric %d (non-log, plural risk): 42; concatenated (non-log): 111; raw multi-line blocks: 1.
| file (top 15, non-log) | literals |
|---|---|
| felhom-controller/controller/internal/web/handlers.go | 115 |
| felhom-controller/controller/internal/cloudflare/countries.go | 113 |
| felhom-controller/controller/internal/web/offbox_handlers.go | 70 |
| felhom-controller/controller/internal/web/storage_handlers.go | 45 |
| felhom-controller/controller/internal/web/handler_debug.go | 39 |
| felhom-controller/controller/internal/backup/offbox_reconstitute.go | 38 |
| felhom-controller/controller/internal/notify/notifier.go | 31 |
| felhom-controller/controller/internal/backup/update_guard.go | 28 |
| felhom-controller/controller/internal/stacks/migrate.go | 28 |
| felhom-controller/controller/internal/stacks/update.go | 25 |
| felhom-controller/controller/internal/appexport/restore.go | 23 |
| felhom-controller/controller/internal/web/alerts.go | 22 |
| felhom-controller/controller/internal/web/share_handlers.go | 22 |
| felhom-controller/controller/internal/appexport/export.go | 21 |
| felhom-controller/controller/internal/api/router.go | 20 |
Most repeated (non-log):
- 13x `érvénytelen kérés`
- 9x `A távoli mentési cél nincs beállítva.`
- 7x `Rendszermonitor`
- 6x `Nem bekötött`
- 6x `érvénytelen csatlakoztatási pont`
- 5x `Beállítások`
- 5x `Hiányzó alkalmazás.`
- 4x `Belső hiba a jelszó mentésekor`
- 4x `Az ügynök nem elérhető.`
- 3x `egy másik mentési/visszaállítási művelet már fut`
### Compared, not shown (controller Go)
- felhom-controller/controller/internal/api/router.go:478 `kötelező`
- felhom-controller/controller/internal/api/router.go:478 `memória`
- felhom-controller/controller/internal/backup/offbox.go:186 `tárhelykeretet`
- felhom-controller/controller/internal/web/alerts.go:208 `meghajtón`
- felhom-controller/controller/internal/web/alerts.go:208 `adattároló`
- felhom-controller/controller/internal/web/alerts.go:208 `meghajtó`
- felhom-controller/controller/internal/web/offbox_handlers.go:132 `A távoli mentési cél elmentve.`
- felhom-controller/controller/internal/web/handlers.go:927 via `offboxStaleWarningMarker` (defined felhom-controller/controller/internal/web/handlers.go) `nincs mentésre jelölt alkalmazás`
### Compared, not shown (templates)
## Hub customer surfaces (notify, self-bind, reset, claim)
**65 literals in 4 files.** shown: 65, error (may reach a page): 0, log only: 0, compared: 0. Format strings with parameters (non-log): 10; numeric %d (non-log, plural risk): 0; concatenated (non-log): 0; raw multi-line blocks: 8.
| file (top 15, non-log) | literals |
|---|---|
| felhom.eu/hub/internal/notify/templates.go | 57 |
| felhom.eu/hub/internal/notify/dispatcher.go | 4 |
| felhom.eu/hub/internal/web/customer_reset.go | 2 |
| felhom.eu/hub/internal/web/selfbind.go | 2 |
Most repeated (non-log):
- 2x `Teszt értesítés`
For scale, NOT in scope: all hub Go non-test = 83 literals (shown 83, error 0, log 0, compare 0); hub operator templates = 64 strings, 52 HU lines.
## Installer
Console banner `felhom.eu/scripts/iso/felhom-bootstrap.sh`: **34** Hungarian lines.
Download page `felhom.eu/website/letoltes.html`: 24 text + 6 attr + 2 JS = **32** strings, 27 HU lines.
## Catalog
53 `.felhom.yml`; 53 carry Hungarian; **835 strings, ~4984 words**.
| field | strings |
|---|---|
| app_info.use_cases[] | 262 |
| app_info.first_steps[] | 233 |
| deploy_fields[].description | 79 |
| deploy_fields[].label | 68 |
| app_info.prerequisites[] | 60 |
| description | 56 |
| app_info.tagline | 53 |
| label | 11 |
| help_text | 6 |
| app_info.default_creds | 5 |
| note | 1 |
| group | 1 |
## Guide
`felhom.eu/documentation/runbooks/VOLUNTEER-first-hour.md`: 44 paragraphs (42 Hungarian), ~1579 words.
## First-boot wizard (reported separately -- never merged into the dashboard count)
8 templates: 86 HU lines; 81 text + 12 attr + 2 JS = **95** strings. Go: 16 literals (shown 16, error 0, log 0, compare 0).
## Dates and sizes
| where | layout | sites |
|---|---|---|
| internal/web Go | `2006-01-02` | 4 |
| internal/web Go | `2006-01-02 15:04` | 3 |
| internal/web Go | `15:04` | 2 |
| internal/web Go | `01-02 15:04` | 1 |
| template | `2006. 01. 02. 15:04` | 1 |
| template | `2006-01-02 15:04` | 1 |
`fmtMB` sites: 8 in templates, 1 in Go. `.Format("` calls in the controller outside internal/web: 25.
+11 -1
View File
@@ -711,7 +711,7 @@ class (an image `VOLUME` at an unmounted path) is still live — `immich-server`
| **R-508** | **[P2-MEDIUM] Customer `tester-1` has no registered e-mail, so neither the self-bind link nor the setup code can reach a volunteer.** MEASURED 2026-09-14: the edit form's `email` value is empty; on bind the hub logged `[ERROR] [claim] claim code generated (gen 1) but customer tester-1 has NO registered email — deliver via resend after setting one`. A volunteer onboarded on this record would sit at „A szerver beállítása" with no code. **What it needs:** the operator sets the volunteer's address on the record before sending the guide (day-0 A.2). The hub's customer page could warn when a record with an unclaimed box has no e-mail — the log line exists, the page says nothing. | **WAITING-ON-OPERATOR — rank P2-MEDIUM; owner: operator (record), CC (page warning)** |
| **R-509** | **[P1-HIGH] A box installed for an EXISTING customer never gets the self-bind e-mail the console tells the volunteer to open.** MEASURED 2026-09-14 (BIGNIGHT, VM 333, ISO 1.27.1): customer `tester-1` now has `tester1@felhom.eu` registered; the box registered as appliance 28 at 17:44:53Z and its console says „Nyisd meg az e-mailben kapott linket"; **ten minutes later the mailbox (read through the Gmail connector) held 0 messages to that address.** Cause, from source: the hub auto-sends the link only at customer creation (`hub/internal/web/configs.go:725`) and at RESET completion (`customer_reset.go:162`); a customer whose e-mail was added later, or whose previous box was destroyed, never receives one unless the operator presses „Send self-bind link". The volunteer guide's operator prerequisites do not list that press. Intervention **I1** of the big night (the operator's button pressed). **Fix shape (for the operator to choose):** send the link when an unclaimed appliance registers and a customer with no host is waiting, or add the press to the guide's operator prerequisites (day-0 A.2). **SHIPPED hub v0.114.0 (2026-09-15), NOT YET PROVEN BY A REAL MAIL:** triggers added — e-mail set/changed on a customer with no box, and host delete — each re-checking no bound host; every send recorded as `selfbind_link_sent` and shown on the Setup tab. Unit-proven with a red-proof (`TestSelfBind_EmailSetOnWaitingCustomerSendsLink`). The live check with the Gmail-read mailbox was NOT run: it needs a throwaway customer, and deleting one runs the RESET cascade (ep0 `deprovision` + Cloudflare), which is fenced without the operator's word. **Closes on:** one real mail from either trigger. | **READY — rank P1-HIGH; owner: CC (hub fix) · operator (which fix shape)** |
| **R-511** | **[P2-MEDIUM] A customer whose box is rebuilt keeps its ep0 PBS token, and then the DR tier can be neither provisioned nor re-issued: the hub's error advises the one action that refuses.** MEASURED 2026-09-14 (BIGNIGHT, VM 333, `tester-1`, DR tier ticked): on the new box's WireGuard registration the hub logged `[ERROR] pbsdr auto-provision for tester-1 (WG-registration hook): the endpoint already holds a PBS token for tester-1 but the hub has no descriptor — use the explicit "Re-issue PBS credentials" action — save the customer config to retry`. The operator's `POST /configs/tester-1/pbsdr-reissue` → **400 `No provisioned PBS DR tier for this customer`** (`hub/internal/web/pbsdr.go` ~411). The token was left by the doorstep walk's host delete (a host delete does not deprovision tenancy; only RESET does, which also removes the tunnel). So a box rebuilt for an existing customer — the reinstall journey — has no whole-guest off-site tier and no button that restores it. **Fix shape:** let re-issue adopt an existing endpoint token when the descriptor is absent (the message already assumes it does), or have host delete offer to drop the PBS token. **SHIPPED hub v0.114.0 (2026-09-15):** re-issue ADOPTS the endpoint token when the descriptor is absent and the DR flag is on (re-key + descriptor rebuilt from the endpoint + `pbsdr_adopted` audit row); DR flag off still refuses, endpoint untouched (both pinned, red-proofed). **NOT proven on tester-1's real state:** re-issue needs an enrolled host and tester-1 has none. **ep0 cleanup DONE on the operator's yes (2026-09-15 08:33Z):** the one doorstep snapshot `ns tester-1 / ct/9201/2026-09-14T16:04:53Z` (logical 1 928 820 672 B) forgotten via the local PBS API; namespace lists empty; token `felhom@pbs!tester-1` kept; nothing outside tester-1 read or touched (`D3-*` in `audits/evidence-p1fixes-2026-09-15/`). The token-only release on host delete was NOT built → R-526. **Closes on:** adopt ending in a descriptor the next tester-1 box consumes **CONSEQUENCE MEASURED 2026-09-16:** the code fix shipped for this row is sound and currently INERT - the adopt path runs and the ENDPOINT refuses it for a missing `Datastore.Modify` grant (**R-534**). On the drill box the result is that a fresh install has NO off-site tier at all, and read-only listing of ep0 shows `ns/tester-1/ct` empty before and after the whole drill. This row stays open until R-534's grant is given and a re-issue is seen to succeed. **CLOSED 2026-09-16 — the adopt path is PROVEN LIVE.** The rebuilt-customer case reproduced by itself on a fresh box (the WG-registration hook refused exactly as this row describes and named the Re-issue action), and the adopt then completed: reissue ok → pbsdr ADOPTED (gen 2) → the box consumed the single-use secret two seconds later. The code shipped 2026-09-15 was sound; what made it inert was the ep0 grant (R-534), now given. | **CLOSED 2026-09-16 — proven live on a fresh box** |
| **R-516** | **[P3-LOW] English a customer meets on a fresh box and its apps' first screens — enumerated by the big night.** MEASURED 2026-09-14 (BIGNIGHT, VM 333, ISO 1.27.1, controller 0.242.0). Felhom-owned: (1) the dashboard menu item **„Debug"**; (2) the dashboard CPU tile **„Load: 0.29 / 0.39 / 0.37"**; (3) the launcher tile **„Filebrowser"** opens a login in English with no Felhom text (R-513); (4) the storage page mixes formal „Adjon hozzá / Csatlakoztasson" with the product's „te". App first screens a household meets before any Felhom text helps: (5) **Uptime Kuma 2.4 opens on „Which database would you like to use?"** (SQLite / Embedded MariaDB, „Next") — the app card's „Első lépések" does not mention it; (6) PrivateBin, Gokapi, AdventureLog and FileBrowser UIs are English (the apps' own). Already rows: the Proxmox installer screens (R-495, answered by the guide), `wiki.DOMAIN` (R-498). **Fix shape:** rename „Debug"/„Load" (controller); add the Uptime Kuma database step to its card, or pre-seed `db-config.json` for SQLite in the template (catalog). **Added by F4 (20:04:54Z):** (7) the storage page prints the disconnect time as a raw ISO UTC string „Leválasztva: 2026-09-14T19:58:02Z"; (8) the „Meghajtó leválasztva" banner appears twice on every page; (9) „4 telepített alkalmazás nem fut — nézze meg a rendszermonitort" uses the formal form. **Added by F7 (20:50–21:00Z, system disk at 95 %):** (10) a banner on every page in English, „**SSD disk usage high: 90%**"; (11) the dashboard tile reads „Rendszer (/) 61.8 GB / 68.7 GB (**90%**)" while `df` reports **95 %** (reserved blocks ignored), and „(/)" labels the data volume `/mnt/sys_drive`; the deploy page says nothing about free disk. | **READY — rank P3-LOW; owner: CC (controller + catalog)** |
| **R-516** | **[P3-LOW] English a customer meets on a fresh box and its apps' first screens — enumerated by the big night.** MEASURED 2026-09-14 (BIGNIGHT, VM 333, ISO 1.27.1, controller 0.242.0). Felhom-owned: (1) the dashboard menu item **„Debug"**; (2) the dashboard CPU tile **„Load: 0.29 / 0.39 / 0.37"**; (3) the launcher tile **„Filebrowser"** opens a login in English with no Felhom text (R-513); (4) the storage page mixes formal „Adjon hozzá / Csatlakoztasson" with the product's „te". App first screens a household meets before any Felhom text helps: (5) **Uptime Kuma 2.4 opens on „Which database would you like to use?"** (SQLite / Embedded MariaDB, „Next") — the app card's „Első lépések" does not mention it; (6) PrivateBin, Gokapi, AdventureLog and FileBrowser UIs are English (the apps' own). Already rows: the Proxmox installer screens (R-495, answered by the guide), `wiki.DOMAIN` (R-498). **Fix shape:** rename „Debug"/„Load" (controller); add the Uptime Kuma database step to its card, or pre-seed `db-config.json` for SQLite in the template (catalog). **Added by F4 (20:04:54Z):** (7) the storage page prints the disconnect time as a raw ISO UTC string „Leválasztva: 2026-09-14T19:58:02Z"; (8) the „Meghajtó leválasztva" banner appears twice on every page; (9) „4 telepített alkalmazás nem fut — nézze meg a rendszermonitort" uses the formal form. **Added by F7 (20:50–21:00Z, system disk at 95 %):** (10) a banner on every page in English, „**SSD disk usage high: 90%**"; (11) the dashboard tile reads „Rendszer (/) 61.8 GB / 68.7 GB (**90%**)" while `df` reports **95 %** (reserved blocks ignored), and „(/)" labels the data volume `/mnt/sys_drive`; the deploy page says nothing about free disk. **Added by the i18n spike (2026-09-17, controller v0.247.0):** (12) **six formal („ön") forms in the converted dashboard copy** — „Olvassa be telefonnal" (launcher QR hint), „Biztosan kikapcsolja a megosztást?" (launcher), „Ha újratelepíti … importálnia kell" (the layout's remove-app modal), „Kérjük, vegye fel a kapcsolatot" (backups empty state). They were NOT fixed — a localisation release may not change Hungarian bytes — and are now COUNTED by `controller/scripts/i18n_missing_gate.py` (`HU_FORMAL_CEILING` = 6, a ratchet: a new one convicts, fixing one lowers it). The inventory (`audits/I18N-INVENTORY-2026-09-17.md`) is the list this row closes against in localisation slice 6 (R-561). | **READY — rank P3-LOW; owner: CC (controller + catalog)** |
| **R-518** | **[P2-MEDIUM] „Mentés most" on the whole-system backup stops every app for about eight minutes while the page promises „csak néhány másodpercre".** MEASURED 2026-09-14 (BIGNIGHT, VM 333, 12 apps): the button's call quiesced all 12 stacks at 19:03:23Z (first stopped 19:03:27Z); the local vzdump ran 19:03:49 → 19:09:59Z; the controller then kept the apps stopped for the second (PBS) tier and restarted them at 19:10:09Z after it failed, the last started 19:11:12Z (`phase4/guest-backup-quiesce-log.txt`) — **≈ 7 m 45 s** with every app answering 404. The page under the button: „Pillanatkép-mód: az alkalmazások csak néhány másodpercre állnak le." A household pressing it at dinner loses every app for the length of the dump, and longer on a bigger box. **Fix shape:** state the real expected downtime (it scales with data), or quiesce per tier and not across a second tier's attempt; do not start a tier whose storage is absent (see R-517). **NARROWED 2026-09-15 (controller v0.243.0 + agent v0.131.0):** a tier whose storage the agent reports absent is skipped before anything stops (`backup_tier_skipped`, once per absence; unknown never skipped), and the button copy now says „általában néhány perc, nagyobb adatnál több". Unit-proven with red-proofs. **Still open:** quiesce per tier, so a slow second tier does not keep every app down. | **READY — rank P2-MEDIUM; owner: CC (controller)** |
| **R-519** | **[P2-MEDIUM] After a backup torn by a power cut, an app's restore point carries the new database dump's time while its files are from the previous run — and no customer screen says the run was interrupted.** MEASURED 2026-09-14 (BIGNIGHT F2, VM 333): „Mentés most" 19:40:03Z; the power was cut 19:40:08Z while adventurelog was stopped for its volume dump. On disk afterwards, `backups/primary/adventurelog`: `db-dumps/adventurelog-postgres.sql` **19:40:07**, `volume-dumps/*.tar` **19:00:35**, `manifest.json created_at 19:02:34Z`; bookstack the same shape (sql 19:40:07, tars 19:00:48). `GET /api/backup/snapshots` for both → `time 2026-09-14T19:40:07Z, helyi` (`phase5/F2/units-on-disk.txt`, `backup-honesty.txt`). `/backups/apps` shows „Utolsó adatbázis mentés 2026-09-14 21:40 · … OK" and every app „Utolsó: 5 perce"; `/backups` and `/dashboard` contain no word of an interruption (fragments `megszakad|sikertelen|nem sikerült` = 0; control „hiba" appears in the standing warning text). The controller itself knew: `[appstop] crash recovery: an app-data backup (volume dump) … was interrupted … restarting them: [adventurelog]` and pushed `backup_failed (error)` to the hub. A household restoring „the 21:40 backup" gets 21:00 files for BookStack's uploads. **Fix shape:** date a point by the oldest part it contains (or mark it partial) and show the interrupted run on the backups page until the next complete one. **F6 (drive unplugged 1 s into a backup, 20:37:59Z) adds three facts:** the run skipped four apps' volume dumps („Skipping volume dump for immich — drive disconnected", also jellyfin, nextcloud, paperless-ngx) and still reported `db_dump {"count":6, "success":true}`; nextcloud's point is dated 20:37:59Z (its SQL finished before the unplug) beside 19:01 volume tars; and a torn `immich-postgres.sql.tmp` (20:38:00) plus F3's `pre-restore-…-nextcloud-mariadb.sql.tmp` are left in the units on the drive. Immich's point correctly stayed at 19:02:34Z (the `.tmp` was not promoted). `/backups/apps` fragments `kihagy|sikertelen|részleges` = 0 (`phase5/F6/`). | **READY — rank P2-MEDIUM; owner: CC (controller)** |
| **R-520** | **[P3-LOW] A power cut during a guarded Update leaves no record that an update was running, so whether the journal resumes or aborts honestly is unmeasured for a real version change.** MEASURED 2026-09-14 (BIGNIGHT F3, VM 333): the only Update the catalog allowed after the Phase 4 revert was a same-version one on nextcloud; power was cut 2 s after `update nextcloud: phase pulling` (safety dump written, pin advanced to the unchanged definition). After boot: no log line resumes, aborts or names the interrupted update; `app.yaml` `pinned_images` = `installed_images`, no hold, no verdict; page „Fut · Naprakész". Nothing wrong was produced — and nothing could have been, with identical images. **What it needs:** the same cut on a real bump (a throwaway app with a one-step catalog move on a scratch branch or the scratch guest), asserting the page's message and the pin after boot. | **READY — rank P3-LOW; owner: CC (drill)** |
@@ -731,6 +731,16 @@ class (an image `VOLUME` at an unmounted path) is still live — `immich-server`
| **R-548** | **[P3-LOW] The whole-guest backup’s LOCAL tier cannot fit on a small-system-disk box, and will retry on that tier for ever.** MEASURED 2026-09-17 (chaos night) on `tester-1-022354`: a whole-guest backup wrote a **~29 GB source** (`mp0` = `local-lvm:vm-9201-disk-1`, 70 G provisioned, 40.58 % used, `backup=1`) into `pve-root`, which on a 32 GB system disk is **14 GB total with ~4.9 GB free**. Two samples thirty seconds apart showed the archive growing ~497 MB while free space fell ~475 MB — **~16 MB/s, i.e. under four minutes to a full `/`** on the nested PVE. **The product’s behaviour is correct and legible throughout:** it failed the tier and said which one — `whole_guest_backup_failed` (error, operator-only): „Whole-guest backup FAILED on the local tier — retrying with backoff (next attempt in 15m0s)” — its status surface agreed (`target_id:"local"`, `success:false`, `size_bytes:0`), and the **off-site tier then ran from the same snapshot and succeeded in ~8½ minutes, encrypted to ep0, consuming no local disk at all**. So the data still left the house. What is filed is the loop: on a box shaped like this the local tier can **never** succeed, and it keeps retrying on a backoff for ever, burning I/O and risking `/` each time. **Honest caveat:** the 32 GB system disk is this drill’s fixture choice, so the row is conditional on disk size — but nothing in the product checks whether the local target could ever hold the source before trying. **Fix shape:** compare the source size against the target’s free space before starting the local tier and skip it with a clear reason, rather than discovering it at ~16 MB/s. Evidence: `audits/evidence-chaos-night-2026-09-17/round-6.txt`. | **READY — rank P3-LOW; owner: CC** |
| **R-551** | **[P3-LOW] No Tier-0 box can put the escrow ceremony in the state R-546 fixes — paused AND connected to its agent — so the readiness branches are proven only by tests.** FOUND 2026-09-17 while live-validating controller v0.246.0 (R-546). The branches (reminder bar held back while the agent's preflight is not `ok`; the waiting card on `/backup/escrow`; `POST /api/escrow/start` refused 409 before staging) need a box whose off-site tier is configured, whose escrow is NOT done, and whose controller reaches the agent. Measured on demo-hp: **9201** reaches the agent but is escrowed (the bar is off by design there; making it paused would be hand-set state on the standing demo box, and a real ceremony would supersede its live escrow — the hub keeps ONE `host_escrow` row per host, `host_id PRIMARY KEY`); **9202** is paused-capable but has **no local-API token at all** (its `bootstrap.json` holds only `schema`, `customer.id`, `disposition`), so its readiness is always UNKNOWN and the bar always shows. The fresh-bind window where this state occurs naturally (~17 min, chaos night Phase 0) needs a fresh install. **What IS proven:** five tests driving the real pages and handler through `ServeHTTP` with a fake agent, each red-proofed; and chaos night measured live that the agent's preflight is red for ~17 minutes after a bind and turns green by itself (`evidence-chaos-night-2026-09-17/phase0-escrow-*`). **Fix shape:** give the scratch guest a local-API token through the agent's own provisioning path, or walk R-546 on the next fresh install. | **READY - rank P3-LOW; owner: CC** |
| **R-552** | **[P3-LOW] An interrupted-restore notice for an app that is then REMOVED stays on the restore page for ever.** FOUND 2026-09-17 by CC reviewing its own controller v0.246.0 (R-550) during live validation. The per-app notice (`Manager.opInterrupted`, persisted in `restore-status.json`) is cleared in exactly one place — `BeginRestoreOp` for that app (`internal/backup/opstatus.go`) — and `removeStack` (`internal/api/router.go`) never touches the restore record. So a household that answers „A visszaállítás megszakadt … indítsd el újra" by REMOVING the app instead of restoring it keeps a „Megszakadt visszaállítás" card about an app that no longer exists. Measured shape, not hypothetical: on 9201 the notice cleared only when homebox was restored again (08:54:50Z, card count 0) — the teardown deliberately took that path before removing it. **Fix shape:** `removeStack` clears the app's notice (a `ClearInterruptedRestore(stack)` beside the existing update-hold clear, R-491's precedent), with a wiring test. Not fixed in v0.246.0: found after the release was built; one release per repo per session. | **READY - rank P3-LOW; owner: CC** |
| **R-553** | **[P3-LOW] Four places decide BEHAVIOUR by matching Hungarian wording — each breaks the day the words change, in either language.** FOUND 2026-09-17 by the i18n inventory (`audits/I18N-INVENTORY-2026-09-17.md` §3), read at source: (1) `controller/internal/api/router.go:478` picks **400 vs 500** for a deploy error by `strings.Contains(err, "kötelező"/"memória")`; (2) `internal/backup/offbox.go:186` classifies the off-site **quota** failure by „tárhelykeretet" in a lower-cased error; (3) `internal/web/alerts.go:208` moves a disk warning inline-vs-top-banner by „meghajtón"/„adattároló"/„meghajtó"; (4) `internal/web/handlers.go:927` compares a **persisted** off-site warning against `offboxStaleWarningMarker` („nincs mentésre jelölt alkalmazás") — a warning written in one language is compared in another. (A fifth hit, `offbox_handlers.go:132`, is a detector false positive.) Templates: zero (detector decoy-tested). **Fix shape:** a typed error / sentinel / reason code at each producer, the display text derived from it, one red-proofed test per site asserting the consequence (status code, quota class, placement, substitution) with the wording changed. **Blocks localisation slice 2 (R-557)** — it goes first. | **READY - rank P3-LOW; owner: CC** |
| **R-554** | **[P3-LOW] Delete the first-boot setup wizard — obsolete by design, still reachable.** OPERATOR DECISION 2026-09-17 (localisation starter, decision 4: „out of scope, obsolete"). `02-controller-module-map.md` L56 calls `internal/setup/` obsolete; `cmd/controller/main.go` L322 still enters it when `setup.NeedsSetup(cfg)` — `customer.id` empty after bootstrap ingestion, or a `.needs-setup` marker (`internal/setup/setup.go` L17-25). Ingestion leaves `customer.id` empty on a missing/invalid `bootstrap.json`, a failed hub pull, or a failed merge/write/reload (`internal/bootstrap/bootstrap.go` L109-163) — so a box whose first boot cannot reach the hub shows a household an 8-page wizard (95 Hungarian strings, its own template set and CSRF). **Fix shape:** decide what such a box shows instead (a single „cannot reach Felhom yet, retrying" page — needs no decision beyond copy), then delete `internal/setup/` and `runSetupMode`; red-proof that a failed ingestion renders the waiting page, not a 404. **Check first** whether any drill/golden path still relies on `.needs-setup`. | **READY - rank P3-LOW; owner: CC** |
| **R-555** | **[P3-LOW] The wire-contract gate counts a field as received when its name appears in a Go COMMENT on the receiving side.** FOUND 2026-09-17 by CC adding the report's `language` field (controller v0.247.0): `scripts/wire_contract_gate.py` passed WITHOUT an allowlist entry, because `receiver_tokens()` tokenises whole files and the word „language" occurs hub-side only in a comment (`hub/internal/web/configs.go:558`, „this page's existing language"). The shape is the gate's own named failure class (name-for-fact, R-421): any English tag name that also appears in hub prose passes unread. The field was allowlisted by hand with this row named. **Fix shape:** strip `//` and `/* */` comments (and template `{{/* */}}`) before tokenising; add the decoy „a tag whose name appears only in a receiver comment must convict"; expect a handful of currently-passing tags to surface — each is a finding, not noise. | **READY - rank P3-LOW; owner: CC** |
| **R-556** | **[P3-LOW] Localisation slice 1 — the other 31 dashboard templates in English, Hungarian byte-identical.** PLAN 2026-09-17 (`architecture/10-localisation.md` §10). ~1 500 strings, ~600 of them inside `<script>`. Per page: capture parity fixtures from the UNCONVERTED template, run `controller/scripts/i18n_extract.py`, **review by eye what it left** (its ASCII-only word list missed 8 strings on the first three pages), translate, parity + English page tests green. Also: show the language switch to every household (§3, decision 6); give the retrieval-promise gate English stems (it scans Hungarian only); widen the English page test past its fixture-string masking (§2.3). Cost 12–16 CC-hours over three releases. | **READY - rank P3-LOW; owner: CC** |
| **R-557** | **[P3-LOW] Localisation slice 2 — Go-side customer strings follow the language.** PLAN 2026-09-17 (10 §10). Inventory §2.2: 947 shown + 184 error literals in 84 files; 237 format strings, 111 concatenations, 42 numeric `%d` (English plurals). Flash messages travel inside the redirect URL (`?flash=<Hungarian>`) and must become keys; `cloudflare/countries.go` (113 country names); alert texts; handler errors printed with `err.Error()`. **After R-553** (the four compare-not-show sites). Cost 16–20 CC-hours. | **READY - rank P3-LOW; owner: CC** |
| **R-558** | **[P3-LOW] Localisation slice 3 — the hub's customer e-mails follow the household's language; the operator sets it at customer creation.** PLAN 2026-09-17 (10 §3, §10), operator decision 2. The box already reports `"language"` (controller v0.247.0); nothing hub-side reads it. Build: a per-customer language on the hub (default hu) set at creation and rendered into `controller.yaml` beside `customer.*` (`hub/internal/configgen/configgen.go`), used by the box only while `settings.json` has no choice; the dispatcher picks the language the box REPORTS; English for the 39 `customerMessages`, the 4 severity labels, the body wrapper, the claim/re-enroll/reset/claimed/self-bind mails and the public bind page (inventory §2.3). Delete the `language` allowlist entry in `wire_contract_gate.py` when the field is read. Cost 6–8 CC-hours, one hub + one controller release. | **READY - rank P3-LOW; owner: CC** |
| **R-559** | **[P3-LOW] Localisation slice 4 — the console banner and the download page in English. SCOPE UNCONFIRMED.** PLAN 2026-09-17 (10 §10, open decision 1b): the starter listed them; the operator's scope ruling names „the controller, emails, guide, app catalog" and not them. Inventory §2.4: 34 banner lines (`scripts/iso/felhom-bootstrap.sh`, printf to the console; `/etc/issue` byte-coupled to `iso/pkg/debian/postinst`, console font avoids ő/ű) and 32 strings on `website/letoltes.html`. Cost 4–6 CC-hours plus an ISO release train. | **BLOCKED on operator (decision 1b); owner: CC** |
| **R-560** | **[P3-LOW] Localisation slice 5 — catalog cards, settings and first steps in English.** PLAN 2026-09-17 (10 §7, §10). Inventory §2.5: 835 strings, ~4 984 words across all 53 `.felhom.yml` (use_cases 262, first_steps 233, deploy_fields descriptions 79 / labels 68, prerequisites 60, description 56, tagline 53). Proposed format: an `i18n: {en: {...}}` block inside each `.felhom.yml` (controller's `yaml.Unmarshal` ignores unknown keys, so older controllers are unaffected), field-by-field fallback. Needs a controller change to read it and catalog copy gates with an English rule. Cost 12–16 CC-hours. | **READY - rank P3-LOW; owner: CC (catalog + controller)** |
| **R-561** | **[P3-LOW] Localisation slice 6 — the volunteer guide in English, then a stranger's first hour in English; closes R-516.** PLAN 2026-09-17 (10 §10). `runbooks/VOLUNTEER-first-hour.md`: 44 paragraphs, ~1 579 words. Then the 2026-09-14 walk repeated by an English speaker on a fresh box, R-516 closed against `audits/I18N-INVENTORY-2026-09-17.md`. Depends on R-556..R-560. Cost 6–8 CC-hours plus the attended walk. | **READY - rank P3-LOW; owner: CC** |
| **R-562** | **[P3-LOW] Dates and sizes are not formatted for any locale — and the Hungarian pages disagree with themselves.** FOUND 2026-09-17 by the i18n inventory §2.8: the two template date layouts differ (`2006. 01. 02. 15:04` Hungarian vs `2006-01-02 15:04` ISO); 10 layout literals in `internal/web` Go and 25 elsewhere pick formats ad hoc; sizes print a decimal POINT (`%.1f GB`, 4 helpers) where Hungarian uses a comma; `timeAgo`/`nextRunLabel`/`pruneLabel` produce Hungarian words outside the three converted pages. Not changed by v0.247.0 (Hungarian bytes are frozen by the parity rule). **Fix shape:** one date and one size formatter per language in `internal/i18n`, the Hungarian output deliberately changed in ONE reviewed release with the parity fixtures re-captured for that release only and the change named in its CHANGELOG. Needs an operator word on the Hungarian format (comma, date style). | **READY - rank P3-LOW; owner: CC** |
| **R-537** | **[P1-HIGH] The app-backup page labels the tier-1 backup „DB + Konfig + Adatok" and prints the app's data-drive size next to it — but the tier-1 unit contains NO drive-side app data at all.** MEASURED 2026-09-16 on the drill box (fresh install, controller 0.243.0, one drive, tier 2 and tier 3 both „Nincs beállítva"): five photos (3 000 000 B) were uploaded into Nextcloud through its own WebDAV interface, then the customer-visible „Mentés most" was pressed (`POST /api/backup/run` → 200, the unit grew 25 337 B → 978 MB). The resulting unit's `manifest.json` lists `db-dumps` + three **docker volume** dumps and nothing else; listing the 781 MB `nextcloud_nextcloud_html.tar` (29 346 entries, positive control `version.php` = 3 hits) gives **`Fotok` = 0 and `nyaralas` = 0**, and `./data/` is the empty bind-mount point. A `find` over the whole `backups/` tree for `*appdata*` / `*Fotok*` returns nothing. The page nevertheless renders „1. mentés … DB + Konfig + Adatok" and „Nextcloud Adatlemez 65.1 MB" — a size measured on exactly the data it does not copy (`internal/web/handlers.go:1176-1178`, `BackupContents`). **This is a truth defect, not a design defect:** `07-backup-architecture.md` §6.2 places nextcloud's file leg at **Tier 2 and Tier 3 only**, and its „[FACT] What the whole-guest tiers do NOT carry" says `mp8 /mnt/felhom-drives` is out of vzdump scope (confirmed live: „excluding bind mount point mp8 … (not a volume)"). So on a one-drive box with no off-site tier — the state every fresh install starts in — the household's files are in **no backup**, while the page says „Adatok". Same family as R-517/R-518. **Fix shape:** render tier-1 contents from the capture set actually written (`ComputeCaptureSet`), so a unit with no file leg reads „DB + Konfig" and the drive size is not shown beside it; and say on the page that the app's files need tier 2 or tier 3. Evidence: `audits/evidence-drill-0243-2026-09-16/phase2-f10.txt`. **CLOSED 2026-09-16 — controller v0.244.0, proven live.** The contents label is computed PER TIER from what that tier captures: Tier 1 says „Adatok" only when the app's data really is in the volumes the unit captured, and a class-A app carries one sentence saying where its files ARE protected. Proven on demo-hp through the page the customer opens: Paperless-ngx reads „1. mentés … DB + Konfig" with „Az alkalmazás fájljait a távoli másolat (és a második meghajtó) védi …", while its „2. mentés" row still reads „DB + Konfig + Adatok". Red-proof: restoring the old app-shaped label fails `TestAppBackupRows_Tier1LabelDoesNotClaimFilesItCannotHold`. **RE-PROVEN 2026-09-16 on a FRESH box** (installed from the built ISO 1.28.0, controller 0.244.0, off-site on by default): the Nextcloud row read „1. mentés … DB + Konfig" with the new sentence, „2. mentés … Nincs 2. (off-drive) másolat", „3. mentés Sikeres restic → …your-storagebox.de"; „DB + Konfig + Adatok" appeared ZERO times while the local unit held no file leg. | **CLOSED 2026-09-16 — controller v0.244.0 (proven live on demo-hp)** |
| **R-538** | **[P1-HIGH] A tier-1 app restore reports plain success and leaves Nextcloud listing files whose bytes were never in the backup — and it destroys the app's own trash, the customer's last copy.** MEASURED 2026-09-16 on the drill box, F10 („a child deletes the photo folder"): the five photos were deleted through Nextcloud (DELETE 204, PROPFIND 404), then restored through the page exactly as a customer would (`POST /backup/restore` `stack_name=nextcloud` `snapshot_id=helyi` → 302, finished in **35 s**, „A(z) nextcloud: 3 adatkötet és az adatbázis visszaállítva — az alkalmazás újraindult."). Afterwards the folder is back and **lists all five photos**, and **none of them opens**: `GET nyaralas-1..5` = 404 / 503×4 with `Sabre\DAV\Exception\NotFound`, while the positive controls at the same moment pass (`status.php` 200, WebDAV PUT 201, GET 200). Cause: the replayed MariaDB dump (11:01:45Z) knows the photos, the bytes live on `mp8` and were never captured (R-537). **Worse:** the bytes were still on the drive in Nextcloud's own trash (`appdata/nextcloud/admin/files_trashbin/files/Fotok.d1789556707/nyaralas-1..5.jpg`, all five present) and the restored database no longer references them — the trash listing comes back **empty**, so „restore from trash", the one route that would have worked, is gone. The customer is left with five unopenable photos, a success message, and no warning. **Fix shape:** before replaying a database whose app has an uncaptured file leg, refuse or warn („ennek az alkalmazásnak a fájljai nincsenek ebben a mentésben — a visszaállítás után a fájlok hiányozni fognak"); and never present a DB-only restore of a class-A app as a complete one. Evidence: `audits/evidence-drill-0243-2026-09-16/phase2-f10.txt`. **CLOSED 2026-09-16 — controller v0.244.0, proven live.** A unit restore refuses before anything is touched when the unit cannot return the app's drive-side files, and names the route that can. Fired live on demo-hp: `POST /backup/restore` for paperless-ngx → 302 with „Ez a mentés nem tartalmazza az alkalmazás fájljait, ezért nem állítjuk vissza az adatbázist föléjük — a fájlok így a helyükön maradnak. A fájlok a távoli másolatból állíthatók vissza …", and the app read `running` before AND after, so nothing was stopped and no trash was made unreachable. The database-and-settings-only path exists as a separately worded second step. Red-proof: disabling the guard fails `TestUnitRestore_RefusesWhenTheUnitCannotHoldTheFiles`. **RE-PROVEN 2026-09-16 on a FRESH box, and this time the refusal had somewhere to point:** after five photos were deleted, `POST /backup/restore` was refused with „…a fájlok így a helyükön maradnak. A fájlok a távoli másolatból állíthatók vissza: … „Teljes visszaállítás (fájlok + adatbázis)"", the app read `running` before AND after, and the wastebasket was untouched. The off-site route then returned all five photos — 200 with the exact uploaded sizes and sha256 IDENTICAL to the originals, 5/5, with a negative control. Evidence: `audits/evidence-backup-promise-2026-09-16/phaseE-photos.txt`. | **CLOSED 2026-09-16 — controller v0.244.0 (proven live on demo-hp)** |
| **R-525** | **[P3-LOW] FileBrowser has its own login; putting it behind the dashboard session (traefik forwardAuth or Quantum proxy auth) is a new mechanism nobody has measured.** Filed 2026-09-15 by the P1-fixes task (B.5). R-513 closed the default-password hole with a generated password; a household still has two logins. **What it needs:** a spike on a scratch guest — forwardAuth to the controller session, and what FileBrowser Quantum does with a trusted header. | **READY — rank P3-LOW; owner: CC (spike)** |
+13
View File
@@ -1,3 +1,16 @@
## i18n inventory + wire-contract allowlist entry (2026-09-17, localisation starter)
- **`i18n_inventory.py` (new, a survey — never fails a build).** Counts every customer-visible
Hungarian string across the four repos, per surface (page templates incl. inline JS, controller Go
classified shown/error/log/compared, hub customer mail, installer, catalog, guide, first-boot wizard),
with the ten most-repeated strings, word-order and plural hazards, date layouts, and every place a
Hungarian string is COMPARED rather than shown (same-line and through a package-level constant).
Hungarian by accented letter (as `\u` escapes — the script is ASCII) or a 46-word ASCII list;
positive and negative control per surface. Output: `documentation/audits/I18N-INVENTORY-2026-09-17.md`.
- **`wire_contract_gate.py`:** allowlist entry for the report's new `language` field (controller
v0.247.0; read by localisation slice 3, R-558). The gate passed without it — the token occurs hub-side
only in a Go comment, and the gate does not strip comments: **R-555**.
## ISO v1.28.0 — the console stops showing the pairing code once the box is connected (2026-09-16, R-535) — PUBLISHED 2026-09-16
**The defect, measured on a fresh box 2026-09-16:** 25 minutes after a successful bind AND claim, with
+531
View File
@@ -0,0 +1,531 @@
#!/usr/bin/env python3
"""i18n_inventory.py -- count every customer-visible Hungarian string across the Felhom repos.
Produces the numbers behind documentation/audits/I18N-INVENTORY-2026-09-17.md: one table per
surface (page templates, controller Go, inline JS, hub customer mail, installer, catalog, guide,
first-boot wizard), the ten most-repeated strings per surface, strings built with word order
(Sprintf/concatenation), numeric-parameter strings (English needs plural forms), date layouts and
size helpers, and every place a Hungarian string is COMPARED rather than shown.
It is a survey, not a gate: it never fails a build. It reads files as UTF-8 in Python, so the
accented-grep traps (locale, kubectl exec) do not apply -- but the workspace rule for Hungarian
search still holds, and each surface prints a POSITIVE control (an ASCII fragment of a Hungarian
word known to be there, which the letter matcher must also flag) and a NEGATIVE control (a fragment
known to be absent, and an ASCII-only line the matcher must NOT flag). A surface whose positive
control reads 0 is reported as BROKEN, not as "no Hungarian".
This source is ASCII-only on purpose: the Hungarian letters are written as \\u escapes.
Usage:
python3 scripts/i18n_inventory.py [--root /mnt/5_hdd/felhom.eu/git] [--json OUT.json]
"""
from __future__ import annotations
import argparse
import collections
import hashlib
import json
import os
import re
import sys
from pathlib import Path
import yaml
HU_LETTERS = set(
"\u00e1\u00e9\u00ed\u00f3\u00f6\u0151\u00fa\u00fc\u0171"
"\u00c1\u00c9\u00cd\u00d3\u00d6\u0150\u00da\u00dc\u0170"
)
# ASCII-only Hungarian words: a string made only of these carries no accented letter and would be
# missed by a letter search. Chosen to avoid English homographs ("Most", "Ma", "Online" are left out).
ASCII_HU_SEED = [
"Fut", "Nincs", "Igen", "Nem", "Hiba", "Mentve", "Rendben", "Adatok", "Napi", "Heti",
"Havi", "Mindig", "Soha", "Tegnap", "Perc", "Letiltva", "Bekapcsolva", "Kikapcsolva",
"Folyamatban", "Sikertelen", "Sikeres", "Tartalom", "Kapcsolat", "Vissza", "Megosztva",
"Kezdeti", "Rendszer", "Csatlakozva", "Kulcs", "Szerkeszt", "Keres", "Megnyit", "Elrejt",
"Mutasd", "Mentsd", "Kattints", "Nyisd", "Ismeretlen", "Szabad", "Foglalt",
# added 2026-09-17 after the spike's by-eye review of what a letter search left behind
"Megszakadt", "Elavult", "Csak", "kompatibilis", "Rendszermonitor", "Most nem",
]
SEED_RE = re.compile(r"\b(" + "|".join(ASCII_HU_SEED) + r")\b", re.I)
NEG_FRAGMENT = "qzxvkjq" # appears in no Felhom file
NEG_ASCII_LINE = "Backup completed at 03:00, next run tomorrow."
def has_hu(s: str) -> bool:
return any(c in HU_LETTERS for c in s)
def is_hu_text(s: str) -> bool:
"""Hungarian by letter, or an ASCII-only string made of a seed word."""
if has_hu(s):
return True
return bool(SEED_RE.search(s))
def read(p: Path) -> str:
return p.read_text(encoding="utf-8", errors="replace")
# ---------------------------------------------------------------------------------------------------
# Go string-literal lexer (comments skipped, raw strings kept, line numbers tracked)
# ---------------------------------------------------------------------------------------------------
def go_literals(src: str):
"""Yield (line, literal_body, raw) for every string literal outside comments."""
i, n, line = 0, len(src), 1
while i < n:
c = src[i]
if c == "\n":
line += 1
i += 1
elif src.startswith("//", i):
j = src.find("\n", i)
i = n if j < 0 else j
elif src.startswith("/*", i):
j = src.find("*/", i + 2)
j = n if j < 0 else j + 2
line += src.count("\n", i, j)
i = j
elif c == "'":
j = i + 1
while j < n and src[j] != "'":
j += 2 if src[j] == "\\" else 1
i = j + 1
elif c == '"':
j = i + 1
while j < n and src[j] != '"' and src[j] != "\n":
j += 2 if src[j] == "\\" else 1
yield line, src[i + 1:j], False
i = j + 1
elif c == "`":
j = src.find("`", i + 1)
j = n if j < 0 else j
yield line, src[i + 1:j], True
line += src.count("\n", i, j)
i = j + 1
else:
i += 1
LOG_RE = re.compile(r"(\blog(ger)?\.|\.logger\.|\blg\.|Logf|Printf|Println|debugf|Debugf|infof|warnf)")
ERR_RE = re.compile(r"(fmt\.Errorf|errors\.New)\(")
CMP_RE = re.compile(r'(==\s*"|!=\s*"|\bcase\s+"|strings\.(Contains|HasPrefix|HasSuffix|EqualFold|Index)\()')
FMT_VERB_RE = re.compile(r"%[-+# 0-9.]*[sdvqfx]")
NUM_VERB_RE = re.compile(r"%[-+# 0-9.]*d")
def classify_go(line_text: str, body: str) -> str:
if body.lstrip().startswith(("[DEBUG]", "[INFO]", "[WARN]", "[ERROR]")) or LOG_RE.search(line_text):
return "log"
if CMP_RE.search(line_text):
return "compare"
if ERR_RE.search(line_text):
return "error"
return "shown"
def go_package_consts(src: str):
"""Yield (name, value) for PACKAGE-LEVEL const/var string declarations only -- a local `msg := ...`
reused as a name in another package is not the same value and must not be matched."""
in_block = False
for l in src.split("\n"):
if re.match(r"^(const|var)\s*\($", l):
in_block = True
continue
if in_block and l.startswith(")"):
in_block = False
continue
m = (re.match(r'^\t([A-Za-z_]\w*)\s*(?:string\s*)?=\s*"((?:[^"\\]|\\.)*)"', l) if in_block
else re.match(r'^(?:const|var)\s+([A-Za-z_]\w*)\s*(?:string\s*)?=\s*"((?:[^"\\]|\\.)*)"', l))
if m:
yield m.group(1), m.group(2)
def go_indirect_compares(files, root: Path):
"""Hungarian held in a package-level constant/variable, then COMPARED through that name in the
same package -- the shape a same-line literal search cannot see (handlers.go
offboxStaleWarningMarker is the case that prompted it)."""
bydir = collections.defaultdict(list)
for f in files:
bydir[f.parent].append(f)
out = []
for d, fs in bydir.items():
srcs = {f: read(f) for f in fs}
names = {}
for f, src in srcs.items():
for n, v in go_package_consts(src):
if is_hu_text(v):
names[n] = (str(f.relative_to(root)), v)
if not names:
continue
alt = "|".join(map(re.escape, names))
use_re = re.compile(r"(strings\.(?:Contains|HasPrefix|HasSuffix|EqualFold|Index)\([^)]*\b(%s)\b|[=!]=\s*(%s)\b|\b(%s)\s*[=!]=)" % (alt, alt, alt))
for f, src in srcs.items():
for i, l in enumerate(src.split("\n"), 1):
if l.lstrip().startswith("//"):
continue
m = use_re.search(l)
if m:
n = m.group(2) or m.group(3) or m.group(4)
out.append({"file": str(f.relative_to(root)), "line": i, "name": n,
"defined": names[n][0], "text": names[n][1][:120]})
return out
def scan_go_files(files, root: Path):
rows = []
for f in files:
src = read(f)
lines = src.split("\n")
for ln, body, raw in go_literals(src):
if not is_hu_text(body):
continue
# A raw literal that is itself an embedded HTML page/template: count it, flag it.
lt = lines[ln - 1] if ln - 1 < len(lines) else ""
kind = classify_go(lt, body)
rows.append({
"file": str(f.relative_to(root)), "line": ln, "text": body[:160], "kind": kind,
"raw": raw, "multiline": "\n" in body,
"format": bool(FMT_VERB_RE.search(body)),
"numeric": bool(NUM_VERB_RE.search(body)),
"concat": bool(re.search(r'"\s*\+|\+\s*"', lt)) and not raw,
})
return rows
# ---------------------------------------------------------------------------------------------------
# HTML template scanner
# ---------------------------------------------------------------------------------------------------
ACTION_RE = re.compile(r"\{\{.*?\}\}", re.S)
CONTROL_RE = re.compile(r"\{\{-?\s*(if|else|end|range|with|define|block|template)\b.*?\}\}", re.S)
SCRIPT_RE = re.compile(r"<script\b[^>]*>(.*?)</script>", re.S | re.I)
STYLE_RE = re.compile(r"<style\b[^>]*>(.*?)</style>", re.S | re.I)
TAG_RE = re.compile(r"<[a-zA-Z!/][^>]*>", re.S)
ATTR_RE = re.compile(r'\s([a-zA-Z][a-zA-Z0-9:_-]*)\s*=\s*"([^"]*)"', re.S)
JS_STR_RE = re.compile(r"'(?:[^'\\\n]|\\.)*'|\"(?:[^\"\\\n]|\\.)*\"")
NUM_PARAM_RE = re.compile(r"\{\{\s*(len\b|\.\w*(Count|Num|Total|Days|Hours|Minutes|Size|N)\b)")
def line_of(src: str, pos: int) -> int:
return src.count("\n", 0, pos) + 1
def scan_template(f: Path, root: Path):
src = read(f)
rel = str(f.relative_to(root))
out = {"file": rel, "hu_lines": sum(1 for l in src.split("\n") if has_hu(l)),
"text": [], "attr": [], "js": [], "compare": [], "numeric": []}
# Mask script/style bodies (keep newlines so line numbers survive).
masked = src
for m in list(SCRIPT_RE.finditer(src)):
for jm in JS_STR_RE.finditer(m.group(1)):
s = jm.group(0)[1:-1]
if is_hu_text(ACTION_RE.sub("", s)):
out["js"].append({"line": line_of(src, m.start(1) + jm.start()), "text": s[:160]})
def blank(m):
return re.sub(r"[^\n]", " ", m.group(0))
masked = SCRIPT_RE.sub(blank, masked)
masked = STYLE_RE.sub(blank, masked)
# A compare is an eq/ne action whose OWN string arguments are Hungarian -- parsed inside the one
# action, never across the text between two actions.
for m in ACTION_RE.finditer(masked):
act = m.group(0)
if not re.search(r"\b(eq|ne)\b", act):
continue
for lit in re.findall(r'"((?:[^"\\]|\\.)*)"', act):
if is_hu_text(lit):
out["compare"].append({"line": line_of(src, m.start()), "text": lit})
for tm in TAG_RE.finditer(masked):
for am in ATTR_RE.finditer(tm.group(0)):
name, val = am.group(1).lower(), am.group(2)
if name in ("class", "id", "href", "src", "style", "name", "type", "for", "action", "method"):
continue
if is_hu_text(ACTION_RE.sub("", val)):
out["attr"].append({"line": line_of(src, tm.start()), "attr": name, "text": val[:160]})
# Text nodes: what lies between tags, split at control actions (if/range/...), with the
# remaining value actions kept in the message (they are its parameters).
text_only = TAG_RE.sub(lambda m: "\x00" + re.sub(r"[^\n]", " ", m.group(0))[1:], masked)
pos = 0
for chunk in text_only.split("\x00"):
start = pos
pos += len(chunk) + 1
for piece in CONTROL_RE.split(chunk):
if piece is None:
continue
p = piece.strip()
if not p or p in ("if", "else", "end", "range", "with", "define", "block", "template"):
continue
visible = ACTION_RE.sub("", p).strip()
if visible and is_hu_text(visible):
ln = line_of(src, start + max(chunk.find(p[:20]), 0))
out["text"].append({"line": ln, "text": " ".join(p.split())[:160]})
if NUM_PARAM_RE.search(p):
out["numeric"].append({"line": ln, "text": " ".join(p.split())[:160]})
return out
# ---------------------------------------------------------------------------------------------------
# Other surfaces
# ---------------------------------------------------------------------------------------------------
def walk_yaml(node, path=""):
if isinstance(node, dict):
for k, v in node.items():
yield from walk_yaml(v, f"{path}.{k}" if path else str(k))
elif isinstance(node, list):
for v in node:
yield from walk_yaml(v, path + "[]")
elif isinstance(node, str):
yield path, node
def controls(name: str, blobs: list[str], pos_fragment: str):
"""Positive: pos_fragment (ASCII) occurs, and some line holding it is flagged Hungarian.
Negative: NEG_FRAGMENT occurs nowhere, and an ASCII English line is not flagged."""
pos_lines = [l for b in blobs for l in b.split("\n") if pos_fragment in l]
pos_flagged = sum(1 for l in pos_lines if is_hu_text(l))
neg = sum(b.count(NEG_FRAGMENT) for b in blobs)
ok = len(pos_lines) > 0 and pos_flagged > 0 and neg == 0 and not is_hu_text(NEG_ASCII_LINE)
return {"surface": name, "positive_fragment": pos_fragment, "positive_lines": len(pos_lines),
"positive_flagged": pos_flagged, "negative_fragment": NEG_FRAGMENT, "negative_hits": neg,
"negative_ascii_line_flagged": is_hu_text(NEG_ASCII_LINE), "ok": ok}
def top10(strings):
c = collections.Counter(" ".join(s.split()) for s in strings)
return [(t, n) for t, n in c.most_common(10) if n > 1]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--root", default="/mnt/5_hdd/felhom.eu/git")
ap.add_argument("--json", default=None)
args = ap.parse_args()
root = Path(args.root)
ctl = root / "felhom-controller" / "controller"
hub = root / "felhom.eu" / "hub"
report = {"script_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), "surfaces": {}}
ctrls = []
# 1-3. Controller templates (+ inline JS) -------------------------------------------------------
tdir = ctl / "internal" / "web" / "templates"
tfiles = sorted(tdir.glob("*.html"))
tres = [scan_template(f, root) for f in tfiles]
report["surfaces"]["templates"] = tres
ctrls.append(controls("controller templates", [read(f) for f in tfiles], "pult"))
# 2. Controller Go (non-test), excluding the first-boot wizard ------------------------------------
gofiles = sorted(p for p in ctl.rglob("*.go")
if not p.name.endswith("_test.go") and "/internal/setup/" not in str(p))
gores = scan_go_files(gofiles, root)
report["surfaces"]["controller_go"] = gores
indirect = go_indirect_compares(gofiles, root)
report["surfaces"]["controller_go_indirect_compares"] = indirect
ctrls.append(controls("controller Go", [read(f) for f in gofiles], "ment"))
# 4. Hub customer surfaces ------------------------------------------------------------------------
hub_customer = sorted(list((hub / "internal" / "notify").glob("*.go"))
+ list((hub / "internal" / "web").glob("selfbind*.go"))
+ [hub / "internal" / "web" / "customer_reset.go", hub / "internal" / "claim" / "engine.go"])
hub_customer = [p for p in hub_customer if p.exists() and not p.name.endswith("_test.go")]
hubres = scan_go_files(hub_customer, root)
report["surfaces"]["hub_customer_go"] = hubres
hub_all = sorted(p for p in hub.rglob("*.go") if not p.name.endswith("_test.go"))
hub_all_rows = scan_go_files(hub_all, root)
hub_tpl = [scan_template(f, root) for f in sorted((hub / "internal" / "web" / "templates").glob("*.html"))]
report["surfaces"]["hub_operator_templates"] = hub_tpl
ctrls.append(controls("hub customer Go", [read(f) for f in hub_customer], "ment"))
# 5. Installer ------------------------------------------------------------------------------------
banner = root / "felhom.eu" / "scripts" / "iso" / "felhom-bootstrap.sh"
btext = read(banner)
banner_lines = [{"line": i + 1, "text": l.strip()[:160]} for i, l in enumerate(btext.split("\n")) if is_hu_text(l)]
dl = root / "felhom.eu" / "website" / "letoltes.html"
dlres = scan_template(dl, root) if dl.exists() else None
report["surfaces"]["installer_banner"] = banner_lines
report["surfaces"]["download_page"] = dlres
ctrls.append(controls("installer banner", [btext], "Felhom"))
# 6. Catalog --------------------------------------------------------------------------------------
cat = []
catfiles = sorted((root / "app-catalog-felhom.eu" / "templates").glob("*/.felhom.yml"))
for f in catfiles:
try:
doc = yaml.safe_load(read(f)) or {}
except yaml.YAMLError as e:
cat.append({"file": str(f.relative_to(root)), "path": "<YAML ERROR>", "text": str(e)[:120]})
continue
for p, v in walk_yaml(doc):
if is_hu_text(v):
cat.append({"file": str(f.relative_to(root)), "path": re.sub(r"\[\]", "[]", p), "text": v[:160],
"words": len(v.split())})
report["surfaces"]["catalog"] = cat
ctrls.append(controls("catalog", [read(f) for f in catfiles], "Alkalmaz"))
# 7. Guide ----------------------------------------------------------------------------------------
guide = root / "felhom.eu" / "documentation" / "runbooks" / "VOLUNTEER-first-hour.md"
gtext = read(guide)
paras = [p for p in re.split(r"\n\s*\n", gtext) if p.strip()]
report["surfaces"]["guide"] = {"file": str(guide.relative_to(root)), "paragraphs": len(paras),
"hu_paragraphs": sum(1 for p in paras if is_hu_text(p)),
"words": len(gtext.split())}
ctrls.append(controls("guide", [gtext], "Felhom"))
# 8. First-boot wizard ----------------------------------------------------------------------------
sdir = ctl / "internal" / "setup"
stpl = [scan_template(f, root) for f in sorted((sdir / "templates").glob("*.html"))]
sgo = scan_go_files(sorted(p for p in sdir.rglob("*.go") if not p.name.endswith("_test.go")), root)
report["surfaces"]["setup_wizard"] = {"templates": stpl, "go": sgo}
# Dates and sizes ---------------------------------------------------------------------------------
fmt_sites = []
for f in sorted((ctl / "internal" / "web").glob("*.go")):
if f.name.endswith("_test.go"):
continue
for i, l in enumerate(read(f).split("\n"), 1):
for m in re.finditer(r'\.Format\("([^"]*)"\)', l):
fmt_sites.append({"file": str(f.relative_to(root)), "line": i, "layout": m.group(1)})
for f in tfiles:
for i, l in enumerate(read(f).split("\n"), 1):
for m in re.finditer(r'\.Format\s+"([^"]*)"|Format\s+"([^"]*)"', l):
fmt_sites.append({"file": str(f.relative_to(root)), "line": i, "layout": m.group(1) or m.group(2)})
fmtmb = []
for f in tfiles + sorted((ctl / "internal" / "web").glob("*.go")):
if f.name.endswith("_test.go"):
continue
for i, l in enumerate(read(f).split("\n"), 1):
if "fmtMB" in l:
fmtmb.append({"file": str(f.relative_to(root)), "line": i})
ctl_fmt_elsewhere = 0
for f in ctl.rglob("*.go"):
if f.name.endswith("_test.go") or "/internal/web/" in str(f):
continue
ctl_fmt_elsewhere += len(re.findall(r'\.Format\("', read(f)))
report["dates"] = fmt_sites
report["fmtMB"] = fmtmb
report["format_calls_outside_web"] = ctl_fmt_elsewhere
report["controls"] = ctrls
# ---------------------------------------------------------------------------------------------
# Markdown summary
# ---------------------------------------------------------------------------------------------
P = print
P(f"script sha256: {report['script_sha256']}\n")
P("## Controls (ASCII fragment, positive + negative)\n")
P("| surface | positive fragment | lines holding it | of those flagged | negative fragment hits | ASCII English line flagged | verdict |")
P("|---|---|---|---|---|---|---|")
for c in ctrls:
P(f"| {c['surface']} | `{c['positive_fragment']}` | {c['positive_lines']} | {c['positive_flagged']} | "
f"{c['negative_hits']} | {c['negative_ascii_line_flagged']} | {'ok' if c['ok'] else 'BROKEN'} |")
def tsum(rs):
return (sum(r["hu_lines"] for r in rs), sum(len(r["text"]) for r in rs), sum(len(r["attr"]) for r in rs),
sum(len(r["js"]) for r in rs), sum(len(r["compare"]) for r in rs), sum(len(r["numeric"]) for r in rs),
sum(1 for r in rs if r["hu_lines"] or r["text"] or r["attr"] or r["js"]))
P("\n## Controller page templates\n")
hl, tx, at, js, cm, nu, withhu = tsum(tres)
P(f"{len(tres)} files; {withhu} carry Hungarian. Lines with an accented letter: **{hl}**. "
f"Strings: **{tx} text runs + {at} attribute values + {js} inline-JS literals = {tx + at + js}**. "
f"Template compares against Hungarian: {cm}. Text runs with a numeric parameter: {nu}.\n")
P("| template | HU lines | text | attr | JS | compare |")
P("|---|---|---|---|---|---|")
for r in sorted(tres, key=lambda r: -(len(r["text"]) + len(r["attr"]) + len(r["js"]))):
P(f"| {Path(r['file']).name} | {r['hu_lines']} | {len(r['text'])} | {len(r['attr'])} | {len(r['js'])} | {len(r['compare'])} |")
P("\nMost repeated (text + attr):")
for t, n in top10([x["text"] for r in tres for x in r["text"] + r["attr"]]):
P(f"- {n}x `{t}`")
def gosum(rows, label):
k = collections.Counter(r["kind"] for r in rows)
files = len({r["file"] for r in rows})
P(f"\n## {label}\n")
P(f"**{len(rows)} literals in {files} files.** shown: {k['shown']}, error (may reach a page): {k['error']}, "
f"log only: {k['log']}, compared: {k['compare']}. Format strings with parameters (non-log): "
f"{sum(1 for r in rows if r['format'] and r['kind'] != 'log')}; numeric %d (non-log, plural risk): "
f"{sum(1 for r in rows if r['numeric'] and r['kind'] != 'log')}; concatenated (non-log): "
f"{sum(1 for r in rows if r['concat'] and r['kind'] != 'log')}; raw multi-line blocks: "
f"{sum(1 for r in rows if r['multiline'])}.\n")
byfile = collections.Counter(r["file"] for r in rows if r["kind"] != "log")
P("| file (top 15, non-log) | literals |")
P("|---|---|")
for fname, n in byfile.most_common(15):
P(f"| {fname} | {n} |")
P("\nMost repeated (non-log):")
for t, n in top10([r["text"] for r in rows if r["kind"] != "log"]):
P(f"- {n}x `{t}`")
return k
gosum(gores, "Controller Go (non-test, first-boot wizard excluded)")
P("\n### Compared, not shown (controller Go)\n")
for r in gores:
if r["kind"] == "compare":
P(f"- {r['file']}:{r['line']} `{r['text']}`")
for r in indirect:
P(f"- {r['file']}:{r['line']} via `{r['name']}` (defined {r['defined']}) `{r['text']}`")
P("\n### Compared, not shown (templates)\n")
for r in tres:
for x in r["compare"]:
P(f"- {r['file']}:{x['line']} `{x['text']}`")
gosum(hubres, "Hub customer surfaces (notify, self-bind, reset, claim)")
hk = collections.Counter(r["kind"] for r in hub_all_rows)
hl2, tx2, at2, js2, _, _, _ = tsum(hub_tpl)
P(f"\nFor scale, NOT in scope: all hub Go non-test = {len(hub_all_rows)} literals "
f"(shown {hk['shown']}, error {hk['error']}, log {hk['log']}, compare {hk['compare']}); "
f"hub operator templates = {tx2 + at2 + js2} strings, {hl2} HU lines.")
P("\n## Installer\n")
P(f"Console banner `{banner.relative_to(root)}`: **{len(banner_lines)}** Hungarian lines.")
if dlres:
P(f"Download page `{dl.relative_to(root)}`: {len(dlres['text'])} text + {len(dlres['attr'])} attr + "
f"{len(dlres['js'])} JS = **{len(dlres['text']) + len(dlres['attr']) + len(dlres['js'])}** strings, "
f"{dlres['hu_lines']} HU lines.")
P("\n## Catalog\n")
byfield = collections.Counter(re.sub(r"^.*?\b(app_info\.\w+|deploy_fields\[\]\.\w+|\w+)$", r"\1", r["path"]) for r in cat)
apps = len({r["file"] for r in cat})
words = sum(r.get("words", 0) for r in cat)
P(f"{len(catfiles)} `.felhom.yml`; {apps} carry Hungarian; **{len(cat)} strings, ~{words} words**.\n")
P("| field | strings |")
P("|---|---|")
for fld, n in byfield.most_common(20):
P(f"| {fld} | {n} |")
g = report["surfaces"]["guide"]
P(f"\n## Guide\n\n`{g['file']}`: {g['paragraphs']} paragraphs ({g['hu_paragraphs']} Hungarian), ~{g['words']} words.")
P("\n## First-boot wizard (reported separately -- never merged into the dashboard count)\n")
shl, stx, sat, sjs, scm, _, _ = tsum(stpl)
sk = collections.Counter(r["kind"] for r in sgo)
P(f"{len(stpl)} templates: {shl} HU lines; {stx} text + {sat} attr + {sjs} JS = **{stx + sat + sjs}** strings. "
f"Go: {len(sgo)} literals (shown {sk['shown']}, error {sk['error']}, log {sk['log']}, compare {sk['compare']}).")
P("\n## Dates and sizes\n")
lay = collections.Counter((Path(x["file"]).suffix, x["layout"]) for x in fmt_sites)
P("| where | layout | sites |")
P("|---|---|---|")
for (suf, l), n in sorted(lay.items(), key=lambda kv: -kv[1]):
P(f"| {'template' if suf == '.html' else 'internal/web Go'} | `{l}` | {n} |")
P(f"\n`fmtMB` sites: {sum(1 for x in fmtmb if x['file'].endswith('.html'))} in templates, "
f"{sum(1 for x in fmtmb if x['file'].endswith('.go'))} in Go. `.Format(\"` calls in the controller outside "
f"internal/web: {ctl_fmt_elsewhere}.")
if args.json:
Path(args.json).write_text(json.dumps(report, ensure_ascii=False, indent=1), encoding="utf-8")
bad = [c for c in ctrls if not c["ok"]]
if bad:
P("\nCONTROLS BROKEN: " + ", ".join(c["surface"] for c in bad), file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
+8
View File
@@ -208,6 +208,14 @@ ALLOWLIST = {
"reached, never on a skip, a missing snapshot or a restore error, so 'presence is not success' "
"does not bite here. **Delete with its siblings.**"),
(_CH, "language"): (
"controller v0.247.0 (i18n spike): the household's dashboard language, published so the hub's "
"customer e-mails can follow it — plan slice 3 in documentation/architecture/10-localisation.md. "
"No hub release reads it yet; the hub stores the raw report, so it is visible there. RECORDED "
"HERE ALTHOUGH THE GATE PASSES WITHOUT IT: the token `language` occurs hub-side only inside a "
"Go COMMENT (hub/internal/web/configs.go), and receiver_tokens() does not strip comments — the "
"row filed 2026-09-17 for that blind spot names this field as its found case. **Delete this "
"entry when slice 3 reads the field.**"),
(_CH, "offsite.last_integrity_depth"): (
"R-399, controller v0.228.0: the DEPTH the verdict was reached at, published beside "
"last_integrity_ok above and unconsumed for the identical reason. It matters because 'checked, "