diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 4f3483e9c..b668230fe 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -7499,6 +7499,16 @@ target. Deleted-resource reconciliation still uses the full current global state, and the synthetic Patrol runtime finding retains its existing special handling. +Finding creation also validates the provider-authored resource ID and display +name as one identity pair against the current runtime snapshot. When both +values resolve uniquely but identify different same-type resources, core +rejects the report and lets the bounded finding repair turn resubmit a coherent +canonical pair; it never guesses which value was intended. This prevents one +incident from being persisted twice when a report copies the ID of an in-scope +sibling while describing the correct affected resource by name. The existing +exact-name repair remains limited to unknown ID transcription errors and does +not redirect a real known resource ID across the finding scope boundary. + A Watch provider turn that stops because its output-token allowance was exhausted is not a completed finding decision. Core preserves the partial turn only as model context, excludes its unfinished prose from the durable Patrol diff --git a/internal/ai/patrol_findings.go b/internal/ai/patrol_findings.go index 29e19668b..bd076d93b 100644 --- a/internal/ai/patrol_findings.go +++ b/internal/ai/patrol_findings.go @@ -550,6 +550,9 @@ func newPatrolFindingCreatorAdapterState(p *PatrolService, snap patrolRuntimeSta } func (a *patrolFindingCreatorAdapter) CreateFinding(input tools.PatrolFindingInput) (string, bool, error) { + if err := a.validateFindingResourceIdentity(input); err != nil { + return "", false, err + } if canonical, repaired := a.canonicalizeUnknownFindingResource(input); repaired { log.Info(). Str("submitted_resource_id", input.ResourceID). @@ -673,6 +676,57 @@ func (a *patrolFindingCreatorAdapter) CreateFinding(input tools.PatrolFindingInp return id, isNew, nil } +// validateFindingResourceIdentity rejects a model-authored resource ID/name +// pair when each value resolves uniquely to a different same-type runtime +// resource. Either value can be copied incorrectly, so core must not guess +// which one the provider intended. Returning a validation error lets the +// bounded finding repair turn submit one coherent canonical identity instead +// of persisting duplicate incidents against sibling resources. +func (a *patrolFindingCreatorAdapter) validateFindingResourceIdentity(input tools.PatrolFindingInput) error { + if a == nil || strings.TrimSpace(input.ResourceID) == "" || strings.TrimSpace(input.ResourceName) == "" { + return nil + } + + resourceIDToken := canonicalPatrolScopeToken(input.ResourceID) + resourceNameToken := canonicalPatrolScopeToken(input.ResourceName) + resourceTypeToken := canonicalPatrolScopeToken(input.ResourceType) + idMatches := make([]int, 0, 1) + nameMatches := make([]int, 0, 1) + recordIndex := 0 + + patrolVisitRuntimeResources(a.snap, func(record patrolRuntimeResourceRecord) bool { + currentIndex := recordIndex + recordIndex++ + if resourceTypeToken != "" && canonicalPatrolScopeToken(record.resourceType) != resourceTypeToken { + return true + } + + for _, value := range append(append([]string(nil), record.ids...), record.aliases...) { + if canonicalPatrolScopeToken(value) == resourceIDToken { + idMatches = append(idMatches, currentIndex) + break + } + } + for _, alias := range record.aliases { + if canonicalPatrolScopeToken(alias) == resourceNameToken { + nameMatches = append(nameMatches, currentIndex) + break + } + } + return true + }) + + if len(idMatches) == 1 && len(nameMatches) == 1 && idMatches[0] != nameMatches[0] { + return fmt.Errorf( + "resource identity mismatch: resource_id %q and resource_name %q identify different %s resources; submit one canonical ID/name pair", + input.ResourceID, + input.ResourceName, + input.ResourceType, + ) + } + return 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 diff --git a/internal/ai/patrol_findings_additional_test.go b/internal/ai/patrol_findings_additional_test.go index 17972a5eb..e5cc828db 100644 --- a/internal/ai/patrol_findings_additional_test.go +++ b/internal/ai/patrol_findings_additional_test.go @@ -1658,6 +1658,40 @@ func TestPatrolFindingAdapterRepairsOnlyUnknownIDsFromUniqueScopedIdentity(t *te } } +func TestPatrolFindingAdapterRejectsKnownSiblingIDAndNameMismatch(t *testing.T) { + state := newPatrolRuntimeState(models.StateSnapshot{ + DockerHosts: []models.DockerHost{{ + ID: "docker-host-1", + Containers: []models.DockerContainer{ + {ID: "app-container-healthy", Name: "control", State: "running", Health: "healthy"}, + {ID: "app-container-unhealthy", Name: "existing", State: "running", Health: "unhealthy"}, + }, + }}, + }) + ps := NewPatrolService(nil, nil) + adapter := newPatrolFindingCreatorAdapterState(ps, state) + adapter.setFindingScope([]string{"app-container-healthy", "app-container-unhealthy"}) + + input := tools.PatrolFindingInput{ + ResourceID: "app-container-healthy", ResourceName: "existing", ResourceType: "app-container", + Key: "health-check-failed", Severity: "warning", Category: "reliability", + Title: "Container health check is unhealthy", Description: "The existing container is unhealthy.", + Impact: "Requests may fail.", Recommendation: "Inspect the health check.", + Evidence: "Current provider health for existing is unhealthy.", + } + if _, isNew, err := adapter.CreateFinding(input); err == nil || isNew || !strings.Contains(err.Error(), "resource identity mismatch") { + t.Fatalf("mismatched sibling report = (%t, %v), want identity validation error", isNew, err) + } + if active := ps.findings.GetActive(FindingSeverityInfo); len(active) != 0 { + t.Fatalf("mismatched sibling report persisted findings: %+v", active) + } + + input.ResourceID = "app-container-unhealthy" + if findingID, isNew, err := adapter.CreateFinding(input); err != nil || !isNew || findingID == "" { + t.Fatalf("coherent canonical report = (%q, %t, %v), want new finding", findingID, isNew, err) + } +} + func TestPatrolFindingAdapterCanAssessSeededRuntimeFindingDuringScopedRun(t *testing.T) { state := newPatrolRuntimeState(models.StateSnapshot{ VMs: []models.VM{{ID: "vm-requested", Name: "requested", VMID: 501}},