Add FindingsStore.GetTrustSummary snapshot for trust metrics

Adds FindingsTrustSummary struct and GetTrustSummary() method that
walks the in-memory findings store and returns a snapshot of how
currently-tracked findings have resolved: tracked, currently-active,
resolved (auto-resolved subset), fix-verified vs fix-failed, dismissed
broken out by reason (noise / expected / later), suppressed, and
regressed-at-least-once.

This is the data layer for the user's "trust metrics" arc — concrete
counts an operator can use to answer "do I trust Patrol?" Operators see
fix-verified and dismissed-as-noise grow over time as Patrol's analysis
gets sharper. The summary is intentionally a snapshot, not lifetime
totals; once findings are cleaned up they no longer contribute, so the
AutoResolved counter (which includes both Resolve(auto=true) and
UpdateInvestigationOutcome(fix_verified) paths) is a current-state
distribution, not a historical aggregate. The struct doc string is
explicit about this so downstream surfaces do not misframe it.

Adds a unit test covering each bucket (active, auto-resolved,
fix-verified, fix-failed, dismissed-as-noise, dismissed-as-expected,
regressed) with a fixture that exercises the real lifecycle methods
rather than mutating the store directly. Updates the ai-runtime
contract to pin the snapshot semantics and the AutoResolved-path
union.
This commit is contained in:
rcourtman
2026-05-08 20:06:36 +01:00
parent 9bb82202cf
commit a801bbf810
3 changed files with 163 additions and 0 deletions
@@ -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,
+66
View File
@@ -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
+85
View File
@@ -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