REPORT: v0.247.0 i18n spike — live parity on demo-hp, red-proofs, observations filed
gates / gates (push) Successful in 21s

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:58:32 +02:00
parent 612c417024
commit cc2ad41516
+86 -73
View File
@@ -1,96 +1,109 @@
# REPORT — controller v0.246.0: an interrupted restore is told; the recovery-code reminder waits
# REPORT — controller v0.247.0: the i18n spike — English on three pages, Hungarian byte-identical
**2026-09-17.** R-550 (operator ruling „fix") and R-546. Architecture read first:
`felhom.eu/documentation/architecture/07-backup-architecture.md` §3 (Lane 1) and
`08-alarm-ladder.md` §6.2.
**Date:** 2026-09-17 · **Task:** localisation starter (spike + design), Phase 1 · **Baseline:** `main`
`89dd3e94b1de` (v0.246.0, MinAgent 0.131.0) · **Released:** `612c417` → image
`gitea.dooplex.hu/admin/felhom-controller:0.247.0` · **MinAgent:** 0.131.0 (unchanged).
**Architecture read before any claim:** `felhom.eu/documentation/architecture/02-controller-module-map.md`
(templates, setup wizard obsolete) and `05-hub-architecture.md` (report ingestion, config push).
**Design written after the spike ran:** `felhom.eu/documentation/architecture/10-localisation.md`.
**Inventory:** `felhom.eu/documentation/audits/I18N-INVENTORY-2026-09-17.md`.
## Claims in the brief that turned out wrong — named first
1. **„Readiness = `escrow.pbs_storage_id` set."** Readiness is the agent's own preflight `ok` over
**five** blocking items (`pbs_storage_id`, `dr_tier`, `age_binary`, `hub_upload`, `sudo_grant`;
`felhom-agent/internal/localapi/escrow_ceremony.go` `handleEscrowPreflight`). `pbs_storage_id` was the
one item R-546's box happened to show. The controller now reads the combined `ok`, never a copy.
2. **„The failure shows a raw error"** — not in a browser. The escrow page already listed the checklist
and showed its start form only when the preflight was `ok`. The raw `-storage` stderr came from the
chaos-night harness calling `POST /api/escrow/start` directly. What a browser user DID meet: red
crosses, the agent's English detail in muted brackets, no start button and no word about waiting.
Both paths are fixed; the direct one is now refused server-side.
3. **„The backup tiers persist their records atomically"** (read from `07`, not re-checked) — **checked
and TRUE.** The tier run records live in `settings.json`, and `Settings.save()`
(`internal/settings/settings.go:727-752`) writes a `.tmp` and renames it, with `.bak` recovery on a
corrupt primary. The restore record follows the same shape through the backup package's own
`atomicWrite` (tmp + rename; like `save()`, no fsync).
The brief named four already. Further ones (details and methods in the inventory §0):
1. **„`fmtTime`, `fmtBytes`, `timeAgo` do not exist; `fmtMB` is the one size helper"** — all three
exist (`internal/web/funcmap.go` L265/L318/L403), plus nine more copy- or locale-producing helpers
and four size helpers. `timeAgo` builds „%d perce" — the plural problem lives there.
2. **„`fmtMB` used 10× in templates"** — 8×.
3. **„~13 `.Format(` layout calls in `internal/web`"** — 10 at the baseline (plus 8 `time.RFC3339`).
4. **Template lines 1 613 / 1 838** — 1 553, by two methods; neither figure reproduced by four variants.
5. **Go literals 1 234 in 99 files** — 1 141 in 84 (a lexer, comments skipped); comments explain most of
the difference.
6. **Banner 33 lines** — 34 once ASCII-only Hungarian words count (method difference).
## What shipped
**B.1 — the restore record survives a restart (R-550).** A design REVERSED by ruling, recorded in the
file header, in `07` §3 and in `CONTEXT.md`: `restore-status.json` in `DataDir`, written at both ends of an
op (`internal/backup/restore_record.go`). A record still marked running at startup becomes a failed,
`Interrupted` result — „A visszaállítás megszakadt (a doboz újraindult) — indítsd el újra." — kept per
app until that app's next restore; `restore_interrupted` (warning, household) pushed once. Wired from
`main()` (`cmd/controller/restore_record_wiring.go`). Shown as a card on `/backups/restore` and in the
off-site wizard's outcome card. Notification cooldowns stay in memory.
- **Mechanism (b), expanded at template LOAD:** `internal/i18n` (`Bundle`, `Expand`, `Msg`, `Plural`,
embedded `locales/hu.json` + `en.json`). `loadTemplates` parses one template set per language via
`parseTemplateSet` (= `ParseFS` + `Expand`); `s.tmpl` stays Hungarian. Why not a runtime `T` func: the
escaper changes `+ ' "` bytes and 618 of 1 867 template strings are inside `<script>`. Why not
`x/text/message`: no gender, only count plurals — a dependency for nothing.
- **Converted:** `launcher.html`, `backups.html`, `app_info.html`, `layout.html`, `backups_shared.html`
(`backups_empty`) — 252 markers, 235 hu keys, all translated (238 en keys incl. plural forms).
Page titles via `TitleKey`; English overrides for `stateLabel`, `timeAgo`, `timeAgoStr`,
`nextRunLabel`, `statusText`; `<html lang>` follows.
- **Setting / switch / report:** `settings.json` `language`; `POST /settings/language`; `?lang=`;
switch in the sidebar footer only when not Hungarian or `?lang=` is present; `"language"` in the hub
report at all four build sites in `cmd/controller/main.go`.
- **Gates:** new `i18n_missing_gate.py` (registered in `controller_gates.py`). `emoji`,
`native_confirm`, `secret_in_markup`, `retrieval_promise` now read templates expanded
(`scripts/i18n_bundle.py`); `mojibake` scans `.json`. **Found while converting:** retrieval-promise
went STALE (red) the moment copy moved; the other three went silently BLIND — the HEAD emoji gate
passed a planted emoji in `en.json`, and the HEAD retrieval gate never saw a planted promise in
`hu.json`.
- **Tool:** `scripts/i18n_extract.py`. First pass pulled layout `<span>`s and a button-link pair into
messages and mis-split a JS string that starts inside a tag — fixed and re-run from clean templates.
It also **missed eight ASCII-only Hungarian strings** (Megszakadt, Elavult, Csak x86, Pi kompatibilis,
Rendszermonitor, Most nem, sikertelen, ismeretlen) and one JS fragment („adatait ide:"), found by a
by-eye review of what it left; the word list was extended and the fragment converted by hand.
- Two existing tests (`TestTemplatesParse`, `TestTemplatesParseWithFuncmap`) parsed raw templates with
`ParseFS`; they now parse through `parseTemplateSet` for every language.
**B.2 — the reminder waits (R-546).** `internal/web/escrow_readiness.go`: the agent's preflight `ok`,
cached 60 s, probed only while paused. The bar is held back while not ready; `/backup/escrow` shows a
waiting card that polls and reloads; `POST /api/escrow/start` refuses 409 before staging. Unknown
readiness keeps the bar (fail loud). Transitions logged at INFO.
## Red-proofs — each seen failing, then passing (`felhom.eu/documentation/audits/i18n-2026-09-17/redproofs/`)
**B.3 — the guide.** `VOLUNTEER-first-hour.md`: the recovery code is now step 7, after the first apps,
and begins „Amikor a sárga sáv megjelenik (a beállítás után néhány perccel)".
**MinAgent: 0.131.0, unchanged** — the preflight has been served since agent 0.88.0.
## Red-proofs — each seen failing, then passing
| test | break | failure seen |
| test | mutation | failure seen |
|---|---|---|
| `TestRestoreRecord_InterruptedRestoreSurvivesRestart` | no persistence | `StartedAt:0001-01-01 … Last:<nil>` — the chaos-night value |
| `TestRestoreRecordAtStartup_RaisesInterruptedOnce` | helper skips loading | `raised no restore_interrupted: []` |
| `TestMainWiresRestoreRecord` | main() not wired | `does not call both … inert seam` |
| `TestR550_RestorePageShowsInterruptedRestore` | handler line removed | `the restore page does not show the interrupted restore` |
| `TestR546_NotReadyBoxHoldsTheBar` | readiness not consulted | `the bar urges a ceremony the box cannot run yet` |
| `TestR546_EscrowPageWaitsWhenNotReady` | flag never set | `does not tell the household the box is still preparing` |
| `TestR546_StartRefusedWhenNotReady` | gate removed | `= 200 … job_id escrow-1` |
| `TestI18nParity` | fixture file absent | `no parity fixture` (red before the fixture existed) |
| `TestI18nParity` | „Megosztási link" → „…linK" in hu.json | `launcher_full: Hungarian page CHANGED at line 261` (+ launcher_nopassword) |
| `TestI18nEnglishPages` | `layout.inditopult` deleted from en.json | `Hungarian template copy on the English page, line 111 … Indítópult` (5 cases) |
| `TestLanguageSwitch_EndToEnd` | `s.addLanguageData` removed from `executeTemplate` | `?lang=en must render English and show the switch`; title and switch missing |
| `TestI18nJSContextValuesAreSafe` | en `launcher.masolva` = `Copied'` | `contains '\'', which breaks its context` |
| `TestBundleParametersMatchAcrossLanguages` | en banner drops `{{.RecoveryAbandonDate}}` | `template fields ".RecoveryAbandonDays", Hungarian has ".RecoveryAbandonDate,.RecoveryAbandonDays"` |
| `TestLocaleFuncsHungarianBundleMatchesFuncMap` | hu `func.state.running` = „Futás" | `hu.json "Futás", funcmap "Fut"` |
| gate decoys (`test_gate_decoys.py`) | undefined key, „Please", gap grew, new formal form; emoji / confirm( / secret / retrieval promise planted in a bundle | all 18 decoys rejected; HEAD emoji gate passed the bundle emoji, HEAD retrieval gate did not see the bundle promise |
Controls: finished restore stays finished; the next restore of that app clears the notice (another app's
does not); a ready box shows the bar; unknown readiness shows the bar; no interruption → no card.
Two of my own test mistakes, corrected: a `-storage` needle that matched the menu's `nav-group-storage`
id (narrowed to `requires -storage`); the three start-order tests updated to `preflight,…` — their
load-bearing assertion (stage BEFORE start) unchanged.
Fixtures: captured by running the parity test with `-update-i18n-golden` in a clean `git worktree` of
`origin/main` 89dd3e94b1de (the test file adapted to `s.tmpl`), then copied in; the current tree passed
them before AND after conversion. All files restored after each mutation (`cmp`).
## Gates
`go build ./... && go vet ./... && go test ./...` — green, 28 packages. `controller_gates.py --fast` —
all OK (`golden-notice` advisory; golden stays 0.245.0 under the waiver to 2026-09-27).
- Green gate: `go build ./... && go vet ./... && go test ./...` → rc 0, 29 packages ok (first run found
the two raw-`ParseFS` tests; fixed; re-run green).
- `controller_gates.py --fast` → all OK incl. `i18n` (English missing 0 / ceiling 0; formal forms 6 /
ceiling 6). Pre-push hook ran them on the push.
## Live validation (endpoint-level; no browser on DooPlex)
## Live validation (endpoint-level; no browser on DooPlex) — demo-hp guest 9201
**B.4(a) — PASS, on demo-hp guest 9201 with a throwaway `homebox`.** Restore accepted 08:51:51Z; 2 s in
the status and the file on disk both said `running … homebox`; `docker kill felhom-controller`; the
agent's supervisor restarted it in 42 s (not by hand). After restart the status read
`ok:false … "A visszaállítás megszakadt …" interrupted:true`; `/backups/restore` showed the card
(negative control 0); the controller logged `restore_interrupted pushed OK (HTTP 200)`; the hub stores the
event under demo-hp. A second restore of homebox completed `ok:true` and the card was gone (0).
Evidence: `felhom.eu/documentation/audits/evidence-chaos-fixes-2026-09-17/partB4a-*.txt`.
Deployed by hand (`/etc/felhom-controller-image`, bootstrap restart); **fleet floor NOT raised; no
golden.** Startup: `loadTemplates: lang=hu … 252 markers expanded` and `lang=en … 252`.
**B.4(b) — NOT live-validated, and why (R-551).** No Tier-0 box is both paused and agent-connected:
9201 is escrowed (bar off by design; making it paused would be hand-set state on the standing demo box,
and a real ceremony would supersede its escrow — the hub keeps one `host_escrow` row per host); 9202 has
no local-API token. Proven by the three tests above through `ServeHTTP` with a fake agent; chaos night
measured live that the agent preflight is red ~17 min after a bind and turns green by itself.
**The ceremony was deliberately NOT run on demo-hp.**
- **Hungarian unchanged:** launcher, `/backups`, `/apps/privatebin` fetched on 0.246.0 and on 0.247.0 —
identical after replacing version string and CSRF token; raw sizes equal (44 462 / 46 250 / 42 081).
A fetch 7 s after restart differed only in app state tags („Nem egészséges" while health warmed up).
- **English:** `?lang=en` and, after `POST /settings/language lang=en` (302 → `/launcher`, settings
`"language": "en"`, INFO `language switch: household language set to en`), identical renders. No
accented Hungarian from template copy. Remaining Hungarian, all Go view models or catalog: backup-target
banner/offer and whole-guest tier labels (`/backups`); the „Naprakész" update badge and the catalog
tagline / use cases / first steps (`/apps/privatebin`). Launcher: none.
- **Hub report:** stored reports read no field (0.246.0), `hu`, `en` (12:54:43Z), `hu` (12:55:22Z).
Negative control: demo-felhom (0.246.0) no field.
- **Refusals:** no `_csrf` → 403; no session → 302 to login; setting unchanged.
- Evidence (CSRF redacted): `felhom.eu/documentation/audits/i18n-2026-09-17/live/`.
## Teardown
- **Machine:** homebox removed with data and backups (0 containers, 0 volumes); 10 of 10 standing apps up.
My first removal was refused 409 („still running — stop it first") — the product was right; stopped,
then removed. Password and scripts shredded in the guest (0 left).
- **Host:** nothing provisioned. 9201 stays on controller 0.246.0 (the release under validation).
- **Hub:** the `restore_interrupted` and `controller_restarted_by_agent` events stay as history.
Machine: guest temp scripts, password file, session file shredded/removed; box left on `hu`, 0.247.0.
Host: the tarball removed from demo-hp. Hub: nothing written; the DB copy read for reports shredded on
DooPlex. Nothing provisioned. The scratch worktree of `origin/main` is removed.
## Observations
1. An interrupted-restore notice for an app that is then removed stays for ever. **FILED: R-552**
2. No Tier-0 box can exercise the paused + agent-connected escrow state. **FILED: R-551**
3. `PushEvent` is best-effort (3 attempts, 3 s apart); `restore_interrupted` inherits that. **NOT-A-FINDING: the page notice comes from the persisted file and does not depend on the event; the event-drop path is the existing, recorded behaviour of every controller event.**
1. The wire-contract gate passes an unread field whose name appears in a hub Go comment (`language`). **FILED: R-555**
2. Four places decide behaviour by matching Hungarian wording (HTTP status, quota class, alert placement, a persisted warning marker). **FILED: R-553**
3. The extractor and the inventory detect ASCII-only Hungarian only through a word list; eight strings slipped past on three pages. **FILED: R-556**
4. `TestI18nEnglishPages` subtracts fixture data strings, so a template word equal to a data string (the launcher title „Indítópult") is masked on the launcher cases. **FILED: R-556**
5. Six formal „ön" forms in the converted Hungarian copy, now counted by a ratchet. **FILED: R-516**
6. Dates and sizes are not locale-formatted; the two template date layouts disagree; sizes use a decimal point. **FILED: R-562**
7. `storage_handlers_test.go` imported `text/template` for its template-parse check. **NOT-A-FINDING: a test-only parse check with no escaping assertion; it now parses through the production `parseTemplateSet`, which uses `html/template`.**