Repair metric reducer governance proof

Record canonical metric resolution and reducer parity ownership in the alerts contract, move the stale-resolution regression into the recognized shared proof, and restore deterministic status evidence ordering.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot] 2026-08-26 23:00:46 +01:00
parent 4223357449
commit b1867a1de2
4 changed files with 55 additions and 48 deletions

View file

@ -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"
}
]

View file

@ -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
`<resourceID>-<metric>` 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

View file

@ -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
// "<resourceID>-<metric>" 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")
}
}

View file

@ -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 "<resourceID>-<metric>" 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")
}
}