From 59b0ee188bfa94f96ca01347d5171fdf5b8cdb49 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 17 Aug 2026 05:01:42 +0100 Subject: [PATCH] Repair scoped Patrol finding identity safely --- .../v6/internal/subsystems/ai-runtime.md | 10 +++ internal/ai/patrol_findings.go | 84 +++++++++++++++++++ .../ai/patrol_findings_additional_test.go | 44 ++++++++++ 3 files changed, 138 insertions(+) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 32c176eea..89e9c7da5 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -7609,3 +7609,13 @@ silently ignore the resource selector and return fleet connection counts. Calls without `resource_id` retain the existing aggregate connection-health overview for backward compatibility. Provider-native IDs and names remain lookup aliases; responses preserve the canonical unified-resource ID. + +Patrol finding creation keeps scope authoritative while tolerating a bounded +redundant-ID transcription error. When a model submits a `resource_id` that +matches no current runtime identity, core may replace it only when the exact +`resource_name` uniquely identifies a same-type runtime record already admitted +to the requested finding lifecycle scope. The canonical ID from that record +then owns deduplication and persistence. A submitted ID that identifies any +real out-of-scope resource, an ambiguous name, a type mismatch, or a name that +does not resolve inside the lifecycle scope remains rejected. No fuzzy name or +ID matching is permitted at this boundary. diff --git a/internal/ai/patrol_findings.go b/internal/ai/patrol_findings.go index db91579ed..a47154350 100644 --- a/internal/ai/patrol_findings.go +++ b/internal/ai/patrol_findings.go @@ -550,6 +550,14 @@ func newPatrolFindingCreatorAdapterState(p *PatrolService, snap patrolRuntimeSta } func (a *patrolFindingCreatorAdapter) CreateFinding(input tools.PatrolFindingInput) (string, bool, error) { + if canonical, repaired := a.canonicalizeUnknownFindingResource(input); repaired { + log.Info(). + Str("submitted_resource_id", input.ResourceID). + Str("canonical_resource_id", canonical.ResourceID). + Str("resource_type", canonical.ResourceType). + Msg("AI Patrol: Repaired unknown finding resource ID from exact scoped identity") + input = canonical + } if !a.findingInCurrentScope(&Finding{ResourceID: input.ResourceID, ResourceName: input.ResourceName}) { return "", false, fmt.Errorf("resource %s is outside the requested patrol finding scope", input.ResourceID) } @@ -665,6 +673,82 @@ func (a *patrolFindingCreatorAdapter) CreateFinding(input tools.PatrolFindingInp return id, isNew, nil } +// canonicalizeUnknownFindingResource repairs only a redundant model-authored +// ID transcription error. The submitted ID must match no current resource, +// while the exact submitted name must resolve to one same-type runtime record +// that is already admitted to the finding lifecycle scope. A real out-of-scope +// ID, an ambiguous name, or a type mismatch remains rejected. This preserves +// the scope boundary without making stable finding identity depend on perfect +// copying of a long canonical handle. +func (a *patrolFindingCreatorAdapter) canonicalizeUnknownFindingResource(input tools.PatrolFindingInput) (tools.PatrolFindingInput, bool) { + if a == nil || strings.TrimSpace(input.ResourceID) == "" || strings.TrimSpace(input.ResourceName) == "" { + return input, false + } + if a.findingInCurrentScope(&Finding{ResourceID: input.ResourceID, ResourceName: input.ResourceName}) { + return input, false + } + + resourceIDToken := canonicalPatrolScopeToken(input.ResourceID) + resourceNameToken := canonicalPatrolScopeToken(input.ResourceName) + resourceTypeToken := canonicalPatrolScopeToken(input.ResourceType) + type match struct { + canonicalID string + } + matches := make([]match, 0, 1) + submittedIDKnown := false + nameMatchCount := 0 + + patrolVisitRuntimeResources(a.snap, func(record patrolRuntimeResourceRecord) bool { + for _, value := range append(append([]string(nil), record.ids...), record.aliases...) { + if canonicalPatrolScopeToken(value) == resourceIDToken { + submittedIDKnown = true + } + } + if resourceTypeToken != "" && canonicalPatrolScopeToken(record.resourceType) != resourceTypeToken { + return true + } + + nameMatches := false + for _, alias := range record.aliases { + if canonicalPatrolScopeToken(alias) == resourceNameToken { + nameMatches = true + break + } + } + if !nameMatches { + return true + } + nameMatchCount++ + + recordInScope := len(a.findingScope) == 0 + canonicalID := "" + for _, id := range record.ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if canonicalID == "" { + canonicalID = id + } + if a.findingScope[id] { + recordInScope = true + canonicalID = id + break + } + } + if recordInScope && canonicalID != "" { + matches = append(matches, match{canonicalID: canonicalID}) + } + return true + }) + + if submittedIDKnown || nameMatchCount != 1 || len(matches) != 1 { + return input, false + } + input.ResourceID = matches[0].canonicalID + return input, true +} + func (a *patrolFindingCreatorAdapter) findingOwnedByAlerts(finding *Finding) bool { if finding == nil || finding.ResourceType != "app-container" || finding.Category != FindingCategoryReliability { return false diff --git a/internal/ai/patrol_findings_additional_test.go b/internal/ai/patrol_findings_additional_test.go index b23e7f8ee..504ac65d2 100644 --- a/internal/ai/patrol_findings_additional_test.go +++ b/internal/ai/patrol_findings_additional_test.go @@ -1614,6 +1614,50 @@ func TestPatrolFindingAdapterSeparatesRequestedLifecycleScopeFromEvidenceScope(t } } +func TestPatrolFindingAdapterRepairsOnlyUnknownIDsFromUniqueScopedIdentity(t *testing.T) { + state := newPatrolRuntimeState(models.StateSnapshot{ + Nodes: []models.Node{ + {ID: "node-requested", Name: "requested", Status: "online"}, + {ID: "node-context", Name: "context-only", Status: "online"}, + }, + }) + adapter := newPatrolFindingCreatorAdapterState(NewPatrolService(nil, nil), state) + adapter.setFindingScope([]string{"node-requested"}) + + input := tools.PatrolFindingInput{ + ResourceID: "node-reques7ed", ResourceName: "requested", ResourceType: "node", + } + repaired, ok := adapter.canonicalizeUnknownFindingResource(input) + if !ok || repaired.ResourceID != "node-requested" { + t.Fatalf("unknown transcription error repair = (%+v, %t), want canonical node-requested", repaired, ok) + } + + input.ResourceID = "node-context" + if got, ok := adapter.canonicalizeUnknownFindingResource(input); ok || got.ResourceID != "node-context" { + t.Fatalf("real out-of-scope ID must not be redirected by a scoped name: (%+v, %t)", got, ok) + } + + input.ResourceID = "node-reques7ed" + input.ResourceType = "vm" + if _, ok := adapter.canonicalizeUnknownFindingResource(input); ok { + t.Fatal("resource type mismatch must not be repaired") + } + + ambiguousState := newPatrolRuntimeState(models.StateSnapshot{ + Nodes: []models.Node{ + {ID: "node-a", Name: "duplicate", Status: "online"}, + {ID: "node-b", Name: "duplicate", Status: "online"}, + }, + }) + ambiguous := newPatrolFindingCreatorAdapterState(NewPatrolService(nil, nil), ambiguousState) + ambiguous.setFindingScope([]string{"node-a"}) + if _, ok := ambiguous.canonicalizeUnknownFindingResource(tools.PatrolFindingInput{ + ResourceID: "node-typo", ResourceName: "duplicate", ResourceType: "node", + }); ok { + t.Fatal("ambiguous exact names must not be repaired even when only one record is in scope") + } +} + func TestPatrolFindingAdapterCanAssessSeededRuntimeFindingDuringScopedRun(t *testing.T) { state := newPatrolRuntimeState(models.StateSnapshot{ VMs: []models.VM{{ID: "vm-requested", Name: "requested", VMID: 501}},