From b119301c6f4c70087da9c82a40dff7eebc3907e3 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 8 Oct 2026 08:00:08 +0200 Subject: [PATCH] =?UTF-8?q?R-243:=20offsite=5Fescrow=5Fpending=20=E2=80=94?= =?UTF-8?q?=20an=20operator=20alarm=20when=20off-site=20is=20on=20and=20th?= =?UTF-8?q?e=20escrow=20never=20done=20(7=20days);=2009=20decisions=20177-?= =?UTF-8?q?179;=2007=20R-899=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hub code unreleased; ships with tomorrow's hub release. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- .../architecture/07-backup-architecture.md | 12 ++ documentation/architecture/08-alarm-ladder.md | 1 + .../architecture/09-update-architecture.md | 18 ++ hub/CHANGELOG.md | 15 ++ hub/internal/api/handler.go | 3 + hub/internal/monitor/offsite.go | 6 + .../monitor/offsite_escrow_pending.go | 145 ++++++++++++++++ .../monitor/offsite_escrow_pending_test.go | 164 ++++++++++++++++++ hub/internal/notify/dispatcher.go | 4 + .../notify/r243_operator_only_test.go | 14 ++ 10 files changed, 382 insertions(+) create mode 100644 hub/internal/monitor/offsite_escrow_pending.go create mode 100644 hub/internal/monitor/offsite_escrow_pending_test.go create mode 100644 hub/internal/notify/r243_operator_only_test.go diff --git a/documentation/architecture/07-backup-architecture.md b/documentation/architecture/07-backup-architecture.md index 596b4c1f..f9a089a7 100644 --- a/documentation/architecture/07-backup-architecture.md +++ b/documentation/architecture/07-backup-architecture.md @@ -405,6 +405,18 @@ read **only when the storage cannot be read**: a fresh saved copy → not due; a unknown, as before. A storage that answers always wins, so a pruned archive still makes the tier due. Tests: `TestBackupDue_R894_*` (felhom-agent `internal/localapi`). +**[DESIGN — operator ruling 2026-10-08, `09` §3 decision 177; built on controller and agent main the same day, ships +with their next releases — R-899] A daytime press never moves the night's backup. Every night takes its own.** The +agent's due-check reads the newest archive on the tier, and a „Mentés most" press is an archive like any other, so a +press at 08:49 made the 24 h tier due at 08:49 the next day — after the window closed — and that night had no +whole-guest backup, no OS leg and no kernel step (demo-hp, 2026-10-07 → 08). Now the controller keeps a ledger of the +last successful press and the last successful SCHEDULED run per tier (`whole-guest-ledger.json` beside the quiesce +marker); a tier the agent calls „not due" is still due on the scheduled path when a press came after the last scheduled +success and that success is older than tonight's gate opening. Such a tier waits for the window (it never fires the +safety valve); a scheduled success inside tonight's window ends it. And a press reaches the agent as +`trigger=manual`, after which the agent runs no OS leg (before, a press ran it at once, in the day). Pinned by +`TestR899_*` (controller) and `TestAfterPrimaryBackup` (agent). + **[FACT, 2026-10-04 — agent v0.140.0, `11-os-updates.md` §8.1] The OS leg closes the night.** After the whole-guest backup (the controller drives it, inside [W+2h, W+6h)) ends SUCCESSFULLY on the primary tier, the agent waits 90 s and runs the guest's Debian fast lane — still holding the host-wide heavy-op gate, so it never overlaps a diff --git a/documentation/architecture/08-alarm-ladder.md b/documentation/architecture/08-alarm-ladder.md index 30f1424d..01f5a9af 100644 --- a/documentation/architecture/08-alarm-ladder.md +++ b/documentation/architecture/08-alarm-ladder.md @@ -352,6 +352,7 @@ one info line beside `host_crash_restart`; `operatorOnlyEvents`, pinned by | `host_crash_guard_tripped` | error | the guard tripped: the next crash leaves the box OFF | the re-arm → `host_crash_guard_rearmed` (info) | `api/crash_test.go` | | `host_kernel_oops` | warning | a kernel oops this boot (taint D) — the box keeps running | — (once per boot) | `api/crash_test.go` | | `agent_behind` | warning | the box has run an agent OLDER than the vouched one for **7 days** (from when the hub first saw it behind; an unreadable version never counts; nothing vouched → nothing behind) — agents update only by a per-box signed job (R-530), so this is the "nobody signed for this box" alarm (hub v0.135.0) | the box reports the vouched agent (or newer) | `osupdates/r530_agent_alarm_test.go` | +| `offsite_escrow_pending` | warning | off-site is ON, the escrow is NOT done (`escrow_state` ≠ `escrowed`) and there has been no successful off-site run for **7 days** — counted from the last successful run, else from the first host report the hub received (an unknown anchor never fires). `offsite_stale` deliberately leaves this state out (pending is the designed onboarding state, `07` §6.1), so until hub main 2026-10-08 a box whose household never did the escrow step never backed up off-site and nothing fired (R-243). Re-sent at most once a week while true; the raise time is persisted, so a hub restart neither re-mails nor forgets (`09` §3 decision 179) | the escrow done, off-site off, or a successful run after the alarm → `offsite_escrow_pending_cleared` (info) | `monitor/offsite_escrow_pending_test.go` `TestR243_*`; `notify/r243_operator_only_test.go` | | `floor_raise_skipped` | warning | a GLOBAL controller floor was raised and one or more boxes keep their own LOWER per-customer floor, so the raise does not move them — ONE mail naming them all (R-604, hub v0.135.0) | — (one per raise) | `web/r604_floor_held_back_test.go` | - **`unknown` never alarms** (R-96 rule 3): a probe that could not ask is neither up nor down. An `unknown` report diff --git a/documentation/architecture/09-update-architecture.md b/documentation/architecture/09-update-architecture.md index 6d850f0a..7dd36daf 100644 --- a/documentation/architecture/09-update-architecture.md +++ b/documentation/architecture/09-update-architecture.md @@ -915,6 +915,24 @@ its length, and both fixes cost something the household would notice — operato 127. **The agent's three by-design abilities (`03` §3.1) stay for now**; revisited before the first paying customer. *Operator ruling 2026-10-05.* (R-861) +### 2026-10-08 (07:12) — operator rulings on R-899 and the day's work (recorded before the work) + +177. **R-899: a daytime whole-guest backup never moves the night's backup. Every night takes its own** (option A). A + household's „Mentés most" press keeps its own record but does not count for „has tonight's backup run". The 20-hour + rule was refused: it would not have caught the 2026-10-07 case (a press at 08:49, a window opening at 04:30). + *Operator ruling 2026-10-08 07:12.* Built the same day (controller `internal/quiesce/nightowed.go`; agent: no OS leg + after a press), ships with the next releases; `07` §6.1. +178. **Today (2026-10-08): progress with other work, without touching tonight's kernel night** (the second one, 8→9, on + demo-hp and demo-felhom). No change to those boxes, Tester 1 or the hub's running version; code is built, tested and + committed today and released tomorrow. *Operator ruling 2026-10-08 07:12.* +179. **R-243's line: `offsite_escrow_pending` fires after 7 days** with off-site ON, the escrow not done and no successful + off-site run. Options: 48 h (`offsite_stale`'s line — but that assumes runs are expected, and pending is the designed + onboarding state, so a slow household would be reported in its first days); 72 h (`expected_backup_missed` — the same + objection); **7 days** (`08` §6.3's line for „a box needing an action nobody took", `os_update_stale` / + `agent_behind`, and the off-site whole-guest cadence). Chosen 7 days: a household that does the step in its first week + is never reported, and one that does not is reported once a week. *Decided by CC (the brief asked CC to pick) — + operator may reverse.* `08` §6.3. + ### 2026-10-07 (evening) — operator rulings on the first kernel night (recorded before the work) 174. **The household kernel mail text stays as written**, plus a reply address (a `Reply-To` that reaches the operator). diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 7ccc3085..be98f1f1 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,3 +1,18 @@ +## Unreleased (2026-10-08) — an alarm when a box never backs up off-site because its escrow is pending (R-243; `09` §3 decision 179) — ships with tomorrow's hub release + +**Operator action on deploy: none.** Expect ONE `offsite_escrow_pending` mail for **Tester 2** on the first sweep after +the deploy: its latest report (2026-10-04) says off-site ON, escrow `pending`, no successful run ever — the state the +operator believes it is in (decision 170). + +- **R-243:** `offsite_stale` deliberately ignores a box whose escrow is not `escrowed` (pending is the designed onboarding + state), so a household that never does the escrow step — or a box held in `awaiting_recovery_key` — never backed up + off-site and nothing fired. New operator-only `offsite_escrow_pending` (warning): off-site ON, escrow not done, no + successful off-site run for **7 days** (from the last success, else from the first host report; an unknown anchor never + fires). Re-sent at most once a week while true; the raise time is persisted in the settings table (`os_alarm:` key), so + a hub restart neither re-mails nor forgets; clears with one `offsite_escrow_pending_cleared` info line. + `monitor/offsite_escrow_pending.go`; both types in `notify.operatorOnlyEvents` and the event allowlist. Tests + `TestR243_*` (7; red-proved: without the call in `Check` no mail) and `TestR243_EscrowPendingIsOperatorOnly`. `08` §6.3. + ## v0.143.1 — a household's reply reaches the operator; a told kernel that is gone is retried (`09` §3 decisions 174, 176; R-898) (2026-10-07) **Operator action on deploy: none.** Deployed with the operator attending (decision 162). diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 8393c14e..e6260553 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -2166,6 +2166,9 @@ var allowedEventTypes = map[string]bool{ "offsite_proof_empty": true, // R-431 — the hub raises this itself; allowlisted so a hub-origin event is never 400'd. "offsite_snapshots_dropped": true, + // R-243 — the hub raises these itself (monitor/offsite_escrow_pending.go); allowlisted like the line above. + "offsite_escrow_pending": true, + "offsite_escrow_pending_cleared": true, // controller v0.289.0 (decision 69): the customer-chosen deletion of set-aside history is deferred // to the operator — the box's append-only key cannot delete. Operator-only (notify.operatorOnlyEvents). "offbox_abandon_deferred": true, diff --git a/hub/internal/monitor/offsite.go b/hub/internal/monitor/offsite.go index 6e94f2a1..982d5ec2 100644 --- a/hub/internal/monitor/offsite.go +++ b/hub/internal/monitor/offsite.go @@ -394,6 +394,8 @@ func (oc *OffsiteChecker) Check() { seen[c.CustomerID] = true off := parseOffsite(c.ReportJSON) if off == nil { + // R-243: off-site gone → a raised escrow-pending alarm clears. + oc.checkEscrowPending(c.CustomerID, nil) delete(oc.fillStates, c.CustomerID) // vanished object (disabled / downgraded) → re-arm delete(oc.staleStates, c.CustomerID) delete(oc.dropStates, c.CustomerID) @@ -434,6 +436,10 @@ func (oc *OffsiteChecker) Check() { } oc.staleStates[c.CustomerID] = newStale + // R-243 (offsite_escrow_pending.go): the state the staleness signal deliberately leaves out — off-site ON, + // escrow never done, so no run ever starts. Persisted, weekly, operator-only. + oc.checkEscrowPending(c.CustomerID, off) + // SNAPSHOT-DROP (R-431). Escalation-only, exactly like the two signals above: one deletion // produces ONE alarm, not one per report cycle. The baseline then moves to the new value, so a // SECOND deletion later is still caught — from the new floor. diff --git a/hub/internal/monitor/offsite_escrow_pending.go b/hub/internal/monitor/offsite_escrow_pending.go new file mode 100644 index 00000000..58c441ec --- /dev/null +++ b/hub/internal/monitor/offsite_escrow_pending.go @@ -0,0 +1,145 @@ +package monitor + +import ( + "encoding/json" + "fmt" + "time" +) + +// ── R-243 (2026-10-08) — A BOX THAT NEVER BACKS UP OFF-SITE BECAUSE ITS ESCROW IS PENDING ───────── +// +// `offsite_stale` deliberately ignores a box whose escrow is not `escrowed`: pending is the designed +// onboarding state (`07` §6.1, Tier-3 PAUSED — a run without the household's recovery code would write a +// copy nobody could open). That exclusion is right, and it left one state unobserved: a household that +// never does the escrow step (or a box held in `awaiting_recovery_key`, the R-241 state) never backs up +// off-site, and no alarm of any kind fires — `offsite_stale` needs `escrowed`, the delivery checker skips +// the applied shape, and `backup_failed` needs a run that never starts. +// +// THE SIGNAL. `offsite_escrow_pending` (warning, OPERATOR-ONLY — the household already sees the reminder +// on every page; this is the operator's „they have not acted" line): off-site is ON, the escrow is not +// `escrowed`, and there has been no successful off-site run for escrowPendingAfter. The clock starts at +// the last successful run when there was one (a box that fell back to pending), otherwise at the first +// host report the hub received from the customer (when the box became observable). An unknown anchor +// never fires: a box that has never sent a host report has its own staleness alarms, and firing here on +// a guess is the 2026-07-23 cry-wolf. +// +// THE LINE: 7 days. `offsite_stale`'s 48 h assumes runs are EXPECTED; here they are not yet, because +// the household is in its onboarding step, so the line must be longer. 7 days is `08` §6.3's line for „a +// box needing an action nobody took" (`os_update_stale`, `agent_behind`) and the off-site whole-guest +// cadence, so a household that does the step in its first week is never reported. +// +// ONE MAIL PER BOX, THEN QUIET FOR A WEEK; it is re-sent weekly while still true (`08` §6.3's re-send +// rule) and CLEARS when the state ends (escrowed, off-site off, or a successful run after the anchor) — +// with one info line, recorded and never mailed, so the operator who was told it broke can see it healed. +// The raise time is persisted (`os_alarm:` setting), so a hub restart neither re-mails nor forgets. +// +// Pinned by TestR243_* (offsite_escrow_pending_test.go). +const escrowPendingAfter = 7 * 24 * time.Hour + +const ( + eventEscrowPending = "offsite_escrow_pending" + eventEscrowPendingCleared = "offsite_escrow_pending_cleared" +) + +func escrowPendingKey(customerID string) string { return "offsite_escrow_pending:" + customerID } + +// escrowPendingAnchor returns when the no-off-site clock started, and whether it is known. +func (oc *OffsiteChecker) escrowPendingAnchor(customerID string, off *offsiteReport) (time.Time, bool) { + if off.LastSuccess != "" { + if t, err := time.Parse(time.RFC3339, off.LastSuccess); err == nil { + return t, true + } + } + first, err := oc.store.GetFirstHostReportAt(customerID) + if err != nil || first.IsZero() { + return time.Time{}, false + } + return first, true +} + +// inEscrowPending is the state: off-site on, escrow not done. +func inEscrowPending(off *offsiteReport) bool { + return off != nil && off.Enabled && off.EscrowState != "escrowed" +} + +// checkEscrowPending raises, re-sends weekly, or clears the signal for one customer. Caller holds oc.mu. +func (oc *OffsiteChecker) checkEscrowPending(customerID string, off *offsiteReport) { + key := escrowPendingKey(customerID) + raised := oc.store.OSAlarmRaised(key) + now := oc.now() + + clear := func(why string) { + if raised.IsZero() { + return + } + _ = oc.store.SetOSAlarmRaised(key, time.Time{}) + msg := fmt.Sprintf("Customer %s: off-site backup is no longer held by a pending escrow (%s).", customerID, why) + details, _ := json.Marshal(map[string]any{"customer_id": customerID, "reason": why}) + oc.logger.Printf("[INFO] Offsite escrow pending CLEARED: %s (%s)", customerID, why) + if _, err := oc.store.SaveEvent(customerID, eventEscrowPendingCleared, "info", msg, string(details), "hub"); err != nil { + oc.logger.Printf("[WARN] Failed to save %s for %s: %v", eventEscrowPendingCleared, customerID, err) + return + } + if oc.onEvent != nil { + oc.onEvent(customerID, eventEscrowPendingCleared, "info", msg, string(details), "hub") + } + } + + if !inEscrowPending(off) { + why := "the escrow is done" + if off == nil || !off.Enabled { + why = "off-site backup is off" + } + clear(why) + return + } + anchor, ok := oc.escrowPendingAnchor(customerID, off) + if !ok { + oc.logger.Printf("[DEBUG] Offsite escrow pending: %s — no anchor (no successful run, no host report) — not judged", customerID) + return + } + if !raised.IsZero() && anchor.After(raised) { + // A run succeeded after the alarm and the box fell back to pending again: a new episode. + clear("a run succeeded after the alarm") + raised = time.Time{} + } + age := now.Sub(anchor) + if age <= escrowPendingAfter { + return + } + if !raised.IsZero() && now.Sub(raised) < 7*24*time.Hour { + return // quiet for a week + } + since := "the box first reported" + if off.LastSuccess != "" { + since = "its last successful off-site run" + } + state := off.EscrowState + if state == "" { + state = "not started" + } + extra := "" + if off.State != "" { + extra = fmt.Sprintf(" The box declares off-site state %q.", off.State) + } + msg := fmt.Sprintf("Customer %s: off-site backup is ON but has not run for %s since %s — the escrow step is %s, so no off-site copy is being made.%s "+ + "The household sees the reminder on every page; this mail is for you: they have not acted.", + customerID, age.Round(time.Hour), since, state, extra) + details, _ := json.Marshal(map[string]any{ + "customer_id": customerID, "escrow_state": off.EscrowState, "offsite_state": off.State, + "last_success": off.LastSuccess, "anchor": anchor.UTC().Format(time.RFC3339), + "after": escrowPendingAfter.String(), + }) + if err := oc.store.SetOSAlarmRaised(key, now); err != nil { + oc.logger.Printf("[WARN] Offsite escrow pending: could not record the raise for %s (%v) — not sending, so a restart cannot mail twice", customerID, err) + return + } + oc.logger.Printf("[INFO] Offsite escrow pending: %s (%s since %s)", customerID, age.Round(time.Hour), since) + if _, err := oc.store.SaveEvent(customerID, eventEscrowPending, "warning", msg, string(details), "hub"); err != nil { + oc.logger.Printf("[WARN] Failed to save %s for %s: %v", eventEscrowPending, customerID, err) + return + } + if oc.onEvent != nil { + oc.onEvent(customerID, eventEscrowPending, "warning", msg, string(details), "hub") + } +} diff --git a/hub/internal/monitor/offsite_escrow_pending_test.go b/hub/internal/monitor/offsite_escrow_pending_test.go new file mode 100644 index 00000000..e5b35644 --- /dev/null +++ b/hub/internal/monitor/offsite_escrow_pending_test.go @@ -0,0 +1,164 @@ +package monitor + +import ( + "io" + "log" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// R-243 — a box whose off-site backup is ON but whose escrow was never done never backs up off-site, and +// until now nothing fired. Every test asserts the CONSEQUENCE: does the operator get the mail (an event +// through onEvent), how many, and when does it stop? + +type r243Event struct{ typ, sev, msg string } + +func r243Setup(t *testing.T, firstReportAgo time.Duration) (*store.Store, *OffsiteChecker, *[]r243Event) { + t.Helper() + st := newDiskStore(t) // customer c1, host h1 + if err := st.SaveHostReport("h1", "c1", []byte(`{}`), store.HostReportDenorm{}); err != nil { + t.Fatal(err) + } + if err := st.SetHostReportsReceivedAtForTest("c1", sqliteAgo(firstReportAgo)); err != nil { + t.Fatal(err) + } + var evs []r243Event + oc := NewOffsiteChecker(st, 0, func(_, typ, sev, msg, _, _ string) { + evs = append(evs, r243Event{typ, sev, msg}) + }, log.New(io.Discard, "", 0)) + return st, oc, &evs +} + +func r243Count(evs []r243Event, typ string) int { + n := 0 + for _, e := range evs { + if e.typ == typ { + n++ + } + } + return n +} + +// THE ONE THAT MATTERS: on for 8 days, escrow pending, never ran → ONE warning to the operator; a second sweep +// sends nothing more. RED-PROOF: remove the oc.checkEscrowPending call from Check → zero events → FAILS. +func TestR243_PendingEightDays_OneMail(t *testing.T) { + st, oc, evs := r243Setup(t, 8*24*time.Hour) + saveOffsiteReport(t, st, "c1", offsiteJSON(true, "pending", "", "", 0, 50)) + oc.Check() + oc.Check() + if n := r243Count(*evs, eventEscrowPending); n != 1 { + t.Fatalf("offsite_escrow_pending mails = %d, want exactly 1 (events %v)", n, *evs) + } + e := (*evs)[0] + if e.sev != "warning" || !strings.Contains(e.msg, "escrow step is pending") { + t.Fatalf("event = %+v, want a warning naming the pending escrow", e) + } + if r243Count(*evs, "offsite_stale") != 0 { + t.Fatalf("a pending box must not raise offsite_stale (onboarding is not staleness): %v", *evs) + } +} + +// Inside the 7-day line: the household may still be doing the step — nothing. +func TestR243_PendingSixDays_Silent(t *testing.T) { + st, oc, evs := r243Setup(t, 6*24*time.Hour) + saveOffsiteReport(t, st, "c1", offsiteJSON(true, "pending", "", "", 0, 50)) + oc.Check() + if len(*evs) != 0 { + t.Fatalf("6 days pending must be silent; got %v", *evs) + } +} + +// Quiet for a week, then re-sent while still true; a hub restart in between neither re-mails nor forgets. +func TestR243_WeeklyResendAndRestartSafe(t *testing.T) { + st, oc, evs := r243Setup(t, 8*24*time.Hour) + saveOffsiteReport(t, st, "c1", offsiteJSON(true, "pending", "", "", 0, 50)) + oc.Check() + + // A hub restart: a new checker over the same store. + oc2 := NewOffsiteChecker(st, 0, func(_, typ, sev, msg, _, _ string) { + *evs = append(*evs, r243Event{typ, sev, msg}) + }, log.New(io.Discard, "", 0)) + oc2.Check() + if n := r243Count(*evs, eventEscrowPending); n != 1 { + t.Fatalf("after a restart: %d mails, want still 1", n) + } + oc2.now = func() time.Time { return time.Now().Add(6 * 24 * time.Hour) } + oc2.Check() + if n := r243Count(*evs, eventEscrowPending); n != 1 { + t.Fatalf("6 days after the mail: %d mails, want still 1 (quiet for a week)", n) + } + oc2.now = func() time.Time { return time.Now().Add(8 * 24 * time.Hour) } + oc2.Check() + if n := r243Count(*evs, eventEscrowPending); n != 2 { + t.Fatalf("8 days after the mail and still pending: %d mails, want 2 (weekly re-send)", n) + } +} + +// It clears when the escrow is done, with one info line (recorded, never mailed), and the next episode alarms again. +func TestR243_ClearsWhenEscrowed(t *testing.T) { + st, oc, evs := r243Setup(t, 8*24*time.Hour) + saveOffsiteReport(t, st, "c1", offsiteJSON(true, "pending", "", "", 0, 50)) + oc.Check() + saveOffsiteReport(t, st, "c1", offsiteJSON(true, "escrowed", "", "", 0, 50)) + oc.Check() + if n := r243Count(*evs, eventEscrowPendingCleared); n != 1 { + t.Fatalf("cleared events = %d, want 1 (events %v)", n, *evs) + } + if !st.OSAlarmRaised(escrowPendingKey("c1")).IsZero() { + t.Fatal("the raise record must be cleared") + } + oc.Check() + if n := r243Count(*evs, eventEscrowPendingCleared); n != 1 { + t.Fatalf("the clear is one line, not one per sweep: %d", n) + } +} + +// A box that ran once and then fell back to pending counts from its last SUCCESSFUL run. +func TestR243_FellBackToPending_CountsFromLastSuccess(t *testing.T) { + st, oc, evs := r243Setup(t, 60*24*time.Hour) + last := time.Now().UTC().Add(-3 * 24 * time.Hour).Format(time.RFC3339) + saveOffsiteReport(t, st, "c1", `{"enabled":true,"escrow_state":"pending","last_run":"`+last+`","last_status":"ok","last_success":"`+last+`"}`) + oc.Check() + if len(*evs) != 0 { + t.Fatalf("last success 3 days ago → silent; got %v", *evs) + } + old := time.Now().UTC().Add(-9 * 24 * time.Hour).Format(time.RFC3339) + saveOffsiteReport(t, st, "c1", `{"enabled":true,"escrow_state":"pending","last_run":"`+old+`","last_status":"ok","last_success":"`+old+`","state":"awaiting_recovery_key"}`) + oc.Check() + if n := r243Count(*evs, eventEscrowPending); n != 1 { + t.Fatalf("last success 9 days ago and pending → 1 mail; got %v", *evs) + } + if !strings.Contains((*evs)[0].msg, "awaiting_recovery_key") { + t.Errorf("the mail should name the box's declared state; got %q", (*evs)[0].msg) + } +} + +// Off-site OFF, or already escrowed, never raises this. +func TestR243_OffOrEscrowedNeverRaises(t *testing.T) { + for _, js := range []string{ + offsiteJSON(false, "pending", "", "", 0, 50), + offsiteJSON(true, "escrowed", time.Now().UTC().Format(time.RFC3339), "ok", 0, 50), + } { + st, oc, evs := r243Setup(t, 30*24*time.Hour) + saveOffsiteReport(t, st, "c1", js) + oc.Check() + if r243Count(*evs, eventEscrowPending) != 0 { + t.Fatalf("report %s raised offsite_escrow_pending: %v", js, *evs) + } + } +} + +// No anchor (no host report, no success) → not judged (never a guess-fire). +func TestR243_NoAnchor_Silent(t *testing.T) { + st := newDiskStore(t) + var evs []r243Event + oc := NewOffsiteChecker(st, 0, func(_, typ, sev, msg, _, _ string) { evs = append(evs, r243Event{typ, sev, msg}) }, log.New(io.Discard, "", 0)) + saveOffsiteReport(t, st, "c1", offsiteJSON(true, "pending", "", "", 0, 50)) + oc.Check() + if len(evs) != 0 { + t.Fatalf("no anchor must not fire: %v", evs) + } +} diff --git a/hub/internal/notify/dispatcher.go b/hub/internal/notify/dispatcher.go index bd876156..24f296e4 100644 --- a/hub/internal/notify/dispatcher.go +++ b/hub/internal/notify/dispatcher.go @@ -703,6 +703,10 @@ var operatorOnlyEvents = map[string]bool{ // the snapshots still hold the data and telling a customer "your backups were deleted" // would be wrong on the usual reading. "offsite_snapshots_dropped": true, + // R-243 (2026-10-08): off-site ON, the escrow never done, no off-site copy for 7 days. The household already sees + // the reminder on every page; this is the operator's „they have not acted" line. Listed in the SAME commit. + "offsite_escrow_pending": true, + "offsite_escrow_pending_cleared": true, // v0.127.0 (decisions 68–69, R-820/R-822). The off-site key registrar, its daily check and the // clean-up window: custody facts about key lines and fingerprints — the household can take no // action on any of them. Listed in the SAME commit that mints them. diff --git a/hub/internal/notify/r243_operator_only_test.go b/hub/internal/notify/r243_operator_only_test.go new file mode 100644 index 00000000..00a85c82 --- /dev/null +++ b/hub/internal/notify/r243_operator_only_test.go @@ -0,0 +1,14 @@ +package notify + +import "testing" + +// R-243: the escrow-pending alarm and its clear line are the operator's — the household already sees the reminder +// on every page, and a missing customerMessages entry would mail them raw operator English. Red-proof: delete either +// line from operatorOnlyEvents and this fails naming it. +func TestR243_EscrowPendingIsOperatorOnly(t *testing.T) { + for _, e := range []string{"offsite_escrow_pending", "offsite_escrow_pending_cleared"} { + if !operatorOnlyEvents[e] { + t.Errorf("%s is not operator-only", e) + } + } +}