Files
2026-05-13 14:19:59 +01:00

365 lines
14 KiB
Go

package unifiedresources
import (
"errors"
"fmt"
"strings"
"time"
)
// LoopReportType is the discriminator that names the loop a report came
// from. Only one loop is implemented for the MVP — maintenance window
// verification — but the field stays in the contract so future loops can
// reuse the same persistence and review surfaces without a schema split.
type LoopReportType string
const (
// LoopReportTypeMaintenanceVerification is the durable summary the
// sentinel writes after a maintenance window ends for a resource. The
// product-facing name is "Maintenance Verification Report"; this is
// the internal type token used for storage and API filtering.
LoopReportTypeMaintenanceVerification LoopReportType = "maintenance_verification"
)
const (
MaintenanceVerificationActivityReported = "maintenance_verification_reported"
maintenanceVerificationSourceAdapter ChangeSourceAdapter = "maintenance_sentinel"
)
// LoopReportStatus reports the verification outcome.
//
// - healthy — all deterministic checks passed.
// - needs_review — evidence is ambiguous; an operator should
// look. Used when there is no metric history to compare, when the
// resource is operator-marked intentionally offline, or when a
// warning-level alert/finding is active.
// - failed_verification — at least one strong negative signal
// (critical alert, critical finding, recent failed action) is
// present. The report does not auto-remediate; the operator
// decides next steps.
//
// The set is intentionally small. Adding more states is a contract
// change because the UI and review action depend on the enum.
type LoopReportStatus string
const (
LoopReportStatusPending LoopReportStatus = "pending"
LoopReportStatusHealthy LoopReportStatus = "healthy"
LoopReportStatusNeedsReview LoopReportStatus = "needs_review"
LoopReportStatusFailedVerification LoopReportStatus = "failed_verification"
)
// LoopReportUserOutcome records the operator's review verdict after
// reading a report. "" means the operator has not yet reviewed.
type LoopReportUserOutcome string
const (
LoopReportUserOutcomeReviewed LoopReportUserOutcome = "reviewed"
)
// LoopReportEvidence captures the deterministic checks the sentinel
// ran. Each field is read-only operational evidence — counts and
// summaries — not executable commands or stale plans. The shape is
// stable so future review tooling can render every report uniformly.
type LoopReportEvidence struct {
// OperatorStateSummary is a short human-readable summary of the
// operator state at evaluation time (e.g.
// "maintenance_window_ended", "intentionally_offline", "no
// operator state").
OperatorStateSummary string `json:"operatorStateSummary,omitempty"`
// ActiveCriticalAlerts and ActiveWarningAlerts count alerts that
// were active for this resource at evaluation time.
ActiveCriticalAlerts int `json:"activeCriticalAlerts"`
ActiveWarningAlerts int `json:"activeWarningAlerts"`
// ActiveCriticalFindings and ActiveWarningFindings count Patrol
// findings active for the resource at evaluation time.
ActiveCriticalFindings int `json:"activeCriticalFindings"`
ActiveWarningFindings int `json:"activeWarningFindings"`
// FailedActionsSinceWindowStart is the number of action audits
// targeting the resource whose state ended in "failed" with an
// updated_at after the maintenance window started.
FailedActionsSinceWindowStart int `json:"failedActionsSinceWindowStart"`
// MetricRecovery describes the basic recent-metric check. Absent
// when no metric source was available for the resource at
// evaluation time.
MetricRecovery *MetricRecoveryEvidence `json:"metricRecovery,omitempty"`
// PatrolRunTODO is set when the deterministic evidence was
// ambiguous and a scoped Patrol run would have helped, but
// triggering one was not safe in this build. The value is the
// reason string so the UI can surface what was missing.
//
// MVP: deterministic checks only; the Patrol run trigger is
// deferred until the Patrol API surfaces a scoped per-resource
// run entrypoint that does not race the global scheduler.
PatrolRunTODO string `json:"patrolRunTodo,omitempty"`
}
// MetricRecoveryEvidence is a small summary of the metric values
// observed inside the maintenance window vs. immediately after it.
// "Recent metric recovery" is intentionally shallow for the MVP — a
// per-metric trend label rather than a model.
type MetricRecoveryEvidence struct {
// MetricsObserved lists the metric short names the sentinel
// inspected (e.g. "cpu", "memory").
MetricsObserved []string `json:"metricsObserved,omitempty"`
// SamplesAfterEnd is the number of metric samples observed after
// the window ended. Zero means the resource has not reported any
// metrics since the window closed — operator should look.
SamplesAfterEnd int `json:"samplesAfterEnd"`
// Trend describes the post-window metric trend in plain English.
// Allowed values: "improving", "stable", "degrading", "unknown".
Trend string `json:"trend,omitempty"`
// Note is an optional explanation surfaced to the operator
// alongside the trend.
Note string `json:"note,omitempty"`
}
// LoopReport is the durable record the sentinel writes when a loop run
// produces an outcome an operator may want to review. Fields are
// minimal on purpose — this is shared substrate, not a generic agent
// framework.
//
// For the maintenance-verification loop:
// - Scope is the canonical resource ID.
// - Trigger is always "maintenance_window_end".
// - WindowStartedAt and WindowEndedAt mirror the operator-set window
// so future runs can de-duplicate against the (resource, window)
// pair.
type LoopReport struct {
ID string `json:"id"`
Type LoopReportType `json:"type"`
Scope string `json:"scope"`
Trigger string `json:"trigger"`
Goal string `json:"goal,omitempty"`
Status LoopReportStatus `json:"status"`
StartedAt time.Time `json:"startedAt"`
CompletedAt time.Time `json:"completedAt"`
WindowStartedAt *time.Time `json:"windowStartedAt,omitempty"`
WindowEndedAt *time.Time `json:"windowEndedAt,omitempty"`
Evidence LoopReportEvidence `json:"evidence"`
LinkedFindingIDs []string `json:"linkedFindingIds,omitempty"`
LinkedAlertIDs []string `json:"linkedAlertIds,omitempty"`
LinkedActionIDs []string `json:"linkedActionIds,omitempty"`
LinkedPatrolRunID string `json:"linkedPatrolRunId,omitempty"`
Recommendation string `json:"recommendation,omitempty"`
UserOutcome LoopReportUserOutcome `json:"userOutcome,omitempty"`
ReviewedAt *time.Time `json:"reviewedAt,omitempty"`
ReviewedBy string `json:"reviewedBy,omitempty"`
ReviewNote string `json:"reviewNote,omitempty"`
}
// ErrLoopReportInvalid is returned when a record fails contract checks
// at the store boundary.
var ErrLoopReportInvalid = errors.New("loop_report_invalid")
// IsValidLoopReportType reports whether the value names a known loop
// report type. Unknown types are rejected at the store boundary so
// freeform discriminators cannot accumulate.
func IsValidLoopReportType(t LoopReportType) bool {
switch t {
case LoopReportTypeMaintenanceVerification:
return true
}
return false
}
// IsValidLoopReportStatus reports whether the value names a known
// terminal or in-flight status.
func IsValidLoopReportStatus(s LoopReportStatus) bool {
switch s {
case LoopReportStatusPending,
LoopReportStatusHealthy,
LoopReportStatusNeedsReview,
LoopReportStatusFailedVerification:
return true
}
return false
}
// IsValidLoopReportUserOutcome reports whether the value names a known
// review verdict. Empty is valid — the operator has not reviewed yet.
func IsValidLoopReportUserOutcome(o LoopReportUserOutcome) bool {
switch o {
case "", LoopReportUserOutcomeReviewed:
return true
}
return false
}
// NormalizeLoopReport trims string fields, canonicalizes the scope,
// and applies UTC to all timestamps. It does not validate — callers
// follow normalize with ValidateLoopReport.
func NormalizeLoopReport(r LoopReport) LoopReport {
r.ID = strings.TrimSpace(r.ID)
r.Scope = CanonicalResourceID(r.Scope)
r.Trigger = strings.TrimSpace(r.Trigger)
r.Goal = strings.TrimSpace(r.Goal)
r.Recommendation = strings.TrimSpace(r.Recommendation)
r.ReviewedBy = strings.TrimSpace(r.ReviewedBy)
r.ReviewNote = strings.TrimSpace(r.ReviewNote)
r.LinkedPatrolRunID = strings.TrimSpace(r.LinkedPatrolRunID)
r.Evidence.OperatorStateSummary = strings.TrimSpace(r.Evidence.OperatorStateSummary)
r.Evidence.PatrolRunTODO = strings.TrimSpace(r.Evidence.PatrolRunTODO)
if r.Evidence.MetricRecovery != nil {
r.Evidence.MetricRecovery.Trend = strings.TrimSpace(r.Evidence.MetricRecovery.Trend)
r.Evidence.MetricRecovery.Note = strings.TrimSpace(r.Evidence.MetricRecovery.Note)
}
if !r.StartedAt.IsZero() {
r.StartedAt = r.StartedAt.UTC()
}
if !r.CompletedAt.IsZero() {
r.CompletedAt = r.CompletedAt.UTC()
}
if r.WindowStartedAt != nil {
t := r.WindowStartedAt.UTC()
r.WindowStartedAt = &t
}
if r.WindowEndedAt != nil {
t := r.WindowEndedAt.UTC()
r.WindowEndedAt = &t
}
if r.ReviewedAt != nil {
t := r.ReviewedAt.UTC()
r.ReviewedAt = &t
}
r.LinkedFindingIDs = trimAndDedupeStrings(r.LinkedFindingIDs)
r.LinkedAlertIDs = trimAndDedupeStrings(r.LinkedAlertIDs)
r.LinkedActionIDs = trimAndDedupeStrings(r.LinkedActionIDs)
return r
}
// ValidateLoopReport applies the contract checks the persistence layer
// must enforce before writing a record. Returns an
// ErrLoopReportInvalid-wrapped error on violation.
func ValidateLoopReport(r LoopReport) error {
if r.ID == "" {
return fmt.Errorf("%w: id is required", ErrLoopReportInvalid)
}
if !IsValidLoopReportType(r.Type) {
return fmt.Errorf("%w: unknown report type %q", ErrLoopReportInvalid, r.Type)
}
if r.Scope == "" {
return fmt.Errorf("%w: scope (canonical resource id) is required", ErrLoopReportInvalid)
}
if r.Trigger == "" {
return fmt.Errorf("%w: trigger is required", ErrLoopReportInvalid)
}
if !IsValidLoopReportStatus(r.Status) {
return fmt.Errorf("%w: unknown status %q", ErrLoopReportInvalid, r.Status)
}
if r.StartedAt.IsZero() {
return fmt.Errorf("%w: startedAt is required", ErrLoopReportInvalid)
}
if r.CompletedAt.IsZero() {
return fmt.Errorf("%w: completedAt is required", ErrLoopReportInvalid)
}
if r.CompletedAt.Before(r.StartedAt) {
return fmt.Errorf("%w: completedAt must not be before startedAt", ErrLoopReportInvalid)
}
if !IsValidLoopReportUserOutcome(r.UserOutcome) {
return fmt.Errorf("%w: unknown user outcome %q", ErrLoopReportInvalid, r.UserOutcome)
}
return nil
}
func trimAndDedupeStrings(in []string) []string {
if len(in) == 0 {
return nil
}
seen := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
for _, v := range in {
v = strings.TrimSpace(v)
if v == "" {
continue
}
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
if len(out) == 0 {
return nil
}
return out
}
// BuildLoopReportResourceChange projects an immutable loop report into the
// canonical resource timeline. The report remains the source-of-truth record;
// this change is the discoverable timeline evidence that lets existing
// change consumers explain why the resource's post-maintenance state changed.
func BuildLoopReportResourceChange(report LoopReport) (ResourceChange, bool) {
report = NormalizeLoopReport(report)
if report.ID == "" || report.Scope == "" || report.Type != LoopReportTypeMaintenanceVerification {
return ResourceChange{}, false
}
observedAt := report.CompletedAt
if observedAt.IsZero() {
observedAt = report.StartedAt
}
if observedAt.IsZero() {
observedAt = time.Now().UTC()
}
observedAt = observedAt.UTC()
var occurredAt *time.Time
if report.WindowEndedAt != nil {
t := report.WindowEndedAt.UTC()
occurredAt = &t
}
metadata := map[string]any{
"activityType": MaintenanceVerificationActivityReported,
"reportId": report.ID,
"reportType": string(report.Type),
"status": string(report.Status),
"trigger": report.Trigger,
}
if report.WindowStartedAt != nil {
metadata["windowStartedAt"] = report.WindowStartedAt.UTC().Format(time.RFC3339)
}
if report.WindowEndedAt != nil {
metadata["windowEndedAt"] = report.WindowEndedAt.UTC().Format(time.RFC3339)
}
if report.Recommendation != "" {
metadata["recommendation"] = report.Recommendation
}
if len(report.LinkedAlertIDs) > 0 {
metadata["linkedAlertCount"] = len(report.LinkedAlertIDs)
}
if len(report.LinkedFindingIDs) > 0 {
metadata["linkedFindingCount"] = len(report.LinkedFindingIDs)
}
if len(report.LinkedActionIDs) > 0 {
metadata["linkedActionCount"] = len(report.LinkedActionIDs)
}
if report.Evidence.OperatorStateSummary != "" {
metadata["operatorStateSummary"] = report.Evidence.OperatorStateSummary
}
return ResourceChange{
ID: resourceChangeID("loop-report", report.Scope, report.ID, observedAt),
ObservedAt: observedAt,
OccurredAt: occurredAt,
ResourceID: report.Scope,
Kind: ChangeActivity,
From: report.Trigger,
To: string(report.Status),
SourceType: SourceHeuristic,
SourceAdapter: maintenanceVerificationSourceAdapter,
Confidence: ConfidenceHigh,
Reason: "Maintenance verification reported " + string(report.Status),
Metadata: metadata,
}, true
}