diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 061aa990df..e643a61249 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -9806,12 +9806,12 @@ "evidence": [ { "repo": "pulse", - "path": "docs/release-control/v6/internal/records/alert-lifecycle-contract-coverage-2026-08-26.md", + "path": "docs/ALERT_ENGINE_EVOLUTION.md", "kind": "file" }, { "repo": "pulse", - "path": "docs/ALERT_ENGINE_EVOLUTION.md", + "path": "docs/release-control/v6/internal/records/alert-lifecycle-contract-coverage-2026-08-26.md", "kind": "file" } ] diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index b78b426aba..e51382f246 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -630,6 +630,21 @@ runtime must migrate the active alert, history entry, acknowledgment record, suppression/rate-limit/flapping tracking, and guest per-disk metric identity to the current canonical state instead of reopening a duplicate alert or resolving only the stale node-scoped identity. +Metric alerts stored under a canonical state key must also clear through that +same identity. Hysteresis resolution must derive the active-alert storage key +from the existing alert and canonical state ID; removing only the legacy +`-` ID can emit a resolved notification while leaving the +canonical alert active to resolve again on every poll. + +`internal/alerts/reducer` is the Phase 1 deterministic characterization of +metric-threshold transitions. It accepts an explicit observation time and +models enablement, hysteresis, sustained delay, and warning/critical severity +without persistence or notification side effects. The alert manager remains +the runtime owner and reference behavior while the parity harness drives both +implementations through identical observations; a divergence is a reducer bug +unless a focused manager regression first proves otherwise. Moving additional +alert families or downstream delivery behavior into the reducer requires a +separate contract slice. That same guest-threshold owner also governs guest-derived lifecycle and posture alerts. Snapshot age, backup age, powered-off state, and configuration-change reevaluation must all construct a canonical lightweight diff --git a/internal/alerts/metric_resolve_regression_test.go b/internal/alerts/metric_resolve_regression_test.go deleted file mode 100644 index 6fb12dd1a9..0000000000 --- a/internal/alerts/metric_resolve_regression_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package alerts - -import ( - "testing" -) - -// Regression: checkMetric stores canonical-identity alerts under the -// canonical state key, but its hysteresis resolution removed by the legacy -// "-" ID, which is not registered as an alias. The -// removal silently no-oped: a resolved notification went out while the -// alert stayed active and re-resolved on every subsequent poll. Guest -// per-disk usage alerts were the remaining production caller of this path. -func TestCheckMetricResolveRemovesCanonicallyKeyedAlert(t *testing.T) { - manager := NewManagerWithDataDir(t.TempDir(), WithoutPersistedAlertRestore()) - t.Cleanup(manager.Stop) - - manager.mu.Lock() - manager.config.Enabled = true - manager.config.TimeThresholds = map[string]int{} - manager.mu.Unlock() - - threshold := &HysteresisThreshold{Trigger: 80, Clear: 75} - resourceID := "guest-123/disk:root" - - manager.checkMetric(resourceID, "test-vm", "node1", "qemu/123", "VM", "disk", 85, threshold, nil) - - manager.mu.Lock() - activeAfterFire := len(manager.activeAlerts) - manager.mu.Unlock() - if activeAfterFire != 1 { - t.Fatalf("active alerts after fire = %d, want 1", activeAfterFire) - } - - manager.checkMetric(resourceID, "test-vm", "node1", "qemu/123", "VM", "disk", 70, threshold, nil) - - manager.mu.Lock() - activeAfterResolve := len(manager.activeAlerts) - manager.mu.Unlock() - if activeAfterResolve != 0 { - t.Fatalf("active alerts after resolve = %d, want 0 (stale alert left behind)", activeAfterResolve) - } - - if resolved := manager.GetResolvedAlert(buildCanonicalStateID(resourceID, "metric-threshold:disk")); resolved == nil { - t.Fatal("expected a recently-resolved entry for the cleared alert") - } -} diff --git a/internal/alerts/threshold_resolution_shared_test.go b/internal/alerts/threshold_resolution_shared_test.go index 316699c50f..08d0a73feb 100644 --- a/internal/alerts/threshold_resolution_shared_test.go +++ b/internal/alerts/threshold_resolution_shared_test.go @@ -939,3 +939,41 @@ func TestGuestFilesystemThresholdOverrideOwnsMountEvaluation(t *testing.T) { t.Fatalf("/boot alert did not fire above its 99%% override") } } + +// Regression: checkMetric stores canonical-identity alerts under the +// canonical state key, so hysteresis resolution must not remove only the +// unregistered legacy "-" ID. +func TestCheckMetricResolveRemovesCanonicallyKeyedAlert(t *testing.T) { + manager := NewManagerWithDataDir(t.TempDir(), WithoutPersistedAlertRestore()) + t.Cleanup(manager.Stop) + + manager.mu.Lock() + manager.config.Enabled = true + manager.config.TimeThresholds = map[string]int{} + manager.mu.Unlock() + + threshold := &HysteresisThreshold{Trigger: 80, Clear: 75} + resourceID := "guest-123/disk:root" + + manager.checkMetric(resourceID, "test-vm", "node1", "qemu/123", "VM", "disk", 85, threshold, nil) + + manager.mu.Lock() + activeAfterFire := len(manager.activeAlerts) + manager.mu.Unlock() + if activeAfterFire != 1 { + t.Fatalf("active alerts after fire = %d, want 1", activeAfterFire) + } + + manager.checkMetric(resourceID, "test-vm", "node1", "qemu/123", "VM", "disk", 70, threshold, nil) + + manager.mu.Lock() + activeAfterResolve := len(manager.activeAlerts) + manager.mu.Unlock() + if activeAfterResolve != 0 { + t.Fatalf("active alerts after resolve = %d, want 0 (stale alert left behind)", activeAfterResolve) + } + + if resolved := manager.GetResolvedAlert(buildCanonicalStateID(resourceID, "metric-threshold:disk")); resolved == nil { + t.Fatal("expected a recently-resolved entry for the cleared alert") + } +}