diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bb0956..e2026d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,45 @@ +## v0.213.0 — the banner promises only what the box can still see is true (2026-08-12, R-302) — MinAgent 0.127.0 + +**The abandon countdown told every customer who had given up their off-site history: *„Addig még +visszaszerezheted őket a helyreállítási kóddal."* Unconditionally, on every page. It is false on a +reachable state — and it rendered on the same screen as the orphan card correctly saying we cannot tell. + +**Why the obvious condition was rejected, recorded so nobody re-proposes it.** The natural proxy — +*does the hub hold a key different from the one this box uses?* — asks about the WRONG key. The +set-aside copies were written under an OLDER key the box no longer has, which is why they were set +aside. On a twice-rebuilt box the proxy answers “yes, promise it” about copies no key on file can open: +right in the ordinary case, wrong in the very case that started the investigation. **Demonstrated, not +argued** — under the proxy both Scenario B (package replaced) and Scenario D (legacy countdown) flip +back to promising. + +**Instead the fact is recorded at the one moment it is a fact.** `startAbandonCountdown` pins the hub’s +escrow key fingerprint as cached AT THE DECISION (`AbandonPinnedEscrowKeySHA256`). From then on the box +asks one exact question — *is the hub still holding that same package?* — rather than guessing which key +is which. Written once, never refreshed: a field re-read at render answers a different question. Same +shape as R-300’s ownership record two sessions ago. + +**⚠ IT IS A RECORDED ASSUMPTION, AND IT SAYS SO.** Nothing on the box records which key wrote the +set-aside copies. The pin presumes the package held at the decision is that one — true in the ordinary +rebuilt-box story, not provable, and wrong on a twice-rebuilt box. Written into the field comment and +into R-302 so it can be narrowed later rather than hardening into a fact. + +- **The certain half always renders**: the deletion and its date. Only the retrieval clause is conditional. +- **Empty is not a match**, on either side — the hub sends “” for a package sealing no repository password. +- **A countdown started before this release carries no pin and takes the cautious branch.** Not + backfilled: that would assert as recorded-at-the-decision something read long afterwards. +- **A FOURTH and FIFTH instance of the same promise were found by sweeping every template.** The backups + page block (`„a mentéseid visszaszerezhetők, és a törlés elmarad"`) got the same condition — fixing the + strip and not the page would leave one contradicting the other. The abandon CONFIRMATION screen + (`recovery.html`) was deliberately left: it renders at the moment of the decision, where the promise is + true by construction, because that is the package about to be pinned. + +**New gate — `retrieval_promise_gate.py`, and it pins the CLAIM rather than the word.** A string ban was +tried twice and failed twice (singular vs plural; then one verb vs another). It cannot simply be +broadened either: **the honest replacement copy contains the stem**, inside a question about whether the +thing is knowable. So every retrieval-claim occurrence across all 36 templates is now REGISTERED with a +reason, and unregistered ones fail. Proven by planting all three historical wordings in turn — each +convicted, each cleared on removal. + ## v0.212.0 — the second promise (2026-08-12, R-299) — MinAgent 0.127.0 **R-299 — the orphan card’s OTHER sentence made the same unevaluable promise, and the spec said it was diff --git a/controller/internal/backup/offbox_abandon.go b/controller/internal/backup/offbox_abandon.go index 0a7c0d0..e42d050 100644 --- a/controller/internal/backup/offbox_abandon.go +++ b/controller/internal/backup/offbox_abandon.go @@ -58,11 +58,21 @@ func (m *Manager) SetOffboxClock(fn func() time.Time) { m.offboxNow = fn } func (m *Manager) startAbandonCountdown(setAsidePath string) { now := m.abandonNow().UTC() due := now.AddDate(0, 0, abandonGraceDays) + // R-302: pin the hub's escrow key fingerprint HERE, at the decision — the one moment it is a fact + // rather than something inferred later from an adjacent value. From now on the banner asks exactly + // one question, "is the hub still holding that same package?", instead of guessing which key is + // which. Written once and never refreshed: a field re-read at render answers a different question + // and would silently restore the defect this replaces. + pinned := "" + if m.settings != nil { + pinned, _ = m.settings.GetHubEscrowKeySHA256() + } if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.AbandonStartedAt = now.Format(time.RFC3339) o.AbandonAt = due.Format(time.RFC3339) o.AbandonRepoPath = setAsidePath o.AbandonPurgeRequested = false + o.AbandonPinnedEscrowKeySHA256 = pinned }); err != nil { m.logger.Printf("[WARN] [offbox] could not record the abandonment countdown: %v", err) return @@ -80,6 +90,15 @@ type AbandonState struct { DaysLeft int // ceiling, so "0 days left" only ever means "today" RepoPath string // the set-aside store awaiting deletion PurgeRequested bool // the store is gone; awaiting the hub to drop the sealed package + // RetrievalStillOffered (R-302) — may the banner still say the set-aside copies can be retrieved + // with the recovery code? TRUE only while the hub is holding the SAME sealed package it held when + // the customer decided. Derived here, once, so the banner and anything else asking cannot disagree. + // + // FALSE covers: the package was replaced after the decision (a fresh escrow ceremony — the act that + // cost both demo boxes their history); the hub reports an empty hash (a legacy package sealing no + // repository password); and a countdown started before R-302, which carries no pin. All three are + // "we cannot see that this is still true", and all three must read as such rather than as a promise. + RetrievalStillOffered bool } // AbandonStatus reports the countdown for the UI and the report. It never mutates. @@ -103,6 +122,13 @@ func (m *Manager) AbandonStatus() AbandonState { if s, serr := time.Parse(time.RFC3339, t.AbandonStartedAt); serr == nil { st.StartedAt = s } + // R-302: the pinned fingerprint vs what the hub reports NOW. Both must be non-empty and equal. + // Empty on either side is "we could not see", never "they match" — the settings comment on + // HubEscrowKeySHA256 establishes that the hub sends "" for a package sealing no repo password. + if cur, _ := m.settings.GetHubEscrowKeySHA256(); cur != "" && + t.AbandonPinnedEscrowKeySHA256 != "" && cur == t.AbandonPinnedEscrowKeySHA256 { + st.RetrievalStillOffered = true + } // Ceiling: a countdown with 30 minutes left says "1 day", never "0". Zero is reserved for due. remaining := due.Sub(m.abandonNow()) if remaining <= 0 { diff --git a/controller/internal/backup/offbox_abandon_pin_r302_test.go b/controller/internal/backup/offbox_abandon_pin_r302_test.go new file mode 100644 index 0000000..958a33d --- /dev/null +++ b/controller/internal/backup/offbox_abandon_pin_r302_test.go @@ -0,0 +1,208 @@ +package backup + +import ( + "context" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// ── R-302 — THE BANNER PROMISES ONLY WHAT THE BOX CAN STILL SEE IS TRUE ───────────────────────── +// +// The abandon banner said "until then you can still retrieve them with your recovery code", +// unconditionally, on every page. Yesterday's reading proved that false on a reachable state. +// +// THE CONDITION IS A PIN, NOT A COMPARISON AGAINST THE CURRENT KEY, and the difference is the whole +// design. The obvious proxy — "does the hub hold a key different from the one I use?" — asks about the +// wrong key: the set-aside copies were written under an OLDER key the box no longer has, which is why +// they were set aside. On a twice-rebuilt box the proxy answers "yes, promise it" about copies no key +// on file can open. The pin instead records the package the hub held AT THE DECISION and asks only +// "is the hub still holding that same one?". +// +// ⚠ THE PIN IS A RECORDED ASSUMPTION. It presumes the package held at the decision is the one that +// opens the set-aside copies. Nothing on the box records which key wrote them. See the field comment +// on settings.AbandonPinnedEscrowKeySHA256. +// +// The countdown is never started, shortened or triggered on a real machine — the clock is injected. + +const pinnedHubKey = "1111111111111111111111111111111111111111111111111111111111111111" +const replacedHubKey = "2222222222222222222222222222222222222222222222222222222222222222" + +// startedCountdown drives the PRODUCTION path (ResetOrphanedRepo → resetOrphanedRepo → +// startAbandonCountdown) so the pin cannot be written by tests alone while the live path never sets +// it — the inert-seam shape that has shipped here before, fully green. +func startedCountdown(t *testing.T, hubKeyAtDecision string) (*Manager, *settings.Settings, time.Time) { + t.Helper() + start := time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC) + m, sett, _ := abandonFixture(t, start) + if err := sett.SetHubEscrowKeySHA256(hubKeyAtDecision, start.Format(time.RFC3339)); err != nil { + t.Fatal(err) + } + if err := m.ResetOrphanedRepo(context.Background()); err != nil { + t.Fatalf("the production reset path failed: %v", err) + } + return m, sett, start +} + +// PRODUCTION WIRING: the live decision path writes the pin. If this fails, every render test below is +// testing a field nothing sets. +func TestR302_ProductionResetPathWritesThePin(t *testing.T) { + _, sett, _ := startedCountdown(t, pinnedHubKey) + + got := sett.GetOffboxTarget().AbandonPinnedEscrowKeySHA256 + if got != pinnedHubKey { + t.Fatalf("pinned fingerprint = %q, want the hub key cached at the decision (%q). The whole "+ + "design is that this is recorded when it is a fact; if the live path does not write it, "+ + "the banner falls to the cautious branch for ever and the grace period becomes theatre", + got, pinnedHubKey) + } + if sett.GetOffboxTarget().AbandonAt == "" { + t.Error("no countdown recorded — the fixture is not exercising the path it claims to") + } +} + +// ── SCENARIO A — package unchanged since the decision → the promise stands ────────────────────── +// +// RED-PROOF: force the condition false (drop the `cur == t.AbandonPinnedEscrowKeySHA256` arm) and this +// fails — a customer who can genuinely still change their mind loses the clause, which the code says +// explicitly must not happen ("a grace period during which recovery is impossible would be theatre"). +func TestR302_ScenarioA_PackageUnchanged_RetrievalStillOffered(t *testing.T) { + m, _, _ := startedCountdown(t, pinnedHubKey) + + st := m.AbandonStatus() + if !st.Active { + t.Fatal("countdown not active") + } + if !st.RetrievalStillOffered { + t.Error("the hub still holds the same package it held at the decision, so the customer really " + + "can still change their mind — the promise must stand") + } +} + +// ── SCENARIO B — the package was REPLACED after the decision → promise withdrawn ──────────────── +// +// This is the act that cost both demo boxes their history on 2026-08-04: a fresh escrow ceremony +// supersedes the package, and the old key it covered is unreachable (superseded packages grant no +// read path — hub store.go's own comment). +// +// RED-PROOF: re-read the pin at render (compare `cur` against itself, i.e. use the CURRENT cached +// value on both sides) and this fails — the promise returns, which is today's defect. +func TestR302_ScenarioB_PackageReplaced_PromiseWithdrawn(t *testing.T) { + m, sett, start := startedCountdown(t, pinnedHubKey) + + // A fresh ceremony after the decision. + if err := sett.SetHubEscrowKeySHA256(replacedHubKey, start.Add(48*time.Hour).Format(time.RFC3339)); err != nil { + t.Fatal(err) + } + if st := m.AbandonStatus(); st.RetrievalStillOffered { + t.Error("the hub's package was replaced after the customer decided, so the key that opened the " + + "set-aside copies is no longer served — the banner must stop promising retrieval") + } + // The pin itself must NOT have moved: it is written once, at the decision. + if got := sett.GetOffboxTarget().AbandonPinnedEscrowKeySHA256; got != pinnedHubKey { + t.Errorf("the pin was refreshed to %q — a field re-read later answers a different question and "+ + "silently restores the defect this replaces", got) + } +} + +// ── SCENARIO D — a countdown started BEFORE this shipped carries no pin ───────────────────────── +// +// RED-PROOF: backfill the pin from the current cached value when it is empty and this fails — a legacy +// countdown gets promised at, asserting as recorded-at-the-decision something read long afterwards. +func TestR302_ScenarioD_LegacyCountdownWithoutAPin_TakesTheCautiousBranch(t *testing.T) { + m, sett, _ := startedCountdown(t, pinnedHubKey) + + // Model the pre-R-302 on-disk shape: a live countdown, no pin. + if err := sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { + o.AbandonPinnedEscrowKeySHA256 = "" + }); err != nil { + t.Fatal(err) + } + st := m.AbandonStatus() + if !st.Active { + t.Fatal("countdown should still be running") + } + if st.RetrievalStillOffered { + t.Error("a countdown with no pin was promised at. There is no honest way to know whether the " + + "hub's package is still the one from the decision, and the cautious answer is the only one " + + "available") + } +} + +// ── SCENARIO E — pinned present, hub's cached value EMPTY → cautious ──────────────────────────── +// +// The hub sends "" for a legacy package that provably seals no repository password. Empty is a +// measurement, not a match. +// +// RED-PROOF: treat empty as equal (drop the `cur != ""` arm) and this fails. +func TestR302_ScenarioE_EmptyHubHash_IsNotAMatch(t *testing.T) { + m, sett, start := startedCountdown(t, pinnedHubKey) + + if err := sett.SetHubEscrowKeySHA256("", start.Add(time.Hour).Format(time.RFC3339)); err != nil { + t.Fatal(err) + } + if st := m.AbandonStatus(); st.RetrievalStillOffered { + t.Error("an EMPTY hub hash was read as a match. It means the hub holds a package that seals no " + + "repository password — the opposite of evidence that retrieval works") + } +} + +// ── SCENARIO F — no countdown → nothing about retrieval is claimed at all ─────────────────────── +func TestR302_ScenarioF_NoCountdown_NoClaim(t *testing.T) { + start := time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC) + m, _, _ := abandonFixture(t, start) + + st := m.AbandonStatus() + if st.Active { + t.Fatal("no countdown was started, yet one is reported active") + } + if st.RetrievalStillOffered { + t.Error("retrieval was offered with no countdown running — the flag must be meaningless " + + "outside an abandonment, not default-true") + } +} + +// The pin is a hash of a secret. It must never reach a customer-facing surface or the report; this +// pins that it is not accidentally exported through the read model. +func TestR302_PinIsNotExposedThroughTheReadModel(t *testing.T) { + m, _, _ := startedCountdown(t, pinnedHubKey) + st := m.AbandonStatus() + if st.RepoPath == pinnedHubKey { + t.Fatal("the pin leaked into RepoPath") + } + // AbandonState carries a BOOLEAN verdict, never the fingerprint itself. + if got := st.RetrievalStillOffered; got != true && got != false { + t.Fatal("unreachable") + } +} + +// ── SCENARIO E, the case that actually bites — BOTH sides empty ───────────────────────────────── +// +// A legacy countdown (no pin) on a box whose hub reports an empty hash (a package sealing no repo +// password). "" == "" is the equality that would quietly become a promise, and it is the ONLY state +// where dropping the emptiness guards changes the answer — TestR302_ScenarioE above passes even with +// them removed, because its pin is non-empty so the equality fails on its own. That test guards the +// sentence; this one guards the claim. +// +// RED-PROOF: drop either `cur != ""` or `t.AbandonPinnedEscrowKeySHA256 != ""` and this fails. +func TestR302_ScenarioE2_BothSidesEmpty_IsNotAMatch(t *testing.T) { + m, sett, start := startedCountdown(t, pinnedHubKey) + + if err := sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { + o.AbandonPinnedEscrowKeySHA256 = "" // legacy countdown, no pin + }); err != nil { + t.Fatal(err) + } + if err := sett.SetHubEscrowKeySHA256("", start.Add(time.Hour).Format(time.RFC3339)); err != nil { + t.Fatal(err) + } + st := m.AbandonStatus() + if !st.Active { + t.Fatal("countdown should still be running") + } + if st.RetrievalStillOffered { + t.Error("two absences compared equal and became a promise. Empty means we could not see; two " + + "things we could not see are not a match") + } +} diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index 398d185..82435ac 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -377,6 +377,27 @@ type OffboxTarget struct { // hub's ACK stops reporting a superseded package. If the two halves could not be removed together // this marker is what makes the box keep asking until they are (Scenario F). AbandonPurgeRequested bool `json:"abandon_purge_requested,omitempty"` + // AbandonPinnedEscrowKeySHA256 (R-302) — the hub's escrow key fingerprint AS CACHED AT THE MOMENT + // THE CUSTOMER DECIDED. It is NOT the current key and NOT re-read: the banner's retrieval promise + // is rendered only while the hub is still holding that same package. + // + // ⚠ IT IS A RECORDED ASSUMPTION, NOT A PROOF, AND THAT IS DELIBERATE. Nothing on the box records + // which key wrote the set-aside copies — that key is gone, which is why they were set aside. What + // is pinned is the package the hub held at the decision, which in the ordinary rebuilt-box story IS + // the pre-rebuild escrow covering the pre-rebuild repo password, i.e. the one that wrote them. On a + // TWICE-rebuilt box that presumption can be wrong: the hub may hold rebuild #2's package while the + // set-aside copies are rebuild #1's, and no key on file opens them. This pin cannot detect that. + // + // What it DOES detect, and what the rejected alternative could not: the package being REPLACED + // after the decision — a fresh escrow ceremony, which is exactly the act that cost both demo boxes + // their history on 2026-08-04. The rejected proxy (hub fingerprint vs the CURRENT key, compared at + // render) answers "does the hub hold a different key?", which is TRUE in the co-render case and so + // would promise precisely where the promise is least safe. + // + // EMPTY IS MEANINGFUL AND IS NOT A MATCH: the hub sends "" for a legacy package that provably seals + // no repository password, and a countdown started before R-302 has no pin at all. Both take the + // cautious branch. NEVER rendered, logged or reported — it is the hash of a secret. + AbandonPinnedEscrowKeySHA256 string `json:"abandon_pinned_escrow_key_sha256,omitempty"` } // CrossDriveBackup configures per-app backup to a secondary drive. diff --git a/controller/internal/web/abandon_promise_r302_test.go b/controller/internal/web/abandon_promise_r302_test.go new file mode 100644 index 0000000..6bc9a73 --- /dev/null +++ b/controller/internal/web/abandon_promise_r302_test.go @@ -0,0 +1,117 @@ +package web + +import ( + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// ── R-302 — RENDERED, at the boundary the defect lives at ─────────────────────────────────────── +// +// The condition is unit-tested in internal/backup; these assert the SENTENCES, because the defect was +// always copy that disagreed with what the box could see, and only the rendered bytes show that. + +// abandonBannerData renders a page carrying the countdown strip. `offered` is the R-302 verdict. +func abandonBannerData(offered bool, repoState string) map[string]interface{} { + d := splitTestData() + d["Offbox"] = &settings.OffboxTarget{ + Enabled: true, Host: "nas.local", User: "felhom", RepoPath: "/srv/repo", + EscrowState: "escrowed", RepoState: repoState, QuotaGB: 50, StatsKnown: true, + } + d["OffboxQuotaPct"] = 0 + d["RecoveryBanner"] = true + d["RecoveryAbandonDays"] = 3 + d["RecoveryAbandonDate"] = "2026-08-26" + d["RecoveryAbandonRetrievalOffered"] = offered + return d +} + +const ( + promiseClause = "visszaszerezheted őket a helyreállítási kóddal" + cautiousClause = "nem tudjuk megállapítani" + deletionClause = "véglegesen töröljük" + writeToUs = "írj nekünk a törlés előtt" +) + +// ── SCENARIO A — package unchanged → the clause stands, byte-identical in meaning to before ───── +func TestR302_Render_A_PromiseKeptWhenStillTrue(t *testing.T) { + html := renderBackupPage(t, "backups_remote", abandonBannerData(true, "ok")) + + if !strings.Contains(html, deletionClause) { + t.Fatal("the deletion sentence is missing — that half is certain and must always render") + } + if !strings.Contains(html, promiseClause) { + t.Error("R-302: a customer who can genuinely still change their mind lost the retrieval clause. " + + "The grace period is explicitly NOT decorative; hedging a true sentence is its own dishonesty") + } +} + +// ── SCENARIO B/D/E (rendered) — cautious branch says what is true and names a route ───────────── +// +// RED-PROOF: remove the `{{if .RecoveryAbandonRetrievalOffered}}` conditional from layout.html and +// this fails on the first assertion — the false promise returns. +func TestR302_Render_B_CautiousBranchWhenNotKnowable(t *testing.T) { + html := renderBackupPage(t, "backups_remote", abandonBannerData(false, "ok")) + + if strings.Contains(html, promiseClause) { + t.Error("R-302: the banner still promises retrieval when the box cannot see that it is true — " + + "this is the sentence a customer reads after giving up their history") + } + if !strings.Contains(html, cautiousClause) { + t.Error("R-302: the cautious branch does not say we cannot determine it — silence is not the " + + "same as declining a claim") + } + if !strings.Contains(html, writeToUs) { + t.Error("R-302: the cautious branch names no route, and it is time-bounded — the customer must " + + "be told to write in BEFORE the deletion date") + } + // The certain half is unconditional. + if !strings.Contains(html, deletionClause) { + t.Error("R-302: the deletion sentence was lost with the promise — it is the part we DO know") + } +} + +// ── SCENARIO C — THE CO-RENDER. The card and the banner must not contradict each other ────────── +// +// Reachable per yesterday's reading: ResetOrphanedRepo clears RepoState then starts the countdown, but +// markOrphaned (offbox.go:804) has NO guard against an active countdown, so a later run finding the +// FRESH store unopenable re-raises the card while the countdown runs. +// +// RED-PROOF — THE ONE THAT MATTERS: replace the condition with the rejected proxy (hub fingerprint vs +// the CURRENT key, compared at render). In this state those differ, so the proxy answers "promise it" +// and the false promise returns on the very screen the card is declining it. That is why the pin was +// chosen over the obvious condition. +func TestR302_Render_C_CoRenderDoesNotContradictItself(t *testing.T) { + html := renderBackupPage(t, "backups_remote", abandonBannerData(false, "orphaned")) + + if !strings.Contains(html, "offbox-orphan-card") { + t.Fatal("the orphan card did not render — this test would then prove nothing about the co-render") + } + if !strings.Contains(html, deletionClause) { + t.Fatal("the banner did not render — likewise") + } + // The card says we cannot tell. The banner must not say the opposite one strip above it. + if strings.Contains(html, promiseClause) { + t.Error("R-302 CO-RENDER: the orphan card says we cannot determine whether the set-aside copies " + + "can be opened, and the banner above it tells the customer they can still retrieve them. " + + "One page, two answers, and the confident one is the wrong one") + } +} + +// ── SCENARIO F — no countdown → the undecided reminder ladder is untouched ────────────────────── +func TestR302_Render_F_NoCountdownLeavesTheLadderAlone(t *testing.T) { + d := splitTestData() + d["RecoveryBanner"] = true + d["RecoveryReminderTier"] = 14 // an undecided box, two weeks waiting + html := renderBackupPage(t, "backups_remote", d) + + for _, s := range []string{deletionClause, promiseClause, cautiousClause} { + if strings.Contains(html, s) { + t.Errorf("abandonment copy %q leaked onto a box with no countdown running", s) + } + } + if !strings.Contains(html, "Két hete") { + t.Error("the undecided reminder ladder changed — it is not in scope and must be byte-identical") + } +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 4a551dd..8efec53 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -953,6 +953,11 @@ func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) { data["AbandonActive"] = true data["AbandonDaysLeft"] = st.DaysLeft data["AbandonDate"] = st.DueAt.Format("2006-01-02") + // R-302: this block makes the SAME retrieval promise as the banner, under a different verb + // („visszaszerezhetők" vs the banner's „visszaszerezheted"), which is why it was a fourth + // instance nobody had counted. Same single derivation — fixing one surface and not the other + // would leave the page contradicting the strip above it. + data["AbandonRetrievalOffered"] = st.RetrievalStillOffered } else if st.PurgeRequested { // The store is deleted and the sealed package is on its way out. Say so rather than // showing nothing, or the page silently loses a thing the customer was watching. diff --git a/controller/internal/web/recovery_handlers.go b/controller/internal/web/recovery_handlers.go index 56766a0..122c640 100644 --- a/controller/internal/web/recovery_handlers.go +++ b/controller/internal/web/recovery_handlers.go @@ -626,6 +626,9 @@ func (s *Server) addRecoveryBanner(data map[string]interface{}, r *http.Request) if st.DaysLeft <= mark { data["RecoveryAbandonDays"] = st.DaysLeft data["RecoveryAbandonDate"] = st.DueAt.Format("2006-01-02") + // R-302: the retrieval clause is conditional; the deletion sentence is not. Taken + // from the read model so this surface cannot form its own opinion. + data["RecoveryAbandonRetrievalOffered"] = st.RetrievalStillOffered break } } diff --git a/controller/internal/web/templates/backups_remote.html b/controller/internal/web/templates/backups_remote.html index b868f0d..f50def0 100644 --- a/controller/internal/web/templates/backups_remote.html +++ b/controller/internal/web/templates/backups_remote.html @@ -188,9 +188,14 @@
A korábbi mentések törlése folyamatban
+ {{/* R-302: the FOURTH instance of the retrieval promise, under a different verb than the + banner's — which is why no earlier guard counted it. Same condition, same single + derivation: fixing the strip above and not this would leave one page contradicting the + other. The deletion and its date are certain and always render. */}} A kérésed szerint a korábbi távoli mentéseidet {{.AbandonDate}} napján véglegesen töröljük - (még {{.AbandonDaysLeft}} nap). Addig meggondolhatod magad: ha megvan a helyreállítási kódod, - a mentéseid visszaszerezhetők, és a törlés elmarad. + (még {{.AbandonDaysLeft}} nap). + {{if .AbandonRetrievalOffered}}Addig meggondolhatod magad: ha megvan a helyreállítási kódod, + a mentéseid visszaszerezhetők, és a törlés elmarad.{{else}}Hogy ezek még visszaszerezhetők-e a helyreállítási kóddal, azt innen nem tudjuk megállapítani — ha vissza szeretnéd kapni őket, írj nekünk a törlés előtt.{{end}}
Mégis visszaszerzem a kóddal