From 209ea8dfdbcc47b35d9b17deeafc04dce9799295 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 6 May 2026 19:02:07 +0100 Subject: [PATCH] Hydrate Assistant handoff resource relationships --- .../v6/internal/subsystems/ai-runtime.md | 16 ++- .../v6/internal/subsystems/api-contracts.md | 25 ++-- .../subsystems/patrol-intelligence.md | 9 +- .../internal/subsystems/unified-resources.md | 7 + internal/ai/chat/service.go | 68 +++++++++ .../chat/service_execute_additional_test.go | 130 ++++++++++++++++++ internal/api/contract_test.go | 3 + 7 files changed, 237 insertions(+), 21 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 8778167f2..bddc0917e 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -189,12 +189,16 @@ runtime cost control, and shared AI transport surfaces. for those handoff resources, using the same unified-resource resolution and policy presentation helpers that govern mention prefetch and provider-bound redaction; that context remains model-only handling guidance, not saved user - text or disclosure authority. Assistant runtime may also hydrate recent - changes for those handoff resources from the canonical unified-resource - timeline as model-only context on each turn, but those timeline facts remain - read-only explanation context and do not grant action authority. The runtime - may also persist structured pending-action and approval references from the - same investigation record as + text or disclosure authority. Assistant runtime may also hydrate canonical + relationship context for those handoff resources through + `FormatResourceRelationshipContext(...)` and canonical parent-edge synthesis, + but those topology facts remain read-only explanation context and do not + grant action authority. Assistant runtime may also hydrate recent changes for + those handoff resources from the canonical unified-resource timeline as + model-only context on each turn, but those timeline facts remain read-only + explanation context and do not grant action authority. The runtime may also + persist structured pending-action and approval references from the same + investigation record as model-context metadata, but those references are review context only: they must not include raw command text, must not grant approval or execution authority, and must route any operator decision back through the governed diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index ad4a4ccb7..0ea57b545 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -761,17 +761,20 @@ the canonical monitored-system blocked payload. resolution and shared policy presentation helpers, but the resulting handling guidance is read-only, model-only context and must not become saved user text, disclosure authority, or action authority. Chat execution may also hydrate - recent changes for those handoff resources from the canonical - unified-resource timeline, but the resulting context is read-only, model-only - explanation data and must not become saved user text or action authority. The - backend may also carry structured pending-action and approval references from - the investigation record into chat execution, but those references must omit - raw proposed-fix commands, remain model-only review context, and leave - approval/execution authority with the governed approval and remediation APIs. - Chat execution may refresh approval status snapshots for those references - from the canonical approval store, but that snapshot is read-only, - org-scoped, and must not expose or infer the raw command. Frontend handoff - briefings must derive from + canonical relationship context for those resources through shared + unified-resource relationship presentation and parent-edge synthesis, but + topology context is read-only and must not become saved user text or action + authority. Chat execution may also hydrate recent changes for those handoff + resources from the canonical unified-resource timeline, but the resulting + context is read-only, model-only explanation data and must not become saved + user text or action authority. The backend may also carry structured + pending-action and approval references from the investigation record into chat + execution, but those references must omit raw proposed-fix commands, remain + model-only review context, and leave approval/execution authority with the + governed approval and remediation APIs. Chat execution may refresh approval + status snapshots for those references from the canonical approval store, but + that snapshot is read-only, org-scoped, and must not expose or infer the raw + command. Frontend handoff briefings must derive from the same shared investigation payload rather than inventing a second finding-context transport shape. 7. Keep Patrol summary payload consumers aligned on one assessment hierarchy: transport-driven Patrol summary surfaces may show supporting counts and outcomes, but the canonical assessment and verification states must remain singular and not be repeated as a second compact verdict strip diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 4aa45d5da..88eb7b877 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -109,10 +109,11 @@ Patrol-specific presentation helpers. approval's current status for review, but Patrol presentation must still keep command payloads inside governed approval/remediation context rather than rendering them as handoff copy. Assistant may also enrich that same handoff - with canonical resource-policy guidance and recent canonical - resource-timeline changes for explanation, but Patrol must keep the visible - finding and drawer briefing tied to the shared investigation payload rather - than forking a Patrol-local policy or timeline summary. + with canonical resource-policy guidance, canonical resource-relationship + context, and recent canonical resource-timeline changes for explanation, but + Patrol must keep the visible finding and drawer briefing tied to the shared + investigation payload rather than forking a Patrol-local policy, topology, or + timeline summary. ## Current State diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index adef8cfa3..636bd22aa 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -993,6 +993,13 @@ That same resource model now also owns the canonical resolve the resource and hand the model the relationship list instead of rebuilding the relationship section header, ordering, or freshness wording locally. +Assistant finding handoffs are part of that same relationship-context contract: +when the runtime needs topology context for product-originated handoff +resources, it should resolve the canonical unified resource, synthesize the +canonical parent edge through `ResourceRelationshipsWithCanonicalParent(...)`, +and call `FormatResourceRelationshipContext(...)` rather than rebuilding +relationship markdown in chat or Patrol-local helpers. The resulting topology +block remains model-only explanation context, not action authority. The same shared relationship presenter also owns the compact change-timeline relationship summary used by resource change records, so change `from` and `to` values stay aligned with the canonical relationship labels instead of diff --git a/internal/ai/chat/service.go b/internal/ai/chat/service.go index 88bf2c032..d8a644d34 100644 --- a/internal/ai/chat/service.go +++ b/internal/ai/chat/service.go @@ -528,6 +528,7 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac handoffResourceProvider := s.unifiedResourceProvider s.mu.RUnlock() handoffContext = mergeHandoffResourcePolicyContext(handoffContext, handoffResources, handoffResourceProvider) + handoffContext = mergeHandoffResourceRelationshipContext(handoffContext, handoffResources, handoffResourceProvider) handoffContext = mergeHandoffResourceTimelineContext(handoffContext, handoffResources, s.actionAuditStore, time.Now()) handoffContext = mergeHandoffActionContext(handoffContext, handoffActions) injectHandoffContextIntoLatestUserMessage(messages, handoffContext) @@ -923,6 +924,73 @@ func buildHandoffResourcePolicyContext(handoffResources []HandoffResource, provi return strings.TrimSpace(b.String()) } +func mergeHandoffResourceRelationshipContext(handoffContext string, handoffResources []HandoffResource, provider tools.UnifiedResourceProvider) string { + relationshipContext := buildHandoffResourceRelationshipContext(handoffResources, provider) + switch { + case strings.TrimSpace(handoffContext) == "": + return relationshipContext + case relationshipContext == "": + return strings.TrimSpace(handoffContext) + default: + return strings.TrimSpace(handoffContext) + "\n\n" + relationshipContext + } +} + +func buildHandoffResourceRelationshipContext(handoffResources []HandoffResource, provider tools.UnifiedResourceProvider) string { + resources := normalizeHandoffResources(handoffResources) + if len(resources) == 0 || provider == nil { + return "" + } + + var b strings.Builder + seen := make(map[string]struct{}, len(resources)) + count := 0 + for _, handoffResource := range resources { + resource, ok := tools.CanonicalHandoffUnifiedResource(provider, handoffResource.ID, handoffResource.Name, handoffResource.Type, handoffResource.Node) + if !ok { + continue + } + key := strings.ToLower(strings.TrimSpace(string(resource.Type)) + "\x00" + strings.TrimSpace(resource.ID)) + if key == "\x00" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + + resource.Relationships = unifiedresources.ResourceRelationshipsWithCanonicalParent(resource) + relationshipContext := strings.TrimSpace(unifiedresources.FormatResourceRelationshipContext(&resource, 3)) + if relationshipContext == "" { + continue + } + if b.Len() == 0 { + b.WriteString("[Resource Relationship Context]") + } + count++ + + policy, aiSafeSummary := unifiedresources.CanonicalGovernanceMetadata(&resource) + label := unifiedresources.ResourcePolicyLabel(resource.Name, aiSafeSummary, policy) + if label == "" { + label = "resource" + } + contextLabel := "Resource Relationships For" + if count > 1 { + contextLabel = fmt.Sprintf("Resource Relationships %d For", count) + } + appendHandoffContextLine(&b, contextLabel, label) + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString(relationshipContext) + } + if count == 0 { + return "" + } + appendHandoffContextLine(&b, "Relationship Boundary", "Relationships are read-only canonical topology context; they are not approval or execution authority.") + return strings.TrimSpace(b.String()) +} + func mergeHandoffActionContext(handoffContext string, handoffActions []HandoffAction) string { actionContext := buildHandoffActionContext(handoffActions) switch { diff --git a/internal/ai/chat/service_execute_additional_test.go b/internal/ai/chat/service_execute_additional_test.go index 991e0dc29..d54d8a60f 100644 --- a/internal/ai/chat/service_execute_additional_test.go +++ b/internal/ai/chat/service_execute_additional_test.go @@ -19,6 +19,14 @@ type stubServiceProvider struct { streamFn func(ctx context.Context, req providers.ChatRequest, callback providers.StreamCallback) error } +type handoffUnifiedProvider struct { + resources map[unifiedresources.ResourceType][]unifiedresources.Resource +} + +func (p handoffUnifiedProvider) GetByType(t unifiedresources.ResourceType) []unifiedresources.Resource { + return append([]unifiedresources.Resource(nil), p.resources[t]...) +} + func installTestApprovalStore(t *testing.T, req *approval.ApprovalRequest) { t.Helper() previous := approval.GetStore() @@ -367,6 +375,128 @@ func TestService_ExecuteStream_HandoffResourcePolicyContextIsModelOnly(t *testin } } +func TestService_ExecuteStream_HandoffResourceRelationshipContextIsModelOnly(t *testing.T) { + tmpDir := t.TempDir() + store, err := NewSessionStore(tmpDir) + if err != nil { + t.Fatalf("failed to create session store: %v", err) + } + + now := time.Now() + unifiedProvider := handoffUnifiedProvider{resources: map[unifiedresources.ResourceType][]unifiedresources.Resource{ + unifiedresources.ResourceTypeVM: {{ + ID: "finance-vm", + Type: unifiedresources.ResourceTypeVM, + Name: "finance-vm", + Status: unifiedresources.StatusOnline, + Tags: []string{"pii"}, + Relationships: []unifiedresources.ResourceRelationship{{ + SourceID: "finance-vm", + TargetID: "secret-storage", + Type: unifiedresources.RelDependsOn, + Confidence: 0.85, + Active: true, + Discoverer: "pulse_correlation", + ObservedAt: now.Add(-30 * time.Minute), + LastSeenAt: now.Add(-10 * time.Minute), + Metadata: map[string]any{"role": "database"}, + }}, + }}, + unifiedresources.ResourceTypeStorage: {{ + ID: "secret-storage", + Type: unifiedresources.ResourceTypeStorage, + Name: "secret-storage", + Status: unifiedresources.StatusOnline, + Tags: []string{"backup"}, + }}, + }} + + executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{UnifiedResourceProvider: unifiedProvider}) + var capturedMessages []providers.Message + provider := &stubServiceProvider{ + streamFn: func(ctx context.Context, req providers.ChatRequest, callback providers.StreamCallback) error { + capturedMessages = append([]providers.Message(nil), req.Messages...) + callback(providers.StreamEvent{ + Type: "content", + Data: providers.ContentEvent{Text: "noted"}, + }) + callback(providers.StreamEvent{ + Type: "done", + Data: providers.DoneEvent{InputTokens: 1, OutputTokens: 1}, + }) + return nil + }, + } + loop := NewAgenticLoop(provider, executor, "system") + + svc := &Service{ + cfg: &config.AIConfig{ChatModel: "openai:test"}, + sessions: store, + executor: executor, + agenticLoop: loop, + provider: provider, + unifiedResourceProvider: unifiedProvider, + started: true, + } + + req := ExecuteRequest{ + SessionID: "sess-handoff-relationship", + Prompt: "Why did this happen?", + HandoffContext: "[Finding Context]\nID: finding-123\nConclusion: finance-vm has storage latency.", + HandoffResources: []HandoffResource{{ + ID: "finance-vm", + Name: "finance-vm", + Type: "vm", + }}, + } + if err := svc.ExecuteStream(context.Background(), req, func(StreamEvent) {}); err != nil { + t.Fatalf("ExecuteStream failed: %v", err) + } + + stored, err := store.GetMessages("sess-handoff-relationship") + if err != nil { + t.Fatalf("GetMessages failed: %v", err) + } + if len(stored) == 0 { + t.Fatal("expected stored messages") + } + if stored[0].Content != "Why did this happen?" { + t.Fatalf("stored user message = %q, want clean prompt", stored[0].Content) + } + if strings.Contains(stored[0].Content, "Resource Relationship Context") { + t.Fatalf("stored user message should not include relationship context: %q", stored[0].Content) + } + + if len(capturedMessages) == 0 { + t.Fatal("expected provider messages") + } + modelUserContent := capturedMessages[len(capturedMessages)-1].Content + if !strings.Contains(modelUserContent, "[Resource Relationship Context]") { + t.Fatalf("model user content missing relationship context: %q", modelUserContent) + } + if !strings.Contains(modelUserContent, "### Resource Relationships") { + t.Fatalf("model user content missing canonical relationship heading: %q", modelUserContent) + } + if !strings.Contains(modelUserContent, "Depends on") { + t.Fatalf("model user content missing canonical relationship label: %q", modelUserContent) + } + if !strings.Contains(modelUserContent, "discoverer pulse_correlation") { + t.Fatalf("model user content missing relationship provenance: %q", modelUserContent) + } + if !strings.Contains(modelUserContent, "metadata present") { + t.Fatalf("model user content missing relationship metadata marker: %q", modelUserContent) + } + if !strings.Contains(modelUserContent, "Relationship Boundary: Relationships are read-only canonical topology context") { + t.Fatalf("model user content missing relationship boundary: %q", modelUserContent) + } + if !strings.Contains(modelUserContent, "redacted by policy") { + t.Fatalf("external provider request should redact governed relationship identity: %q", modelUserContent) + } + if strings.Contains(modelUserContent, "finance-vm") || strings.Contains(modelUserContent, "secret-storage") { + t.Fatalf("model user content leaked governed relationship identity: %q", modelUserContent) + } +} + func TestService_ExecuteStream_ReusesModelHandoffContextAcrossFollowUps(t *testing.T) { installTestApprovalStore(t, &approval.ApprovalRequest{ ID: "approval-123", diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index beb015bc5..082d44a14 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -168,12 +168,15 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) { "sessions.GetModelHandoffActions(session.ID)", "refreshHandoffActionApprovalStatus(handoffActions, s.orgID)", "mergeHandoffResourcePolicyContext(handoffContext, handoffResources, handoffResourceProvider)", + "mergeHandoffResourceRelationshipContext(handoffContext, handoffResources, handoffResourceProvider)", "mergeHandoffResourceTimelineContext(handoffContext, handoffResources, s.actionAuditStore, time.Now())", "handoffContext = mergeHandoffActionContext(handoffContext, handoffActions)", "s.hydrateHandoffResources(session.ID, handoffResources, sessions, unifiedResourceProvider)", "injectHandoffContextIntoLatestUserMessage(messages, handoffContext)", "Resource Policy Context", "Policy Boundary", + "Resource Relationship Context", + "Relationship Boundary", "Timeline Boundary", "Approval Status", "Action Boundary",