hub v0.90.0 — a dropped notification leaves a trace, and the backup digest arrives (R-182)
gates / gates (push) Successful in 7s

processOperator's cooldown no longer returns bare. It dropped the event BEFORE
LogNotification, so a suppressed operator alert and an event that never happened
were indistinguishable — from the operator's side and from the hub's own records.
Measured 2026-08-03: nine recovery_unit_capture_failed events arrived, two were
mailed, seven left no row anywhere. That is why the defect took a day to get the
right way round: there was nothing to read.

A suppressed operator event now writes a `suppressed` row carrying the message
and the key that suppressed it. This applies to EVERY operator event, not only
the one that exposed it. It does NOT change the cooldown's duration or semantics.

backup_run_failures: the per-run digest. In allowedEventTypes AND in
operatorOnlyEvents — allowlisting alone does not make an event operator-only,
and FormatCustomerEmail falls back to the raw English message rather than
blocking. A test demonstrates a customer with the type enabled receiving nothing.

recordOnlyEvents: a third routing class — stored and recorded, never mailed.
recovery_unit_capture_failed moves here: it is the record, the digest is the
notification. A register rather than downgrading severity to info, which would
relabel a genuine failure as informational everywhere it is queried.

cooldownRunSuffix: a sibling of cooldownTierSuffix, not a branch inside it, so
tier keeps byte-identical semantics and R-97a's tests are untouched. It makes
the cooldown effectively inert for the digest, which is the intent — a digest is
already rate-limited by construction; the refresh sweep sends no run_id and so
stays under the ordinary hourly cooldown.

The email renders as a list, not a JSON blob. An absent space reading renders as
unavailable, never as zeros.
This commit is contained in:
2026-08-03 13:46:48 +02:00
parent 7dc1744eec
commit dd40f85bb8
8 changed files with 551 additions and 3 deletions
@@ -0,0 +1,40 @@
package api
import "testing"
// R-182 — the backup run digest needs the allowlist half, and NOT the customerMessages half.
//
// A new event type is a pair of register entries, and each half fails differently. For this type the
// pair is unusual and that is the point:
//
// - missing from `allowedEventTypes` → POST /event returns 400 and the digest VANISHES,
// which would rebuild the exact silence R-182 exists to end;
// - PRESENT in `customerMessages` → the customer would be e-mailed, in Hungarian, a
// list of which apps' backups failed and why — operator detail they can take no action on. So
// this type must be allowlisted and must NOT have a customer message.
//
// The customer-facing half of decision D-c is the FILL WARNING, which fires before this and is
// actionable (free space, delete files, add a drive). This is the operator's half.
//
// Operator-only routing itself is enforced by `notify.operatorOnlyEvents`, NOT by the absence of a
// customerMessages entry — that assumption shipped in v0.78.0 and was wrong, because
// FormatCustomerEmail falls back to the raw message. It is pinned in
// `internal/notify/backup_run_digest_test.go`, which demonstrates a customer with the type in their
// enabled list receiving nothing.
func TestBackupRunDigestIsAllowlisted(t *testing.T) {
if !allowedEventTypes["backup_run_failures"] {
t.Fatal("backup_run_failures must be in allowedEventTypes, or POST /event 400s and the " +
"whole run digest is dropped at the door — the silence R-182 was filed against")
}
}
// The per-app event is the RECORD and must not be removed while the digest is the notification.
// Deleting it would make the digest the only trace, and a digest that fails to send would then take
// the record with it — the coupling R-182's fix exists to break.
func TestPerAppCaptureEventStaysAllowlisted(t *testing.T) {
if !allowedEventTypes["recovery_unit_capture_failed"] {
t.Fatal("recovery_unit_capture_failed was removed from allowedEventTypes — it is the " +
"durable per-failure RECORD, and the digest is only the notification; the operator " +
"register and every historical query depend on it")
}
}
+5
View File
@@ -1586,6 +1586,11 @@ var allowedEventTypes = map[string]bool{
// OPERATOR-ONLY IS ENFORCED BY `notify.operatorOnlyEvents` — see the paragraph above. This entry
// alone does NOT make it operator-only.
"recovery_unit_capture_failed": true,
// R-182. The per-run backup digest: one event at the end of a run, listing every app whose
// backup failed or was refused. Allowlisting it is NOT what keeps it away from customers —
// `notify.operatorOnlyEvents` is (see the comment there); both entries ship together and
// `backup_run_digest_event_test.go` pins the pair.
"backup_run_failures": true,
// Controller-pushed events
"controller_started": true,