diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 64f02664..b9285267 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,3 +1,17 @@ +## v0.151.0 — a drive that comes back mails too (R-1049) (2026-10-15) + +- **`storage_reconnected` → `storage_disconnected`** and **`health_recovered` → `health_degraded`** join + `recoveredPairedDownTypes`: the operator hears both edges, the household hears the return only if it heard the loss. + Measured 2026-10-14 night on Tester 1: two pulled drives mailed „disconnected" twice; both returns and both health + recoveries were received (severity info) and mailed to nobody — the severity gate dropped them + (`documentation/audits/media-drive-events-2026-10-14/K8-hub-mails.txt`). +- `cooldownEndedBy`: a new `storage_disconnected` ends the previous `storage_reconnected` cooldown (and + `health_degraded` the `health_recovered` one), so the next return inside the hour mails again (the night's two pulls + were 26 minutes apart). +- Tests: `TestR1049_DriveBackMailsBothEdges` (red-proved: without the two pairs the household gets 1 mail, not 2); + `TestDriveLostAgainAfterReplugMailsAgain` now counts the return mail too (3 household / 6 operator). +- **Not deployed** — the operator's „yes" first (`09` §3 decision 162). + ## v0.150.0 — an app's repair after a power cut reaches the operator, counts only (R-1007) (2026-10-14) Needs **controller ≥ 0.311.0** to see anything (it sends the event). Older boxes never send it — nothing changes for them. diff --git a/hub/internal/notify/dispatcher.go b/hub/internal/notify/dispatcher.go index 05abde5b..1f1834d7 100644 --- a/hub/internal/notify/dispatcher.go +++ b/hub/internal/notify/dispatcher.go @@ -157,6 +157,13 @@ var recoveredPairedDownTypes = map[string][]string{ // no customer hears either, which is correct — neither store belongs to a customer. "pbsdr_box_recovered": {"pbsdr_box_unreachable"}, "offsite_box_recovered": {"offsite_box_unreachable"}, + // R-1049 (2026-10-15). MEASURED 2026-10-14 night on Tester 1: two pulled drives mailed „unexpectedly + // disconnected" (error) twice, and the hub RECEIVED both `storage_reconnected` and both `health_recovered` (severity + // info) and mailed none — the severity gate dropped them, so the operator could not tell a returned drive from one + // still gone. Same reasoning and same necessity as the entries above; the customer hears the return iff it heard the + // loss (the pairing gate). Pinned by TestR1049_DriveBackMailsBothEdges. + "storage_reconnected": {"storage_disconnected"}, + "health_recovered": {"health_degraded"}, } // severityNotifies reports whether a severity triggers email notifications. warning / error / critical @@ -1017,6 +1024,10 @@ func (d *Dispatcher) replyToFor(to string) string { // Pinned by TestDriveLostAgainAfterReplugMailsAgain. var cooldownEndedBy = map[string][]string{ "storage_reconnected": {"storage_disconnected"}, + // R-1049: a new loss ends the previous return's cooldown, so the NEXT return mails again (both pulls of the + // 2026-10-14 night were 26 minutes apart — inside the hour). + "storage_disconnected": {"storage_reconnected"}, + "health_degraded": {"health_recovered"}, } // forgetIncidentCooldowns drops both channels' cooldowns for customerID and each down type, including diff --git a/hub/internal/notify/r1049_drive_back_test.go b/hub/internal/notify/r1049_drive_back_test.go new file mode 100644 index 00000000..58b5ce0c --- /dev/null +++ b/hub/internal/notify/r1049_drive_back_test.go @@ -0,0 +1,55 @@ +package notify + +import ( + "io" + "log" + "testing" +) + +// TestR1049_DriveBackMailsBothEdges pins the CONSEQUENCE of R-1049: a drive that is pulled and comes back mails the +// operator BOTH edges, twice in a row inside the hour (the 2026-10-14 night: pulls at 22:55 and 23:21), and the household +// hears the return only if it heard the loss. Measured before the fix: the hub received `storage_reconnected` and +// `health_recovered` and logged no notification at all (audits/media-drive-events-2026-10-14/K8-hub-mails.txt). +// +// COMPANION RED-PROOF (2026-10-15): remove the two R-1049 entries from recoveredPairedDownTypes → the operator gets 4 +// mails, not 8, and the household 1, not 2; this test fails. Restored. +func TestR1049_DriveBackMailsBothEdges(t *testing.T) { + st := newDispStore(t) + if err := st.SaveNotificationPrefs("on", "house-on@example.com", []string{"storage_disconnected"}, 6); err != nil { + t.Fatal(err) + } + if err := st.SaveNotificationPrefs("off", "house-off@example.com", []string{"backup_failed"}, 6); err != nil { + t.Fatal(err) + } + d := NewDispatcher(st, "test-key", "from@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0)) + sent := captureSeam(d) + + // One pull per customer for the household leg: the customer pairing compares SECOND-granular send times and a tie + // is „no mail" by design (flap-safe), so a second pull in the same test second would be skipped for a reason that + // has nothing to do with R-1049. The operator leg has no pairing: two pulls inside the hour, both edges each time. + for _, c := range []string{"on", "off"} { + d.ProcessEvent(c, "storage_disconnected", "error", "Meghajtó váratlanul leválasztva: Media B", `{"label":"Media B"}`, "controller") + d.ProcessEvent(c, "storage_reconnected", "info", "Meghajtó újra csatlakoztatva: Media B", `{"label":"Media B"}`, "controller") + } + if got := len(mailsFor(*sent, "house-on@example.com")); got != 2 { + t.Fatalf("household that hears losses: %d mails, want 2 (the loss and the return)", got) + } + for _, c := range []string{"on", "off"} { + d.ProcessEvent(c, "storage_disconnected", "error", "Meghajtó váratlanul leválasztva: Media C", `{"label":"Media C"}`, "controller") + d.ProcessEvent(c, "storage_reconnected", "info", "Meghajtó újra csatlakoztatva: Media C", `{"label":"Media C"}`, "controller") + } + if got := len(mailsFor(*sent, "op@felhom.eu")); got != 8 { + t.Fatalf("operator: %d mails, want 8 (two customers × two pulls × loss + return)", got) + } + if got := len(mailsFor(*sent, "house-off@example.com")); got != 0 { + t.Fatalf("household that hears no losses: %d mails, want 0 — a return it was never told about is noise", got) + } + + // health_recovered pairs with health_degraded for the operator the same way. + d.ProcessEvent("on", "health_degraded", "warning", "Rendszer állapot romlott (volt: ok)", `{}`, "controller") + before := len(mailsFor(*sent, "op@felhom.eu")) + d.ProcessEvent("on", "health_recovered", "info", "Rendszer állapot helyreállt: ok (volt: warn)", `{}`, "controller") + if got := len(mailsFor(*sent, "op@felhom.eu")); got != before+1 { + t.Fatalf("health_recovered: the operator got %d new mail(s), want 1", got-before) + } +} diff --git a/hub/internal/notify/storage_reconnect_cooldown_test.go b/hub/internal/notify/storage_reconnect_cooldown_test.go index b6793bb8..2a6114fb 100644 --- a/hub/internal/notify/storage_reconnect_cooldown_test.go +++ b/hub/internal/notify/storage_reconnect_cooldown_test.go @@ -30,13 +30,14 @@ func TestDriveLostAgainAfterReplugMailsAgain(t *testing.T) { d.ProcessEvent(c, "storage_reconnected", "info", "drive back", `{}`, "controller") d.ProcessEvent(c, "storage_disconnected", "error", "drive lost again", `{}`, "controller") } - if got := len(mailsFor(*sent, "house-on@example.com")); got != 2 { - t.Fatalf("household that switched it on: %d lost-drive mails, want 2 (one per loss)", got) + // R-1049 (2026-10-15): the return now mails too — 2 losses + 1 return. + if got := len(mailsFor(*sent, "house-on@example.com")); got != 3 { + t.Fatalf("household that switched it on: %d mails, want 3 (one per loss + the return, R-1049)", got) } if got := len(mailsFor(*sent, "house-off@example.com")); got != 0 { t.Fatalf("household that did not switch it on got %d mails, want 0", got) } - if got := len(mailsFor(*sent, "op@felhom.eu")); got != 4 { - t.Fatalf("operator: %d mails, want 4 (two losses × two customers)", got) + if got := len(mailsFor(*sent, "op@felhom.eu")); got != 6 { + t.Fatalf("operator: %d mails, want 6 (two losses + one return, × two customers; R-1049)", got) } }