diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 307a9af79..4c0f6326f 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -288,6 +288,18 @@ runtime cost control, and shared AI transport surfaces. and to leave `impact` empty rather than fabricate one when the consequence is genuinely unknown; the runtime must not synthesize a default in that case. + `FindingsStore.GetTrustSummary` returns a snapshot of how currently + tracked findings have resolved (tracked, currently-active, resolved, + auto-resolved, fix-verified, fix-failed, dismissed-as-noise, + dismissed-as-expected, dismissed-as-later, suppressed, + regressed-at-least-once). It is the data layer for trust metrics on + operator surfaces. The summary is intentionally a snapshot of the + in-memory store, not lifetime totals; once findings are cleaned up + they no longer contribute. Downstream surfaces must frame the + counts as current-state distribution rather than historical + aggregates, and the AutoResolved bucket includes both the + `Resolve(auto=true)` path and the + `UpdateInvestigationOutcome(fix_verified)` path. Findings carry a `previous_resolved_fix_summary` field as operational memory across regressions: when a finding that had a resolved investigation with a proposed fix is re-detected, diff --git a/internal/ai/findings.go b/internal/ai/findings.go index 8d555fa05..852fe05ae 100644 --- a/internal/ai/findings.go +++ b/internal/ai/findings.go @@ -1737,11 +1737,77 @@ type FindingsSummary struct { Total int `json:"total"` } +// FindingsTrustSummary is a snapshot of how the currently-tracked findings +// have resolved over time. It answers the user-facing "do I trust Patrol?" +// question with concrete counts: how many were auto-resolved, how many were +// fix-verified, how many were dismissed as noise versus expected behavior. +// +// This is a snapshot of the in-memory store; it does not include historical +// findings that have been cleaned up. Treat the counts as "current state +// distribution of tracked findings," not lifetime totals. +type FindingsTrustSummary struct { + Tracked int `json:"tracked"` + CurrentlyActive int `json:"currently_active"` + Resolved int `json:"resolved"` + AutoResolved int `json:"auto_resolved"` + FixVerified int `json:"fix_verified"` + FixFailed int `json:"fix_failed"` + DismissedAsNoise int `json:"dismissed_as_noise"` // dismissed_reason=not_an_issue + DismissedAsExpected int `json:"dismissed_as_expected"` // dismissed_reason=expected_behavior + DismissedAsLater int `json:"dismissed_as_later"` // dismissed_reason=will_fix_later + Suppressed int `json:"suppressed"` + RegressedAtLeastOnce int `json:"regressed_at_least_once"` +} + // HasIssues returns true if there are any warning or critical findings func (s FindingsSummary) HasIssues() bool { return s.Critical > 0 || s.Warning > 0 } +// GetTrustSummary returns a snapshot of how currently-tracked findings have +// resolved. See FindingsTrustSummary for caveats on snapshot vs lifetime +// totals. +func (s *FindingsStore) GetTrustSummary() FindingsTrustSummary { + s.mu.RLock() + defer s.mu.RUnlock() + + var summary FindingsTrustSummary + for _, f := range s.findings { + summary.Tracked++ + if f.IsActive() { + summary.CurrentlyActive++ + } + if f.ResolvedAt != nil { + summary.Resolved++ + if f.AutoResolved { + summary.AutoResolved++ + } + } + if f.Suppressed { + summary.Suppressed++ + } + if f.RegressionCount > 0 { + summary.RegressedAtLeastOnce++ + } + switch f.InvestigationOutcome { + case string(aicontracts.OutcomeFixVerified): + summary.FixVerified++ + case string(aicontracts.OutcomeFixFailed), + string(aicontracts.OutcomeFixVerificationFailed): + summary.FixFailed++ + } + switch f.DismissedReason { + case "not_an_issue": + summary.DismissedAsNoise++ + case "expected_behavior": + summary.DismissedAsExpected++ + case "will_fix_later": + summary.DismissedAsLater++ + } + } + return summary +} + // IsHealthy returns true if there are no watch, warning, or critical findings func (s FindingsSummary) IsHealthy() bool { return s.Critical == 0 && s.Warning == 0 && s.Watch == 0 diff --git a/internal/ai/findings_test.go b/internal/ai/findings_test.go index 713dfc3f9..60fab664a 100644 --- a/internal/ai/findings_test.go +++ b/internal/ai/findings_test.go @@ -555,6 +555,91 @@ func TestFindingsStore_Add_UpdateExisting(t *testing.T) { } } +// TestFindingsStore_GetTrustSummary covers the snapshot counts that answer +// the operator-facing "do I trust Patrol?" question. It verifies the bucket +// boundaries for each finding outcome the summary tracks. +func TestFindingsStore_GetTrustSummary(t *testing.T) { + store := NewFindingsStore() + + // Active finding (no resolution, no dismissal) + store.Add(&Finding{ + ID: "f-active", ResourceID: "r1", Severity: FindingSeverityWarning, + Category: FindingCategoryReliability, Title: "Active", + }) + + // Auto-resolved finding (resolved + auto_resolved=true) + store.Add(&Finding{ + ID: "f-auto", ResourceID: "r2", Severity: FindingSeverityWarning, + Category: FindingCategoryReliability, Title: "Auto", + }) + store.Resolve("f-auto", true) + + // Fix-verified finding (investigation_outcome = fix_verified) + store.Add(&Finding{ + ID: "f-verified", ResourceID: "r3", Severity: FindingSeverityWarning, + Category: FindingCategoryReliability, Title: "Verified", + }) + store.UpdateInvestigationOutcome("f-verified", string(aicontracts.OutcomeFixVerified)) + + // Fix-failed finding + store.Add(&Finding{ + ID: "f-failed", ResourceID: "r4", Severity: FindingSeverityWarning, + Category: FindingCategoryReliability, Title: "Failed", + }) + store.UpdateInvestigationOutcome("f-failed", string(aicontracts.OutcomeFixFailed)) + + // Dismissed as noise + store.Add(&Finding{ + ID: "f-noise", ResourceID: "r5", Severity: FindingSeverityWarning, + Category: FindingCategoryReliability, Title: "Noise", + }) + store.Dismiss("f-noise", "not_an_issue", "") + + // Dismissed as expected + store.Add(&Finding{ + ID: "f-expected", ResourceID: "r6", Severity: FindingSeverityWarning, + Category: FindingCategoryReliability, Title: "Expected", + }) + store.Dismiss("f-expected", "expected_behavior", "Maintenance window") + + // Regressed at least once: setup via the regression branch + store.Add(&Finding{ + ID: "f-regressed", ResourceID: "r7", Severity: FindingSeverityWarning, + Category: FindingCategoryReliability, Title: "Regressed", + }) + store.Resolve("f-regressed", true) + store.Add(&Finding{ + ID: "f-regressed", ResourceID: "r7", Severity: FindingSeverityWarning, + Category: FindingCategoryReliability, Title: "Regressed", + }) + + got := store.GetTrustSummary() + if got.Tracked != 7 { + t.Errorf("Tracked = %d, want 7", got.Tracked) + } + // AutoResolved counts findings that were resolved without operator action. + // Both Resolve(auto=true) and UpdateInvestigationOutcome(fix_verified) set + // the AutoResolved flag, so f-auto and f-verified both contribute. + if got.AutoResolved != 2 { + t.Errorf("AutoResolved = %d, want 2", got.AutoResolved) + } + if got.FixVerified != 1 { + t.Errorf("FixVerified = %d, want 1", got.FixVerified) + } + if got.FixFailed != 1 { + t.Errorf("FixFailed = %d, want 1", got.FixFailed) + } + if got.DismissedAsNoise != 1 { + t.Errorf("DismissedAsNoise = %d, want 1", got.DismissedAsNoise) + } + if got.DismissedAsExpected != 1 { + t.Errorf("DismissedAsExpected = %d, want 1", got.DismissedAsExpected) + } + if got.RegressedAtLeastOnce != 1 { + t.Errorf("RegressedAtLeastOnce = %d, want 1", got.RegressedAtLeastOnce) + } +} + // TestFindingsStore_Add_CapturesPreviousResolvedFixOnRegression covers the // regression branch: when a finding that had a resolved investigation with a // proposed fix is re-detected, the prior fix description must be preserved on