Clarify canonical timeline ownership

This commit is contained in:
rcourtman
2026-03-20 11:05:08 +00:00
parent 72daa50893
commit 42e472e9f7
7 changed files with 87 additions and 7 deletions
@@ -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
@@ -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
@@ -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.
+50
View File
@@ -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)
+6 -2
View File
@@ -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
+6 -3
View File
@@ -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)
}
@@ -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: