Plumb operator-state and operational memory into investigation findings

Closes the "has context vs uses context" gap that defines Pulse's
agent-paradigm differentiation. The orchestrator (in pulse-pro) used
to receive a Finding with no awareness of the operator's
commitments — Patrol could investigate a resource the operator had
marked never-auto-remediate and propose a restart fix that the
action broker would refuse downstream. The proposal shouldn't have
happened in the first place.

Adds two optional fields to aicontracts.Finding:

- OperatorContext: intentionally offline, never auto-remediate,
  maintenance window with computed active flag, criticality, note.
  Populated in MaybeInvestigateFinding from the same operator-state
  projection the suppression hot path consumes, so investigation
  reasoning and suppression behavior cannot drift apart.
- OperationalMemory: regression count, previous resolved fix
  summary, last regression timestamp, times raised. Populated in
  ToCoreFinding from fields the internal Finding already carries.

ResourceOperatorStateProjection grew a NeverAutoRemediate field —
the investigation read path needs it (so the orchestrator can avoid
proposing fixes the broker would refuse) even though the
suppression hot path doesn't. Same projection serves both reads.

Both fields are nil when there's no signal (fresh finding, no
operator state) so the orchestrator branches on absence rather
than parsing zero-valued structs. The pulse-pro orchestrator
consumes the fields in a separate slice; this slice ships the
in-repo half of the data path.
This commit is contained in:
rcourtman
2026-05-09 21:03:15 +01:00
parent c38a46b0b5
commit 51c5d344ce
12 changed files with 348 additions and 1 deletions
@@ -917,6 +917,15 @@ profile and assignment columns, but embedded table framing must route through
## Current State
The router projection-builder (`internal/api/router.go`) that wires
the operator-state provider into the findings runtime now also
populates `NeverAutoRemediate` on the projection. The investigation
runtime reads the same projection to attach `OperatorContext` to
findings handed to the orchestrator, so investigation reasoning sees
the same lock-against-remediation flag that the action broker
enforces downstream — no possible drift between "what Patrol
proposes" and "what the broker accepts."
`/api/agent/events` is the SSE stream agents subscribe to for
real-time notifications (`finding.created` events plus a 15-second
heartbeat keepalive). The broadcaster drops events for slow
@@ -591,6 +591,29 @@ call (active maintenance window plus the `IntentionallyOffline`
flag) so adding new signals later does not multiply round-trips per
finding.
The investigation runtime now hands the orchestrator a Finding
pre-enriched with operator-set state and operational memory.
`MaybeInvestigateFinding` (in `internal/ai/patrol_findings.go`)
calls `f.ToCoreFinding()` then attaches a
`FindingOperatorContext` from the in-memory operator-state
projection (intentionally offline, never auto-remediate, active
maintenance window) and a `FindingOperationalMemory` projection
(regression count, previous resolved fix summary, times raised)
populated from fields the internal `Finding` already carries. The
orchestrator (in pulse-pro) consumes these fields when reasoning
about the next move — it does not need a separate read to get
the situated picture, and it can avoid proposing fixes the
operator has locked the resource against.
`ResourceOperatorStateProjection` carries `NeverAutoRemediate`
alongside `IntentionallyOffline` and `MaintenanceWindow` so the
investigation read path and the suppression read path share a
single projection. The findings store exposes the projection via
`OperatorStateProjectionFor`; the suppression hot path keeps its
existing internal access. Both paths see the same operator-state
facts so investigation reasoning and suppression behavior cannot
drift against each other.
A cross-slice consequence worth pinning: operator-state-suppressed
findings (auto-dismissed with `DismissedReason="expected_behavior"`
and `operator_state_cause` metadata) are also ineligible for
@@ -1343,6 +1343,17 @@ the canonical monitored-system blocked payload.
## Current State
`aicontracts.Finding` (the shape Patrol hands the investigation
orchestrator) carries optional `OperatorContext` and
`OperationalMemory` projections. The router-side wire-up populates
the operator-state projection with `NeverAutoRemediate` alongside
`IntentionallyOffline` and the maintenance-window block, and the
investigation runtime in `internal/ai/patrol_findings.go` attaches
the projection to the Finding before calling the orchestrator. This
is the one in-process write path that decides what the orchestrator
sees about operator commitments; all consumers (in-process Patrol,
external Claude Code, future MCP) read the same enriched shape.
`/api/agent/events` is the agent SSE stream — the substrate piece
that closes the agent-paradigm triangle (discovery + bundled reads +
push notifications). Agents subscribe once and receive real-time
@@ -1198,6 +1198,21 @@ the other. The section uses `createNonSuspendingQuery` rather than
`createResource` so the drawer's parent Suspense boundary does not
flicker the page-level fallback while operator state is in flight.
The investigation runtime hands the orchestrator a finding
pre-enriched with the operator-set state and operational memory it
needs to reason from. The `aicontracts.Finding` shape carries an
optional `OperatorContext` (intentionally offline, never
auto-remediate, maintenance window) and an `OperationalMemory`
projection (regression count, previous resolved fix summary, times
raised); `MaybeInvestigateFinding` populates both from the
in-process findings store and the operator-state provider before
calling the orchestrator. This is the data path that converts
"Pulse holds privileged context" into "Pulse uses privileged
context" — the orchestrator (in pulse-pro) reads the fields when
formatting its system prompt, so investigations on locked or
under-maintenance resources reason from the operator's
commitments instead of contradicting them.
The findings store also consumes per-resource operator-set state via
the narrow `ResourceOperatorStateProvider` interface installed by
`SetResourceOperatorStateProvider`. The API layer wires an adapter
@@ -506,6 +506,15 @@ shell clickable behind another overlay.
## Current State
The investigation enrichment path in `MaybeInvestigateFinding`
adds at most one operator-state projection lookup per investigation
(in-memory via the existing provider) and zero additional reads —
`OperationalMemory` is built from fields the internal Finding
already carries. Investigation enrichment does not multiply
per-finding cost as the operator-state surface grows; the projection
serves both suppression and investigation reads from a single
shape.
The agent SSE stream at `/api/agent/events` keeps publishers
non-blocking by design: the broadcaster's per-subscriber buffer is
bounded (64 events) and a full buffer drops the event for that
@@ -947,6 +947,13 @@ bypass the API fail-closed execution gate.
## Current State
The investigation enrichment path reads operator-state from the
in-memory provider already wired against the durable
`resource_operator_state` SQLite table, so an operator's
commitments survive across restarts on the investigation read path
the same way they do on the suppression read path — both flow
through the same provider over the same durable table.
The agent SSE stream at `/api/agent/events` is in-memory and
stateless. No persistence; each connection starts fresh from the
moment of subscribe. Agents that need to catch up across reconnects
+50 -1
View File
@@ -567,8 +567,15 @@ func (f *Finding) CanRetryInvestigation() bool {
// ToCoreFinding converts to the shared InvestigationFinding type used by the
// investigation orchestrator. Centralised here so a field added to
// InvestigationFinding causes a compile error in exactly one place.
//
// The OperationalMemory projection is populated unconditionally from
// fields the internal Finding already carries (regression count,
// previous resolved fix). OperatorContext is NOT populated here
// because that data lives in the unified-resources store, which this
// package does not import. Callers that have access to the operator
// state attach it via WithOperatorContext after conversion.
func (f *Finding) ToCoreFinding() *InvestigationFinding {
return &InvestigationFinding{
core := &InvestigationFinding{
ID: f.ID,
Key: f.Key,
Severity: string(f.Severity),
@@ -586,6 +593,23 @@ func (f *Finding) ToCoreFinding() *InvestigationFinding {
LastInvestigatedAt: f.LastInvestigatedAt,
InvestigationAttempts: f.InvestigationAttempts,
}
// Populate operational memory from fields the internal Finding
// already accumulates. Only attach when there is something
// meaningful to say — a fresh finding (no regressions, no prior
// fix, no raises beyond 1) leaves the field nil so the
// orchestrator can treat absence as "no prior history" rather
// than parsing a zero-valued struct.
if f.RegressionCount > 0 || f.PreviousResolvedFixSummary != "" || f.TimesRaised > 1 {
core.OperationalMemory = &aicontracts.FindingOperationalMemory{
RegressionCount: f.RegressionCount,
LastRegressionAt: f.LastRegressionAt,
PreviousResolvedFixSummary: f.PreviousResolvedFixSummary,
TimesRaised: f.TimesRaised,
}
}
return core
}
// Getter methods for aicontracts.OrchestratorAIFinding interface
@@ -692,6 +716,14 @@ type ResourceOperatorStateMaintenanceWindow struct {
type ResourceOperatorStateProjection struct {
MaintenanceWindow *ResourceOperatorStateMaintenanceWindow
IntentionallyOffline bool
// NeverAutoRemediate is the operator's "do not act on this
// resource" flag. The findings store does not consume it during
// suppression (that's the action broker's concern), but the
// investigation runtime needs it so the orchestrator can avoid
// proposing fixes that the broker would refuse. Carrying it on
// the same projection means the suppression and investigation
// hot paths share a single read.
NeverAutoRemediate bool
}
// ResourceOperatorStateProvider is the narrow interface the findings
@@ -740,6 +772,23 @@ func (s *FindingsStore) SetResourceOperatorStateProvider(p ResourceOperatorState
s.resourceOperatorStateProvider = p
}
// OperatorStateProjectionFor exposes the operator-state projection for
// a resource so callers outside the suppression hot path (e.g. the
// patrol investigation runtime, when it's about to hand a finding to
// the orchestrator) can attach the same per-resource intent the
// store uses internally. Returns false when no provider is wired or
// when the provider reports no state for the resource — callers
// branch on the bool rather than parsing zero-valued projections.
func (s *FindingsStore) OperatorStateProjectionFor(resourceID string, now time.Time) (ResourceOperatorStateProjection, bool) {
s.mu.RLock()
provider := s.resourceOperatorStateProvider
s.mu.RUnlock()
if provider == nil || resourceID == "" {
return ResourceOperatorStateProjection{}, false
}
return provider.OperatorStateProjection(resourceID, now)
}
// SetPersistence sets the persistence layer and loads existing findings
func (s *FindingsStore) SetPersistence(p FindingsPersistence) error {
s.mu.Lock()
+113
View File
@@ -2109,3 +2109,116 @@ func TestFindingsStore_OperatorStateSuppressedFindingsDoNotTriggerInvestigation(
})
}
}
func TestToCoreFinding_AttachesOperationalMemoryWhenHistoryExists(t *testing.T) {
// The orchestrator (in pulse-pro) reads OperationalMemory to know
// "what we already know" about this finding — regression count,
// previous fix that worked, total times raised. Without it, the
// LLM proposing a fix has no operational context and may suggest
// something that has already been tried and failed. Pin the
// projection so the in-repo data path stays wired.
regressionTime := time.Date(2026, 5, 1, 10, 0, 0, 0, time.UTC)
f := &Finding{
ID: "f-history",
ResourceID: "vm:101",
Severity: FindingSeverityWarning,
Title: "Disk pressure",
RegressionCount: 3,
LastRegressionAt: &regressionTime,
PreviousResolvedFixSummary: "Free 200GB before backup window",
TimesRaised: 7,
}
core := f.ToCoreFinding()
if core.OperationalMemory == nil {
t.Fatal("OperationalMemory must be attached when the finding has prior history")
}
if core.OperationalMemory.RegressionCount != 3 {
t.Errorf("regression count: got %d want 3", core.OperationalMemory.RegressionCount)
}
if core.OperationalMemory.PreviousResolvedFixSummary != "Free 200GB before backup window" {
t.Errorf("previous fix summary must round-trip; got %q", core.OperationalMemory.PreviousResolvedFixSummary)
}
if core.OperationalMemory.TimesRaised != 7 {
t.Errorf("times raised: got %d want 7", core.OperationalMemory.TimesRaised)
}
if core.OperationalMemory.LastRegressionAt == nil || !core.OperationalMemory.LastRegressionAt.Equal(regressionTime) {
t.Errorf("last regression timestamp must round-trip; got %v", core.OperationalMemory.LastRegressionAt)
}
}
func TestToCoreFinding_LeavesOperationalMemoryNilForFreshFindings(t *testing.T) {
// A fresh finding (first detection, no regressions, no prior fix)
// must NOT carry an OperationalMemory — the orchestrator's
// reasoning should treat absence as "no prior history" rather
// than parsing a zero-valued struct.
f := &Finding{
ID: "f-fresh",
ResourceID: "vm:102",
Severity: FindingSeverityWarning,
Title: "CPU saturated",
TimesRaised: 1, // first raise — not regression history
}
core := f.ToCoreFinding()
if core.OperationalMemory != nil {
t.Errorf("OperationalMemory must be nil for fresh findings; got %+v", core.OperationalMemory)
}
}
func TestFindingsStore_OperatorStateProjectionFor_ReturnsFalseWithoutProvider(t *testing.T) {
// Default store with no provider wired must report false so
// callers can branch on absence rather than parsing zero-valued
// projections.
store := NewFindingsStore()
_, ok := store.OperatorStateProjectionFor("vm:101", time.Now())
if ok {
t.Error("expected false when no provider is wired")
}
}
func TestFindingsStore_OperatorStateProjectionFor_ProxiesProvider(t *testing.T) {
// When a provider is wired, OperatorStateProjectionFor must
// surface the same projection the suppression hot path sees.
// The investigation runtime relies on this to attach
// OperatorContext to findings handed to the orchestrator —
// drift between this read path and the suppression read path
// would mean the orchestrator reasons against different facts
// than the suppression branch.
store := NewFindingsStore()
expected := ResourceOperatorStateProjection{
IntentionallyOffline: true,
NeverAutoRemediate: true,
}
store.SetResourceOperatorStateProvider(
ResourceOperatorStateProviderFunc(func(_ string, _ time.Time) (ResourceOperatorStateProjection, bool) {
return expected, true
}),
)
got, ok := store.OperatorStateProjectionFor("vm:locked", time.Now())
if !ok {
t.Fatal("expected projection from wired provider")
}
if got.IntentionallyOffline != expected.IntentionallyOffline {
t.Errorf("IntentionallyOffline must round-trip; got %v want %v", got.IntentionallyOffline, expected.IntentionallyOffline)
}
if got.NeverAutoRemediate != expected.NeverAutoRemediate {
t.Errorf("NeverAutoRemediate must round-trip — investigation runtime reads this projection too; got %v want %v", got.NeverAutoRemediate, expected.NeverAutoRemediate)
}
}
func TestFindingsStore_OperatorStateProjectionFor_RejectsEmptyResourceID(t *testing.T) {
// Defensive: empty resource ID short-circuits to false rather
// than calling the provider with a useless query.
store := NewFindingsStore()
store.SetResourceOperatorStateProvider(
ResourceOperatorStateProviderFunc(func(_ string, _ time.Time) (ResourceOperatorStateProjection, bool) {
t.Fatal("provider must not be called with empty resource id")
return ResourceOperatorStateProjection{}, false
}),
)
if _, ok := store.OperatorStateProjectionFor("", time.Now()); ok {
t.Error("expected false on empty resource id")
}
}
+36
View File
@@ -1216,6 +1216,42 @@ func (p *PatrolService) MaybeInvestigateFinding(f *Finding) {
// Convert Finding to shared finding type for the investigation orchestrator
invFinding := f.ToCoreFinding()
// Attach the operator-set state for the finding's resource so the
// orchestrator's reasoning can incorporate the operator's
// commitments (intentionally offline, never auto-remediate, active
// maintenance window). Without this, Patrol can propose fixes that
// contradict the operator's intent — the action broker refuses
// them downstream (slice 33's `resource_remediation_locked:`), but
// the proposal shouldn't have happened in the first place. The
// projection is also the data path "Pulse uses the privileged
// context" that defines the product's differentiation.
if p.findings != nil {
now := time.Now()
if projection, ok := p.findings.OperatorStateProjectionFor(f.ResourceID, now); ok {
ctx := &aicontracts.FindingOperatorContext{
IntentionallyOffline: projection.IntentionallyOffline,
NeverAutoRemediate: projection.NeverAutoRemediate,
}
if window := projection.MaintenanceWindow; window != nil {
ctx.MaintenanceWindowActive = !now.Before(window.StartAt) && now.Before(window.EndAt)
start := window.StartAt
end := window.EndAt
ctx.MaintenanceStartAt = &start
ctx.MaintenanceEndAt = &end
ctx.MaintenanceReason = window.Reason
}
// Attach only when something meaningful is set —
// otherwise leave the field nil so the orchestrator can
// branch on absence rather than on zero values.
if ctx.IntentionallyOffline ||
ctx.NeverAutoRemediate ||
ctx.MaintenanceStartAt != nil ||
ctx.MaintenanceWindowActive {
invFinding.OperatorContext = ctx
}
}
}
// Trigger investigation in background with a timeout to prevent indefinite runs.
// Track with WaitGroup so graceful shutdown can wait for completion.
p.investigationWg.Add(1)
+18
View File
@@ -13233,6 +13233,24 @@ func assertJSONSnapshot(t *testing.T, got []byte, want string) {
}
}
// TestContract_InvestigationProjectionCarriesNeverAutoRemediate pins
// the cross-path agreement: the projection-builder closure in
// router.go must populate NeverAutoRemediate on the projection so
// the investigation read path sees the same lock-against-remediation
// flag the action broker enforces. Without this, Patrol could
// propose fixes on locked resources that the broker would refuse —
// the fix proposal shouldn't have happened in the first place.
func TestContract_InvestigationProjectionCarriesNeverAutoRemediate(t *testing.T) {
source, err := os.ReadFile("router.go")
if err != nil {
t.Fatalf("read router.go: %v", err)
}
src := string(source)
if !strings.Contains(src, "NeverAutoRemediate: state.NeverAutoRemediate,") {
t.Error("router.go must populate NeverAutoRemediate on the projection so the investigation read path sees the operator's lock — drift here means Patrol can propose fixes the broker refuses")
}
}
// TestContract_AgentEventsStreamPublishesOnFindingCreated pins the
// agent SSE stream's most consequential producer hook: the
// findings-runtime callback in router.go must publish a
+1
View File
@@ -1842,6 +1842,7 @@ func (r *Router) startPatrolForContext(ctx context.Context, orgID string) bool {
}
projection := ai.ResourceOperatorStateProjection{
IntentionallyOffline: state.IntentionallyOffline,
NeverAutoRemediate: state.NeverAutoRemediate,
}
if state.IsInMaintenanceAt(now) {
projection.MaintenanceWindow = &ai.ResourceOperatorStateMaintenanceWindow{
+56
View File
@@ -75,6 +75,62 @@ type Finding struct {
InvestigationOutcome string `json:"investigation_outcome,omitempty"`
LastInvestigatedAt *time.Time `json:"last_investigated_at,omitempty"`
InvestigationAttempts int `json:"investigation_attempts"`
// OperatorContext carries the operator's per-resource intent the
// investigation runtime should respect when reasoning about this
// finding. nil when no operator-set state has been recorded for
// the resource. The orchestrator (in pulse-pro) consumes this to
// avoid proposing fixes that contradict operator commitments —
// e.g. if NeverAutoRemediate is set, propose investigation-only
// outputs instead of remediation actions; if a maintenance
// window is active, frame the finding as "expected during
// maintenance" rather than urgent.
OperatorContext *FindingOperatorContext `json:"operator_context,omitempty"`
// OperationalMemory carries the regression and fix history Pulse
// already accumulated for this finding's identity. It exists to
// give the orchestrator's reasoning a "what we already know" block
// without it having to query the findings store separately. nil
// when there is no prior history (fresh finding).
OperationalMemory *FindingOperationalMemory `json:"operational_memory,omitempty"`
}
// FindingOperatorContext is the orchestrator-facing projection of
// operator-set state for the finding's resource. Mirrors the shape of
// `internal/api/agent_resource_context.go`'s
// AgentResourceOperatorState so external agents and the in-process
// orchestrator see the same field names.
type FindingOperatorContext struct {
IntentionallyOffline bool `json:"intentionally_offline"`
NeverAutoRemediate bool `json:"never_auto_remediate"`
MaintenanceStartAt *time.Time `json:"maintenance_start_at,omitempty"`
MaintenanceEndAt *time.Time `json:"maintenance_end_at,omitempty"`
MaintenanceReason string `json:"maintenance_reason,omitempty"`
Criticality string `json:"criticality,omitempty"`
Note string `json:"note,omitempty"`
MaintenanceWindowActive bool `json:"maintenance_window_active"`
}
// FindingOperationalMemory bundles the regression-and-fix history
// the orchestrator should reason from when proposing the next move.
// All fields zero/empty mean "no operational memory" — the
// orchestrator should treat the finding as fresh.
type FindingOperationalMemory struct {
// RegressionCount is the number of times this finding has been
// resolved and re-detected. >0 means "this is not a one-off."
RegressionCount int `json:"regression_count"`
// LastRegressionAt is the most recent regression timestamp.
LastRegressionAt *time.Time `json:"last_regression_at,omitempty"`
// PreviousResolvedFixSummary is the description of the proposed
// fix that resolved the finding the last time it was active.
// Captured at regression time from the prior investigation
// record. Operator memory: "what worked last time."
PreviousResolvedFixSummary string `json:"previous_resolved_fix_summary,omitempty"`
// TimesRaised is the total raise count across all detections.
// Distinct from RegressionCount because it includes
// re-detections while still active (not just regressions
// after resolution).
TimesRaised int `json:"times_raised"`
}
// ---------------------------------------------------------------------------