v0.259.0 — the claim page and the backup warnings answer in the reader's language (R-596, R-598)
gates / gates (push) Successful in 28s

The 2026-09-20 English drill ended one screen short: the claim page was English
and its answers were Hungarian, so a household who mistyped the code from their
e-mail could not tell a typo from a dead code. Fourteen call sites carrying nine
messages now go through s.msg; the backup page's two protection warnings — which
are promises about whether the customer's files are safe — follow the same route.

Hungarian is byte-identical, proved structurally by the go-parity gate against the
frozen base capture and red-proofed on a single added full stop.

data["Title"] was DEAD (claim.html is standalone; .Title is layout.html's) and is
deleted rather than translated — a translated dead field is a permanent false
signal about where the page's title comes from.

Six existing copy-contract tests were kept, not weakened: each now resolves its key
through the real bundle, so it still convicts on a reworded Hungarian sentence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-21 07:45:56 +02:00
parent f6909492cc
commit 0fe04bb6d5
15 changed files with 736 additions and 143 deletions
+37 -20
View File
@@ -159,20 +159,29 @@ func baseName(p string) string {
// HEALTHY RENDERS NOTHING. There is deliberately no "your backup is safe" banner: a working
// configuration must look normal, or every customer's dashboard grows a permanent notice and the
// warning stops meaning anything (E-2 Scenario E).
//
// R-598 — THESE ARE PROMISES ABOUT WHETHER THE CUSTOMER'S FILES ARE SAFE, and until v0.259.0 they
// were composed in Go as finished Hungarian sentences and handed to the page as DATA. That is the
// defect class R-573 and R-590 already cost: no template-parity fixture and no English-page test can
// see a Hungarian sentence arriving as a struct field. So the constants below are now KEYS, and
// degradedMessageFor returns the key; the one place that has a language resolves it.
//
// The Hungarian text moved to the bundle byte-for-byte (scripts/i18n_go_parity.py measures that
// against the frozen base capture), so a Hungarian household reads exactly the same words.
const (
backupTargetDegradedText = "A rendszermentés jelenleg ugyanazon a lemezen van, mint a rendszer — " +
"így hibás fájlok ellen véd, lemezhiba ellen nem. Csatlakoztass egy második meghajtót a teljes védelemhez."
backupTargetOfferText = "Ezt a meghajtót kijelölheted a rendszermentés helyéül — így egy lemezhiba " +
"után is vissza tudod állítani a rendszert."
backupTargetDegradedKey = "backup.target.degraded"
backupTargetOfferKey = "backup.target.offer"
// backupTargetAbsentText (R-114) is the CONFIGURED-BUT-GONE state. It is VERBATIM the hub's
// customerMessages["backup_target_absent"] (felhom.eu hub/internal/notify/templates.go:93) so the
// banner a customer reads on the page and the email they receive say exactly the same thing — a
// customer who is told two different stories about one drive trusts neither.
//
// DRIFT RISK, filed not fixed: this string now lives in two repos with nothing binding them. If
// one is reworded the other silently disagrees.
backupTargetAbsentText = "A rendszermentés meghajtója nem érhető el — amíg vissza nem " +
"csatlakoztatod, a teljes rendszermentés nem készül el."
// one is reworded the other silently disagrees. (v0.259.0: the HUNGARIAN side of the drift risk is
// unchanged — hu.json carries the same bytes the const carried. The ENGLISH side is new: the hub's
// English `mail.event.backup_target_absent` and this key must say the same thing too, and nothing
// binds those either. Same row, now twice as wide.)
backupTargetAbsentKey = "backup.target.absent"
)
// degradedMessageFor is the single decision point for "does the customer see anything?" — extracted
@@ -182,6 +191,10 @@ const (
// Returns "" for BOTH healthy and unknown. They are different states with the same rendering, and
// collapsing them here is deliberate: unknown means we could not ask, which is not evidence of
// degradation (the absence-read-as-a-value mistake R-88 Part 2 closed).
// It returns a bundle KEY, not a sentence (R-598). The decision — "does the customer see anything,
// and which fact is it?" — is language-free and stays here; the words are chosen by the caller that
// knows who is reading. Returning a key also means an untranslated state is a visible key on the
// page rather than a Hungarian sentence on an English one, which is the failure mode worth having.
func degradedMessageFor(st BackupTargetState) string {
if !st.Known || !st.Degraded {
return ""
@@ -189,9 +202,9 @@ func degradedMessageFor(st BackupTargetState) string {
if st.TargetAbsent {
// R-114: configured, drive gone. A different fact with a different remedy, so a different
// sentence — routed through here so there is still exactly one place that decides copy.
return backupTargetAbsentText
return backupTargetAbsentKey
}
return backupTargetDegradedText
return backupTargetDegradedKey
}
// ---- the render (R-112) ------------------------------------------------------------------------
@@ -220,15 +233,19 @@ type BackupTargetView struct {
// backupTargetView resolves the state and reduces it to what the page renders, or nil for the two
// states that render NOTHING — healthy and unknown. Returning nil rather than an empty struct means
// a template typo cannot accidentally decorate a working box.
func (s *Server) backupTargetView(ctx context.Context) *BackupTargetView {
//
// `lang` is the reader's, resolved once by the handler (s.langFor(r)) — the same shape
// buildDataPathCards and buildStorageBars already use. The KEY comes from degradedMessageFor; the
// WORDS are chosen here, and nowhere else.
func (s *Server) backupTargetView(ctx context.Context, lang string) *BackupTargetView {
st := s.resolveBackupTargetState(ctx)
msg := degradedMessageFor(st)
if msg == "" {
key := degradedMessageFor(st)
if key == "" {
return nil // healthy or unknown — a working configuration must look normal
}
v := &BackupTargetView{Message: msg}
v := &BackupTargetView{Message: s.msgLang(lang, key)}
if st.OfferPath != "" {
v.OfferPath, v.OfferLabel, v.OfferText = st.OfferPath, st.OfferLabel, backupTargetOfferText
v.OfferPath, v.OfferLabel, v.OfferText = st.OfferPath, st.OfferLabel, s.msgLang(lang, backupTargetOfferKey)
}
return v
}
@@ -244,11 +261,11 @@ func (s *Server) handleBackupTargetState(w http.ResponseWriter, r *http.Request)
out["target"] = st.TargetID
out["label"] = st.Label
if st.Degraded {
out["message"] = degradedMessageFor(st)
out["message"] = s.msg(r, degradedMessageFor(st))
if st.OfferPath != "" {
out["offer_path"] = st.OfferPath
out["offer_label"] = st.OfferLabel
out["offer_message"] = backupTargetOfferText
out["offer_message"] = s.msg(r, backupTargetOfferKey)
}
}
}
@@ -266,17 +283,17 @@ func (s *Server) handleBackupTargetAssign(w http.ResponseWriter, r *http.Request
Path string `json:"path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "backup.target.err.bad_request"), nil)
return
}
stable := strings.TrimSpace(req.Path)
if stable == "" {
writeDiskJSON(w, http.StatusBadRequest, false, "hiányzó meghajtó", nil)
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "backup.target.err.missing_drive"), nil)
return
}
agent, err := s.agentClient()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, "a host-ügynök nem elérhető", nil)
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.msg(r, "backup.target.err.agent_unreachable"), nil)
return
}
// The agent operates on the RAW host mount path, not our stable guest path — the same asymmetry
@@ -289,7 +306,7 @@ func (s *Server) handleBackupTargetAssign(w http.ResponseWriter, r *http.Request
res, err := agent.SetBackupTarget(r.Context(), raw)
if err != nil {
s.logger.Printf("[WARN] [web] backup-target assign %s (raw %s): %v", stable, raw, err)
writeDiskJSON(w, http.StatusBadGateway, false, "a mentési cél beállítása nem sikerült: "+s.errText(r, err), nil)
writeDiskJSON(w, http.StatusBadGateway, false, s.msg(r, "backup.target.err.assign_failed")+s.errText(r, err), nil)
return
}
// Record the INTENT only after the agent accepted, so our flag can never claim a target the agent