48f3336956
gates / gates (push) Successful in 23s
The notes a background run SAVES — last night's backup line, the last error, the proof result, the restore outcome — are written in the BOX's language at the moment they are written. A household that switches sees the previous run's note in the old language until the next run rewrites it: the operator's §16 option 1, stated rather than hidden. EndRestoreOp no longer receives a Hungarian literal from anywhere. The language switch is a globe. Two text links wrapped in the sidebar footer and asked the reader to recognise "Magyar"/"English" as links; a globe is the one symbol every web user already reads as "language", so nobody has to read Hungarian to escape Hungarian. It is <details>/<summary> — a menu with no script, drawn inline because the icon sprite lives only in layout.html and the visitor pages have their own shell. Those visitor pages get the same globe, and a visitor's choice stays theirs: a display-only felhom_lang cookie that langFor reads ONLY when there is no session. A signed-in household can never inherit a language a previous visitor picked in the same browser. POST /lang is CSRF-exempt for a narrow reason written at the exemption — its only achievable effect is the language of the page the victim's own browser shows them — and safeBackPath refuses //evil.example as well as https://, because "starts with /" alone is not the test. §16 taken: a successful claim carries the cookie into the household's setting. TWO PARITY EXCEPTIONS, MEASURED: 106 fixtures compared with a real diff — exactly two change shapes (the dashboard footer, the globe in the shells) and 5 byte-identical, which are the three pages that must not change. I INTRODUCED A DEADLOCK AND THE SUITE CAUGHT IT BY HANGING. UpdateOffboxStatus holds the settings write lock while running its callback; boxLang() wants the read lock; sync.RWMutex is not reentrant. On a real box an off-site run would have hung forever HOLDING the settings lock. Fixed by resolving the language before the callback, and guarded by a test that names the file and line in a second instead of hanging for 25 minutes. MinAgent: 0.131.0 (unchanged). No hub release needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
275 lines
19 KiB
Markdown
275 lines
19 KiB
Markdown
# REPORT — localisation slice 2, COMPLETE (controller v0.252.0 + v0.253.0 + v0.254.0, R-557)
|
||
|
||
**2026-09-18 · base commit `736f54b49610` (v0.251.0) · MinAgent 0.131.0, unchanged · no hub release.**
|
||
**Three releases: A (the sentences shown now), B (the 179 error messages), C (the saved notes + the globe).**
|
||
Architecture read first and named: `felhom.eu/documentation/architecture/10-localisation.md` §1
|
||
(parity), §2 (mechanism), §5 (gates), §9 (the R-553 signals), §10 (this slice); `07-backup-architecture.md`
|
||
for what the hub reads from a box; `05-hub-architecture.md` for what the hub composes itself.
|
||
|
||
---
|
||
|
||
## 1. Claims in the task that live source disproved — named first
|
||
|
||
| the task said | source says |
|
||
|---|---|
|
||
| "`cloudflare/countries.go` is used by `report/types.go` (on the wire)" | **No.** `report/builder.go` puts country **CODES** on the wire (`AllowedCountries []string`); no country NAME leaves the box. `CountryName()` has no caller at all. So the names are display-only and were free to translate — which is what this release did. |
|
||
| "the hub's dispatcher composes its own customer mail from event kinds (grep of `dispatcher.go` for `.Message`: nothing)" | **Wrong in substance.** `.Message` finds nothing because the message arrives as a PARAMETER, not a field. `FormatCustomerEmail` (hub `notify/templates.go` L167-210) uses `customerMessages[eventType]` when it has one and **falls back to the controller's message** when it does not — and appends it as „- Üzenet: %s" whenever the two differ. Several event types deliberately carry no entry so the controller's sentence IS the mail (hub `api/handler.go`, the notes at L2034/L2045/L2066). The conclusion (do not translate them) was right; the reason was not. |
|
||
| "5 flash readers / 8 writers" | 8 writers, **8 read sites** — the three named plus `auth.go:100` (login), `server.go:498` (claim) and the `flash_error` twins. All eight converted. |
|
||
| per-file counts (`handlers.go` 121, `offbox_handlers.go` 72, `storage_handlers.go` 48, `handler_debug.go` 40, `offbox_reconstitute.go` 41, `notify/notifier.go` 30, `api/router.go` 19) | measured at `736f54b49610`: **120 / 70 / 45 / 39 / 38 / 31 / 18**. Totals: **1 120** Hungarian literals, not 1 141. |
|
||
| "`scripts/i18n_inventory.py`" (as a controller script) | it lives in **`felhom.eu/scripts/`**. Its Go-literal walker was copied into the new gate rather than imported — a controller clone need not sit beside felhom.eu, and a gate that can fail to import its scanner can silently skip. |
|
||
|
||
## 2. What shipped
|
||
|
||
**226 Go literals converted**, plus 237 country names now available in English. 426 keys, each
|
||
recorded in `scripts/i18n_go_keys.json` against the literal it replaced.
|
||
|
||
| file | before | after | converted | of the rest, `fmt.Errorf` (release B) |
|
||
|---|---|---|---|---|
|
||
| `internal/web/handlers.go` | 120 | 44 | 76 | 0 |
|
||
| `internal/web/offbox_handlers.go` | 70 | 22 | 48 | 0 |
|
||
| `internal/web/storage_handlers.go` | 45 | 19 | 26 | 16 |
|
||
| `internal/api/router.go` | 18 | 0 | 18 | 0 |
|
||
| `internal/web/share_handlers.go` | 22 | 5 | 17 | 0 |
|
||
| `internal/web/sharing_handlers.go` | 18 | 3 | 15 | 1 |
|
||
| `internal/web/alerts.go` | 13 | 0 | 13 | 0 |
|
||
| `internal/web/tier2_config_handler.go` | 8 | 3 | 5 | 0 |
|
||
| `internal/api/geo.go` | 5 | 0 | 5 | 0 |
|
||
| `internal/web/backup_handlers.go` | 12 | 9 | 3 | 0 |
|
||
| `internal/cloudflare/countries.go` | 113 | 113 | 0 (the TABLE stays — it validates codes) | 0 |
|
||
|
||
Mechanism: `i18n.Bundle.Msgf`; `web.(*Server).msg/msgLang/msgN`; `api.(*Router).msg/msgLang/langFor/countryName`;
|
||
`flashQuery`/`flashText`/`flashFrom`; `Alert.MessageKey`/`MessageArgs`/`LinkTextKey` + `Alert.rendered(lang)`;
|
||
`TitleArgs` for the four app-named titles (R-566, closed).
|
||
|
||
**Word order without a second syntax.** The task asked for a named-parameter (`{{.Name}}`) form for
|
||
multi-parameter messages. It is not here, deliberately: English reorders with Go's own explicit
|
||
argument indexes (`%[2]s`), which `fmt` already understands. The Hungarian value then stays the format
|
||
string the code always had, byte for byte — which is exactly what the parity gate compares, so the
|
||
measurement needs no exception. `TestBundleParametersMatchAcrossLanguages` counts an indexed verb as
|
||
one verb, so a reordered English still has to use the same verbs on the same values.
|
||
|
||
## 3. The wire — surveyed, frozen, not translated (A.1)
|
||
|
||
Everything a HUB reads keeps its bytes. Filed against **R-558** (slice 3 owns it):
|
||
|
||
| producer | where it lands | why it stays |
|
||
|---|---|---|
|
||
| `internal/monitor/healthcheck.go` — 5 storage sentences | report `health.warnings` / `health.issues` | the hub stores and shows them to the operator |
|
||
| `internal/notify/notifier.go` — 31 event messages | event `message` → `FormatCustomerEmail` | **the household reads these in an e-mail**; several types have no `customerMessages` entry precisely so this sentence is the mail |
|
||
| `internal/cloudflare/countries.go` — the table | nothing (codes only) | it validates codes; the DISPLAY name is what follows the language |
|
||
|
||
Pinned by `TestStorageWarningWireTextIsFrozen`, `TestStorageWarningFormatsAreFrozen`,
|
||
`TestEventMessageWireTextIsFrozen`, `TestEventMessagesCarryNoBundleKey`. Both goldens were captured
|
||
from the producers at the base commit, before any change, and both were seen to fail.
|
||
|
||
The **persisted** text found by the same survey is listed in §6 for release C.
|
||
|
||
## 4. Tests and their red-proofs
|
||
|
||
Every one was run against a planted break and watched to convict; the planted change was then
|
||
reverted and the suite re-run green. Full log: `audits/i18n-slice2-2026-09-18/redproofs.txt`.
|
||
|
||
| test / gate | what was planted | what convicted |
|
||
|---|---|---|
|
||
| `i18n_go_parity.py` | one byte added to a hu.json value | `CHANGED`, naming the key and the base literal |
|
||
| `i18n_go_parity.py` | a key citing text no literal has | `INVENTED`, naming the text |
|
||
| `i18n_go_parity.py` | a joined key with reworded fragments | `INVENTED`, naming the fragment |
|
||
| `test_gate_decoys.py` go-parity ×3 | reworded / invented / unlisted | all three rejected |
|
||
| `TestEventMessageWireTextIsFrozen` | „telepítve" → „telepitve" | got/want printed |
|
||
| `TestStorageWarning*IsFrozen` | a wire warning translated | both tests |
|
||
| `TestFlashOnAServerWithNoBundleField` | `s.bundle()` without the `i18n.Shared` fallback | the page showed `flash.share.enabled` |
|
||
| `TestFlashKeyRoundTrip` | — | legacy prose, unknown key, `<script>`, empty: all six rows |
|
||
| `TestParameterisedPageTitles` | — | the four app-named titles equal the old concatenation |
|
||
| `TestAPIMessagesFollowTheHouseholdLanguage` | — | hu byte-identical, en different, per key |
|
||
|
||
**The gate caught a live defect in itself, not a decoy.** Its first capture filtered literals through
|
||
an ASCII-Hungarian word list and missed seven real ones („Naponta", „5 percenkent", „Eletjel
|
||
(Heartbeat)", „Adatbazis mentes", „Biztonsagi mentes", „Mentes integritas", „Rendszer allapot") — the
|
||
R-565 class exactly. It refused the keys that cited them. The filter is gone: every literal is
|
||
indexed, because the only question this index answers is "did the base commit contain this text?".
|
||
|
||
## 5. Green gate
|
||
|
||
`go build ./... && go vet ./... && go test ./...` — all green.
|
||
`python3 scripts/controller_gates.py` — 16 blocking gates OK + golden-notice advisory, **including the
|
||
new `go-parity`**. `python3 scripts/test_gate_decoys.py` — all 23 decoys behaved.
|
||
`HU_FORMAL_CEILING` 16 → 18, the measured number, no word changed (R-516 owns the words).
|
||
|
||
## 6. What is NOT in this release — each is a row, not prose
|
||
|
||
- **894 Hungarian literals remain**, of which **176 are `fmt.Errorf`/`errors.New`** — release B.
|
||
- **Persisted text** (release C): `settings.OffboxTarget.LastError/LastWarning/LastProofReason`,
|
||
`LastSyncError`, `RestoreOpResult`, the `unitRestore*MsgFmt` and `tier2*Msg` family in
|
||
`handlers.go`, and `backup/offbox_reconstitute.go`. Written by a background run, read days later.
|
||
**The §16 decision stands as its default: option 1** — written in the box's language at the time,
|
||
with a household that switches seeing the old language until the next run.
|
||
- **R-570's producer is untouched**, as its row requires.
|
||
- New rows: **R-572** (`pruneLabel`/`fmtDuration` in `funcmap.go` are copy-producing helpers that
|
||
`localeFuncs` does not override — „vasárnap" renders on an English page), **R-573** (the
|
||
agent-channel and endpoint-drift banner text is composed by the channel-health checker and reaches
|
||
`Alert.Message` as finished Hungarian), **R-574** (`handler_debug.go`, 39 literals, page copy and
|
||
JSON payload not yet separated).
|
||
|
||
## 7. Live validation
|
||
|
||
Endpoint-level on demo-hp guest 9201 (no browser on DooPlex — `claude-in-chrome` is not available
|
||
here). Evidence: `felhom.eu/documentation/audits/i18n-slice2-2026-09-18/A/live/`.
|
||
|
||
|
||
---
|
||
|
||
# Release B — v0.253.0: errors carry a key
|
||
|
||
**179 Hungarian error literals converted; ZERO remain.** `util.MsgError` carries the key from the
|
||
package that makes the error to the handler that prints it.
|
||
|
||
## 1. The mechanism, and the three things it had to do at once
|
||
|
||
| property | why it is not optional | pinned by |
|
||
|---|---|---|
|
||
| `Error()` is the Hungarian, byte for byte | every un-converted printer (a log line, a `%v`, a third-party wrapper) keeps printing what it printed. Without it, 179 producers could not be converted before all their printers were. | `TestMsgErrorKeepsKindAndHuText` |
|
||
| `errors.Is` answers for the kind **and** for a wrapped cause | `KindErrorf` returned the kind alone and dropped the cause; a converted producer usually wraps one | `TestMsgErrorUnwrapsTheCauseToo` |
|
||
| an error ARGUMENT renders recursively | `„formázás sikertelen: %w"` is a sentence wrapping a sentence; both halves are ours | `TestErrTextRendersAWrappedMessageErrorToo` |
|
||
| a FOREIGN error prints verbatim | restic, docker, ssh and the stdlib are not ours to translate (R-553's rule) | `TestErrTextFallsBackVerbatim` |
|
||
|
||
76 display sites in `internal/web` and `internal/api` now go through `errText`;
|
||
`TestNoErrErrorInPageOutput` examines 316 display-sink lines and convicts any that do not. A
|
||
`strings.Contains(err.Error(), …)` is a COMPARISON and stays untouched — the four English ones are
|
||
R-569.
|
||
|
||
`memoryVerdict` returns an ERROR rather than a sentence, so the deploy's 409 and the household's
|
||
language come from one value; `UpdateRefusal` gained a `Cause` so that error survives the update path
|
||
to the API.
|
||
|
||
## 2. Plurals — one rule, stated once
|
||
|
||
**A key that carries `.one`/`.other` forms in a language is a plural key, and its FIRST parameter is
|
||
the count** (`i18n.Bundle.form`). Hungarian never carries them, so a Hungarian render is unchanged at
|
||
every count. Deliberately NOT a per-call-site flag: the producer somebody forgot would read „3 app is
|
||
not running" with nothing to catch it, and the bundle is where a translator works.
|
||
|
||
`TestPluralFirstArgIsNumeric` pins that every plural value really takes its count first.
|
||
`TestNoOrdinaryKeyEndsInAPluralSuffix` pins that the two suffixes stay reserved — **it caught a real
|
||
collision the day the rule landed** (`alert.deadapp.one`, the ONE dead app, would have been read as
|
||
the singular of a key that does not exist). Renamed to `alert.deadapp.single`.
|
||
|
||
## 3. Two defects release B found in its OWN tooling
|
||
|
||
Both are recorded here rather than quietly fixed, because each is a shape that will recur.
|
||
|
||
**(a) The bulk converter silently dropped a multi-line concatenation.**
|
||
`fmt.Errorf("a: "+ "b: %s", x)` kept only `"a: "` and lost the rest with its arguments — 7 producers
|
||
damaged. **The parity gate did NOT catch it**, and the reason matters: every surviving fragment WAS a
|
||
real base-commit literal, so the gate's question ("is this text real?") was answered *yes* while the
|
||
CALL had lost text. What caught it was two behaviour tests that assert the sentence a customer reads
|
||
(`TestR356_ScenarioC_UndeployedAppIsStillRefused`, `TestR379_ScenarioA_RollbackSucceeds_AppComesBack`).
|
||
All 7 were rebuilt as joined keys and the six wrong keys pruned from both bundles. **The lesson: a
|
||
structural gate over the TEXT cannot see a defect in the CALL; only a test that renders the sentence
|
||
can.**
|
||
|
||
**(b) My own counting script was case-sensitive**, so it reported "0 error literals left" while five
|
||
remained („occ parancs sikertelen", „hub hiba", „OnlyOffice aldomain nem ismert" ×2). That is the
|
||
R-565 shape inside the measuring instrument. Re-measured with `re.I`; the five are converted. The
|
||
ASCII-fragment search that found them is in §5 below, with its controls.
|
||
|
||
## 4. Red-proofs
|
||
|
||
Seven planted breaks, each seen to convict and then reverted (full log:
|
||
`audits/i18n-slice2-2026-09-18/redproofs.txt`, entries 8–14). Plus the two live catches above, which
|
||
are not red-proofs — nobody planted them.
|
||
|
||
## 5. The "no Hungarian error left" claim, with controls
|
||
|
||
- **Negative control:** `fmt.Errorf("…qzxvkjq` → 0 hits (the fragment is in no file).
|
||
- **Instrument works:** the same ASCII search run against the release-B BASE commit finds
|
||
`sikertelen` 12× in `migrate.go` and `nincs` 7× in `offbox_restore.go`.
|
||
- **The claim, now:** an ASCII search for `sikertelen|sikeres|telepitve|mentes|nincs|kotelezo|hiba|folyamatban`
|
||
inside `fmt.Errorf`/`errors.New`, **case-insensitively**, and a separate accented-letter search:
|
||
**0 hits each**, outside a comment in `internal/util/msgerr.go` that quotes an example.
|
||
|
||
## 6. What is left after B
|
||
|
||
**705 Hungarian literals**, none of them errors: the country TABLE (113, not on the wire and not
|
||
translated on purpose), `handler_debug.go` (R-574), the notifier's 27 wire messages (R-558),
|
||
`funcmap.go`'s two un-overridden helpers (R-572), the text a background run PERSISTS (release C), and
|
||
the R-570 producer. Release C's default stands: written in the box's language at write time.
|
||
|
||
|
||
---
|
||
|
||
# Release C — v0.254.0: the saved notes, and a globe for the switch. SLICE 2 CLOSED.
|
||
|
||
## 1. Claims in the prompt that live source disproved — named first
|
||
|
||
| the prompt said | source says |
|
||
|---|---|
|
||
| "Release A's survey (persisted list, wire list)" is in `audits/i18n-slice2-2026-09-18/A/` | That folder holds only `live/`. The persisted list is in this REPORT's §6 for release A. Derived again from source here, which is the stronger reading — and it found the largest persisted group (`EndRestoreOp`'s outcome family, ~40 literals in two files) that a list would have had to remember. |
|
||
| "the three shells share no layout" | **True, and confirmed by reading**: `login.html`, `claim.html`, `recovery.html` each open their own `<html lang="{{T "layout.html_lang"}}">`. But the consequence was missed: the icon SPRITE lives only in `layout.html`, so a `<use href="#i-globe">` would render nothing on those three. The globe is drawn INLINE in the partial instead — one definition, four places, no sprite coupling. No dead symbol was added. |
|
||
| "no cookie is read anywhere for language" | Confirmed by grep: no language cookie existed. |
|
||
| the `<details>` menu "working without script in the browsers the guide names" | **Cannot be confirmed here.** `claude-in-chrome` is not available on DooPlex, so no browser rendered this page. `<details>`/`<summary>` is a plain HTML element with no script, and the markup is asserted; whether it LOOKS right is an operator click-through. Said plainly rather than implied. |
|
||
|
||
## 2. What shipped
|
||
|
||
**The saved notes.** ~70 producers across `internal/backup` (off-site classes and quota, tier-2
|
||
reasons and warnings, undo phrases, the reconstitute hold, the restore record) and `internal/web`
|
||
(the whole `EndRestoreOp` outcome family). Each renders through `util.Text(boxLang(), key, …)`.
|
||
**`EndRestoreOp` no longer receives a Hungarian literal from anywhere** (grep, with the negative
|
||
control). `s.noteErr`/`m.noteErr` render release B's errors into a note in the same language.
|
||
|
||
**The globe.** One partial (`lang_globe.html`), used by the dashboard footer and the three visitor
|
||
shells; drawn inline in the same stroke style as the sprite. `langFor` gained one step, and the order
|
||
is now fixed: `?lang=` → **household setting when a session exists** → visitor cookie when none →
|
||
setting → `hu`. A signed-in household never reads the cookie, which is the row that protects them.
|
||
|
||
**`POST /lang`** — CSRF-exempt for a narrow, checkable reason written at the exemption: the only
|
||
achievable effect is changing the language of the page the victim's own browser shows them. It writes
|
||
one cookie, reads nothing, and `safeBackPath` refuses `//evil.example` as well as `https://…` —
|
||
"starts with /" alone is not the test, and that is the mistake the function exists to not make.
|
||
|
||
**§16 taken (the operator's stated default): a claim carries the visitor's language**, on success
|
||
only, at the one moment an anonymous visitor becomes the household.
|
||
|
||
## 3. The two parity exceptions, measured
|
||
|
||
106 fixtures rendered and compared with a **real (LCS) diff**: exactly **two change shapes** — the
|
||
dashboard footer (89 fixtures) and the globe in the shells (12) — and **5 byte-identical**, which are
|
||
precisely the two guest share pages and the catch-all, the three that must not change. Full diff:
|
||
`audits/i18n-slice2-2026-09-18/C/parity-exception-diff.txt`.
|
||
|
||
**Deviation, named:** the prompt asked for one commit per re-capture. This is one commit, because
|
||
splitting them leaves an intermediate commit whose suite is red — and a commit that does not pass is
|
||
worse than a commit whose EVIDENCE separates the two blocks, which this one does explicitly.
|
||
|
||
## 4. Two defects release C found in its own work
|
||
|
||
**(a) A DEADLOCK I introduced, caught by the suite hanging.** `UpdateOffboxStatus` holds the settings
|
||
WRITE lock while it runs its callback; `boxLang()` reads the language through the settings READ lock.
|
||
`sync.RWMutex` is not reentrant. The first draft rendered a note inside that callback — on a real box
|
||
an off-site run would have hung **forever, holding the settings lock**, wedging everything else that
|
||
touches `settings.json`. The only symptom was `go test` timing out at 25 minutes instead of 8.
|
||
Fixed by resolving the language before the callback; `TestNoteHelpersAreNotCalledUnderTheSettingsLock`
|
||
now names the file and line in a second instead. **A hang is the worst kind of failure to diagnose,
|
||
and the lesson is: a helper that takes a lock must never be called from inside a callback that holds
|
||
one.**
|
||
|
||
**(b) The first "only two blocks changed?" measurement compared LINE BY INDEX.** An insertion shifts
|
||
every line below it, so it reported 60 520 changed lines and measured nothing. **A line-index compare
|
||
is not a diff.** Redone with difflib.
|
||
|
||
Also caught before it shipped: the parity HARNESS rendered the three shells through the DASHBOARD
|
||
path, which would have baked a form the real page never serves. Found by reading the diff before
|
||
re-capturing, and independently by `TestI18nDirectRenderPagesFollowLanguage`.
|
||
|
||
## 5. Green gate
|
||
|
||
`go build ./... && go vet ./... && go test ./...` green; all controller gates OK. Red-proofs 15–19 in
|
||
`audits/i18n-slice2-2026-09-18/redproofs.txt`, each seen to convict.
|
||
|
||
## 6. What is left after slice 2
|
||
|
||
**629 Hungarian literals**, none of them errors and none of them saved notes: the country TABLE (113,
|
||
not on the wire, not translated on purpose), the page-title literals slice 1 handles through
|
||
`TitleKey`, `handler_debug.go` (R-574), the notifier's 27 wire messages (R-558), `funcmap.go`'s two
|
||
un-overridden helpers (R-572), the R-570 producer, and the soft memory warning (R-575).
|
||
**R-557 is CLOSED.** New row: **R-577**, the guest-share visitor's language.
|