8.0 KiB
REPORT — Hub notification train v0.71.0: F11 + F12 + F14-light (2026-07-22)
Task: paired recovery notifications, prefs seeding at claim, empty-email no-clobber guard,
priority headers, operator test leg — all in hub/; felhom-controller read-only reference.
Origin: documentation/audits/AUDIT-power-outage-recovery-2026-07-22.md (F11/F12/F14-light).
Baselines (live-verified before starting)
| Repo | main @ start | → shipped |
|---|---|---|
| felhom.eu | 5b35023574173e17f9ee968ba09b6ab34616a322, hub 0.70.1 in manifests/hub.yaml |
hub 0.71.0 — code c766c8a, manifest f1da171 |
| felhom-controller | 0eba37d5cdc6 v0.160.0 |
unchanged (read-only) |
Phase 0a — controller prefs-push fact (read-only, cited)
Does the startup push fire for a never-configured customer, and with what email? It does NOT
fire: felhom-controller/controller/cmd/controller/main.go:821–830 guards the startup sync with
if prefs.Email != "" — an unconfigured box pushes nothing. The settings-save leg
(internal/web/handlers.go:1532–1545, the 2026-07-15 wipe-guard) refuses empty-email-with-events
outright; only the intentional all-off case (empty email + zero events) pushes an empty email, and
that push is legitimate. Verdict: the clobber was LATENT on controller 0.160.0, not live — the
hub-side guard shipped as the belt for older/rogue controllers. The debug re-sync
(internal/web/handler_debug.go:432) pushes stored prefs as-is. Test-leg endpoint the UI invokes:
POST /settings/notifications/test (internal/web/server.go:395 →
settingsNotificationsTestHandler, handlers.go:1616) → notifier.SendTest() → hub
POST /api/v1/event with eventType test.
Phase 0b — Resend custom-headers probe
One real mail via the Resend API (from monitoring@felhom.eu, to admin@felhom.eu, subject
"[Felhom] header probe") with "headers": {"X-Priority":"1","Importance":"high"} — HTTP 200,
mail id 34d3f7f3-849f-432a-ac8e-eb479a1c6695. Key sourced from the k8s Secret into the shell env
only; never written to any file or log. Mechanism GO.
Files modified (commit c766c8a, docs+code; manifest bump f1da171)
hub/internal/store/store.go—LastCustomerSentAt(pairing-evidence query, uses the(customer_id, created_at DESC)index),SeedNotificationPrefs(INSERT OR IGNORE, empty-email no-op);stringsimport.hub/internal/notify/dispatcher.go—priorityHeaders;recoveredPairedDownTypes; recovery branch inProcessEvent(before the severity gate;severityNotifiesbyte-untouched);processRecovery(operator = existingprocessOperator; customer = blocked → prefs-nil/email → pairing → prefs cooldown; INFO logs on every gated skip);sendEmailFn/sendEmailgrewheaders map[string]string(payload carries"headers"only when non-empty; all callers updated);sendTestEmailoperator copy with forced priority headers + latent nil-prefs panic fixed (GetNotificationPrefsreturns(nil, nil)— the old code dereferencedprefs.Email).hub/internal/notify/templates.go— ✅ icon for*_recovered(eventType-aware, severity fallback);customerMessages["host_recovered"].hub/internal/claim/engine.go—defaultSeedEvents(critical-only) + seed call inMarkClaimed(WARN-and-continue on failure; claim never hinges on notification plumbing).hub/internal/api/handler.go—handleSavePreferencesempty-email no-clobber guard (INFO log).- Tests:
notifications_seed_test.go(store),dispatcher_recovery_test.go(notify),seed_test.go(claim),preferences_guard_test.go(api);dispatcher_test.goseam signatures. - Docs:
hub/CHANGELOG.md,CONTEXT.md(4 rulings),REUSE.md(§Alerting rewritten — serves as the notification-plane map; no standalone notification feature doc exists, noted below),00-capability-map.md(§F rows),ROADMAP.md(R-68 SHIPPED, R-69 minted for F14-full).
Tests — 449 → 466 (17 new), full suite green (go build && go vet && go test ./... exit 0)
| Group | Test | Result |
|---|---|---|
| A | TestRecovery_PairedCustomerMail (down→recovery: both channels, Hungarian subject, ✅ subject, both log rows) |
PASS |
| B | TestRecovery_UnpairedStaysCustomerSilent (operator only; enabled_events ignored) |
PASS |
| C | TestRecovery_FlapDamping (suppressed down ⇒ no 2nd recovery mail; op 1 h cooldown) |
PASS |
| D | TestMarkClaimed_SeedsNotificationPrefs / _NeverOverwritesExistingPrefs / _EmptyEmail_NoRow_ClaimSucceeds + store TestSeedNotificationPrefs_* (2) + TestLastCustomerSentAt_PairingQuery |
PASS |
| E | TestSavePreferences_EmptyEmailCannotClobber / _NonEmptyEmailStillUpdates / _EmptyEmailNoStoredRow |
PASS |
| F | TestTestEvent_BothChannels / TestTestEvent_NoPrefsRow_NoPanic |
PASS |
| G | TestPriorityHeaders (table) / TestPriorityHeaders_EndToEnd (headers on error both channels, absent on warning) |
PASS |
| guard | TestRecovery_SeverityStaysInfo (severityNotifies unwidened; non-recovery info silent) |
PASS |
Red-proofs (each: mutation applied → FAIL confirmed → reverted → suite green):
- Pairing check forced open (
if false && …) →TestRecovery_UnpairedStaysCustomerSilentFAILED ("unpaired recovery must NOT mail the customer, got 1"). ✔ - Seed via
SaveNotificationPrefsupsert →TestMarkClaimed_NeverOverwritesExistingPrefsFAILED ("claim seed MODIFIED an existing row"). ✔ - Clobber guard removed →
TestSavePreferences_EmptyEmailCannotClobberFAILED ("CLOBBERED the stored address: email="""). ✔ - Headers unconditional →
TestPriorityHeaders_EndToEndFAILED ("warning mail … must carry NO headers") (+ table test). ✔
Deploy verification
Image built + pushed (build.sh 0.71.0 --push), manifest bumped (f1da171), ArgoCD hard-refresh +
deliberate sync → Synced / Healthy, pod hub-6ffbf574d8-p2w5p Running, deployed image
gitea.dooplex.hu/admin/felhom-hub:0.71.0, startup log clean (checkers seeded 2 ok, no dispatch
errors).
Live test-mail leg (the exact endpoint the UI invokes)
POST /settings/notifications/test on the demo-felhom guest controller (in-guest, session +
CSRF): controller returned „Teszt email elküldve." Hub log:
Event from demo-felhom: test (info) → Test email sent to doodoo21@freemail.hu →
Operator test email sent for demo-felhom. notification_log rows (UTC):
demo-felhom test customer sent Teszt értesítés 2026-07-22 18:59:34
demo-felhom test operator sent operator test copy 2026-07-22 18:59:34
Resend accepted both (no failed rows). HANDED TO VIKTOR: confirm the operator mail landed in
Primary, the phone buzzed (the new Gmail filter's proof), and the client shows it high-priority.
NOT live-validated in this task (verbatim, per spec)
- The natural
*_recoveredmail on a real staleness cycle — rides the next real event or the deliberate reboot drill; NOT fabricated by blocking reports (F9-bypass-shaped). - Seed-at-claim on a real claim — next onboarding; Peti's Friday reinstall is the natural candidate.
Observations (not acted on)
sendTestEmailnil-prefs panic was latent-reachable live: a „Teszt értesítés" click on a customer with nocustomer_notificationsrow (demo-hp today) would have panicked the dispatcher goroutine. Fixed in-scope (the function was already an edit surface); noted here because it predates this train.- No standalone notification-plane feature doc exists under
documentation/— N.4 was satisfied inREUSE.md§Alerting (the de-facto plane map) + the capability map row; if Viktor wants a dedicatedarchitecture/notifications.md, that is a docs-only follow-up. handleNotify(legacy), mailrelay,SendClaimEmail/SendSelfBindEmailbyte-untouched (claim/ selfbind sends now pass explicitnilheaders — no behavior change).- Website shows no hub version → N.6 n/a.
- The
notification_logis now pairing evidence (recovery gate reads it) — future retention/prune work must keep customer-channel stale/down/recovered rows long enough to pair (noted in REUSE).