Files
felhom-controller/REPORT.md
T
admin 48f3336956
gates / gates (push) Successful in 23s
v0.254.0 — the saved notes follow the language, and the switch becomes a globe (R-557 slice 2 release C; SLICE 2 CLOSED)
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
2026-09-18 14:19:31 +02:00

19 KiB
Raw Blame History

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.