diff --git a/docs/release-control/v6/internal/SOURCE_OF_TRUTH.md b/docs/release-control/v6/internal/SOURCE_OF_TRUTH.md index be2c992c1..7e562ed6f 100644 --- a/docs/release-control/v6/internal/SOURCE_OF_TRUTH.md +++ b/docs/release-control/v6/internal/SOURCE_OF_TRUTH.md @@ -1,6 +1,6 @@ # Pulse v6 Source Of Truth -Last updated: 2026-03-17 +Last updated: 2026-03-20 Status: ACTIVE This file is the stable human governance layer for the active v6 release @@ -398,6 +398,11 @@ Assertion design rules: defensible stop point and complete the next obvious same-lane work when it is necessary for trustworthy results, then normalize any remaining valid gap instead of calling the lane done by default. +12. Unified-resource change history is the canonical durable backend timeline. + Alert incident memory may retain investigation-local notes, analysis, + commands, runbooks, and alert lifecycle breadcrumbs, but it must remain a + derived incident projection rather than a competing source of truth for + durable resource history. ## Cross-Repo Contracts diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 3068c08ad..7fc764177 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -188,6 +188,11 @@ detectors only serving as fallback coverage when the canonical store is not available. When that patrol-local fallback is used, it must render through the shared memory change presentation helper so the same heading, scope prefix, and change-type labels are reused instead of being rebuilt ad hoc in AI-local code. +`internal/ai/memory/incidents.go` is therefore an alert-scoped investigation +projection only: it may retain notes, analysis, command executions, runbooks, +and alert lifecycle breadcrumbs for one incident, but it must not become a +parallel source of truth for durable backend history that already belongs to +`internal/unifiedresources/`. The AI correlation root-cause engine also consumes the canonical unified- resource relationship model directly, so cross-resource reasoning stays aligned with the same relationship edges that back the resource API instead of @@ -246,6 +251,9 @@ Resource-only incident context should follow the same rule: if an alert timeline is absent, the incident prompt path should fall back to the canonical unified-resource timeline rather than depending only on patrol-local change memory. +When both an alert identifier and a canonical resource ID are known, the prompt +path should include both surfaces in source-precedence order: alert-scoped +incident memory first, canonical resource timeline second. The same runtime boundary now also owns durable action execution auditing. `internal/ai/chat/service.go` initializes the unified-resource audit store on diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 34910b3b8..c74ba6a43 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -463,6 +463,13 @@ record state that SQLite persists for the control-plane execution trail. The enterprise audit API now reads those same unified-resource action and export records back out, so the durable store is not just a write sink but the canonical history surface for the control-plane verbs. +That same ownership boundary applies to incident-adjacent runtime history: +durable backend facts about what changed on a resource belong in +`ResourceChange` and the shared unified-resource store, while +`internal/ai/memory/incidents.go` remains an alert-scoped investigation +projection for notes, analyses, command breadcrumbs, runbooks, and other +operator-facing incident memory. Agents must not model the same durable backend +fact in both places as competing primary histories. The unified resource core is strong and canonical, but monitoring and some frontend/API consumers are still being tightened around it. diff --git a/internal/ai/coverage_increase_test.go b/internal/ai/coverage_increase_test.go index 9caea314e..f53e72378 100644 --- a/internal/ai/coverage_increase_test.go +++ b/internal/ai/coverage_increase_test.go @@ -8,6 +8,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/ai/baseline" "github.com/rcourtman/pulse-go-rewrite/internal/ai/correlation" "github.com/rcourtman/pulse-go-rewrite/internal/ai/memory" + "github.com/rcourtman/pulse-go-rewrite/internal/alerts" "github.com/rcourtman/pulse-go-rewrite/internal/models" unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" ) @@ -479,6 +480,55 @@ func TestService_BuildIncidentContext(t *testing.T) { } } +func TestService_BuildIncidentContext_AlertIncludesCanonicalResourceChanges(t *testing.T) { + s := NewService(nil, nil) + + incidentStore := memory.NewIncidentStore(memory.IncidentStoreConfig{}) + alert := &alerts.Alert{ + ID: "alert-canonical-1", + Type: "cpu_high", + Level: alerts.AlertLevelWarning, + ResourceID: "res-canonical-1", + ResourceName: "vm-canonical-1", + StartTime: time.Now().Add(-30 * time.Minute), + } + incidentStore.RecordAlertFired(alert) + + canonicalStore := unifiedresources.NewMemoryStore() + if err := canonicalStore.RecordChange(unifiedresources.ResourceChange{ + ID: "change-canonical-1", + ObservedAt: time.Now().Add(-15 * time.Minute), + ResourceID: alert.ResourceID, + Kind: unifiedresources.ChangeConfigUpdate, + From: "old", + To: "new", + SourceType: unifiedresources.SourcePulseDiff, + SourceAdapter: unifiedresources.AdapterOpsAgent, + Confidence: unifiedresources.ConfidenceHigh, + Reason: "Config drift corrected", + }); err != nil { + t.Fatalf("record canonical change: %v", err) + } + + s.mu.Lock() + s.incidentStore = incidentStore + s.orgID = "org-1" + s.resourceExportStore = canonicalStore + s.resourceExportStoreOrgID = s.orgID + s.mu.Unlock() + + ctx := s.buildIncidentContext(alert.ResourceID, alert.ID) + if !strings.Contains(ctx, "Incident Memory") { + t.Fatalf("expected alert incident memory in context, got %q", ctx) + } + if !strings.Contains(ctx, "Recent Changes") { + t.Fatalf("expected canonical recent changes in alert context, got %q", ctx) + } + if !strings.Contains(ctx, "Config update") { + t.Fatalf("expected canonical change summary in alert context, got %q", ctx) + } +} + func TestService_BuildRelationshipContext_UsesCanonicalReadState(t *testing.T) { now := time.Now() s := NewService(nil, nil) diff --git a/internal/ai/memory/incidents.go b/internal/ai/memory/incidents.go index 185ed482d..4099b3ce0 100644 --- a/internal/ai/memory/incidents.go +++ b/internal/ai/memory/incidents.go @@ -45,7 +45,9 @@ type IncidentEvent struct { Details map[string]interface{} `json:"details,omitempty"` } -// Incident captures an alert occurrence and its timeline. +// Incident captures an alert occurrence and its investigation timeline. +// It is an alert-scoped memory/projection for investigation support rather than +// the canonical durable resource-change history. type Incident struct { ID string `json:"id"` AlertIdentifier string `json:"alertIdentifier"` @@ -147,7 +149,9 @@ type IncidentStoreConfig struct { MaxAgeDays int } -// IncidentStore maintains incident timelines and persistence. +// IncidentStore maintains alert-scoped incident timelines and persistence for +// investigation memory. Durable resource history belongs to the canonical +// unified-resource change model. type IncidentStore struct { mu sync.RWMutex saveMu sync.Mutex diff --git a/internal/ai/service.go b/internal/ai/service.go index 1ddc726ee..9a3841f3d 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -183,7 +183,7 @@ type Service struct { resourceExportStoreOrgID string patrolService *PatrolService // Background AI monitoring service metadataProvider MetadataProvider // Enables AI to update resource URLs - incidentStore *memory.IncidentStore // Incident timelines for alert memory + incidentStore *memory.IncidentStore // Alert-scoped investigation memory; not the canonical durable resource history chatService ChatServiceProvider // Chat service for investigation orchestrator // Infrastructure discovery service - detects apps running on hosts @@ -4488,7 +4488,10 @@ func (s *Service) buildRemediationContext(resourceID, currentProblem string) str return context } -// buildIncidentContext adds incident timeline context for alerts/resources. +// buildIncidentContext adds alert-scoped incident memory first, then the +// canonical resource timeline and relationship context when a resource is known. +// The incident store is a derived investigation view, not the source of truth +// for durable backend history. func (s *Service) buildIncidentContext(resourceID, alertIdentifier string) string { s.mu.RLock() store := s.incidentStore @@ -4508,7 +4511,7 @@ func (s *Service) buildIncidentContext(resourceID, alertIdentifier string) strin } } - if alertIdentifier == "" && resourceID != "" { + if resourceID != "" { if recentChanges := s.buildRecentResourceChangesContext(resourceID); recentChanges != "" { sections = append(sections, recentChanges) } diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 4e98817d5..208fc52ae 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -2047,7 +2047,10 @@ class SubsystemLookupTest(unittest.TestCase): self.assertEqual(lane_context["lane_id"], "L13") self.assertEqual( {decision["id"] for decision in lane_context["resolved_decisions"]}, - {"host-type-migration-boundary-audit"}, + { + "canonical-timeline-source-precedence", + "host-type-migration-boundary-audit", + }, ) def test_lookup_paths_assigns_monitoring_discovery_runtime(self) -> None: