Files
pulse/docs/ALERT_ENGINE_EVOLUTION.md
T
rcourtman 54cd77d3b1 Mark stale spec, plan, and contract docs with verified status
Repository specs, plans, and contracts are records of past decisions, not
demand. Each triaged document now carries a Status line that was checked
against the code and governance surfaces rather than against the document:

- VMware vCenter phase-1 onboarding, API runtime, resource projection, and
  alerts/assistant specs: IMPLEMENTED (shared floor shipped; live vCenter
  proof still outstanding).
- VMware execution plan and proof matrix: PARKED with a note that the
  first-lab-ready checkpoint was reached and the live proof has been
  blocked since 2026-03-30 with no ledger entry.
- Customer Account and Pulse Account portal specs: IMPLEMENTED at the L17
  RC floor; post-GA expansion is a lane follow-up, not a build signal.
- v6 bridge release foundation spec: SUPERSEDED by the resolved decision
  and lanes L18, L19, L20, and L22.
- Service assurance plan: slices A-E IMPLEMENTED, slices F-H PARKED.
- Availability history contract: IMPLEMENTED (history and fleet slices).
- Alert engine evolution: IMPLEMENTED; UI migration stays demand-gated.
- Legacy host classification audit: record; post-release queue PARKED.
- OIDC scope fix spec: closed record, fixes shipped in v6.0.4/v6.0.5.

Backup task timeline contract is left as ready for implementation because
its ledger entry is threshold-met. The HOME status wall spec, subsystem
contracts, and records are untouched.
2026-09-01 20:43:26 +01:00

9.1 KiB
Raw Blame History

Alert Engine Evolution

Status: IMPLEMENTED (phases 03 engine side complete 2026-08-27: internal/alerts/alert_policy.go, history_projection.go, lifecycle_contract_test.go; the rules-first UI migration stays deferred and demand-gated through FEATURE_REQUESTS.md, as recorded under Phase 3 below). Last reviewed 2026-09-01. Date: 2026-08-26 Scope: pulse only Predecessor: docs/CANONICAL_ALERT_ENGINE_MIGRATION_2026-03-10.md

Purpose

The March 2026 canonical migration correctly rejected a full alert-engine rewrite and began moving alert inputs onto canonical identity (internal/unifiedresources) and specs (internal/alerts/specs). It deliberately froze two layers: transition state stays in the manager's tracking maps, and notification-suppression rules stay where they are.

The post-March issue record shows the recurring regression classes live in exactly those frozen layers: config saves emitting resolved notifications (#1682), grouping silently dropping N1 of N alerts (#1683), resolves sent for suppressed firings (#1553), self-resolving fire/resolve churn deleting history (#1693), chronic re-fires on terminal container state (#1724). Cleaner inputs feeding the same fragile core does not retire these classes.

This document extends the migration's end-state one layer deeper. The mechanism stays the same — strangler migration inside one engine, no big-bang rewrite — applied to the transition core and the suppression path.

Target model (delta over the March doc)

The March doc's layers 1 (unified resource identity), 2 (canonical specs), and 5 (notification fan-out boundary in monitor_alerts.go) stand unchanged. Two layers are added or replaced:

Event log (new, Phase 0)

An append-only alert event log, SQLite-backed, owned by internal/alerts. Every lifecycle transition (pending, firing, resolved, acknowledged, escalated) and every notification decision — including suppressions, with the mechanism and reason that held them — is an event. History, the delivery log, frequency analytics, and AlertDeliveryDiagnosis become projections of one log instead of separately maintained structures. Transparency stops being a feature and becomes a property of the architecture.

Phase 0 is strictly additive: the existing manager writes events alongside its current behavior. No lifecycle semantics change.

Incident reducer (replaces the frozen transition core, Phases 12)

A pure transition function — next(state, signal, rules, clock) → (state', events) — replaces the manager's string-keyed tracking maps. Identity is computed in exactly one place (canonical resource ID + spec ID); lifecycle is an explicit state machine; acknowledgement, snooze, and flapping are typed fields on one incident record. Because the reducer is pure, the lifecycle contract suite (config save ⇒ zero events; suppressed firing ⇒ no resolve; N grouped ⇒ N rendered) runs exhaustively and gates releases.

The March doc's "stable behaviors that must not change" list is the seed of the reducer's characterization suite: ack preservation, cooldown re-notify, escalation carry-over, and confirmation semantics are pinned by parity tests against the live manager before any cutover, not rewritten from memory.

Phases

  • Phase 0 — event log, additive. Event store + manager wiring for transition and suppression events + API + UI surfacing of the existing delivery diagnosis and held-notification rows. Zero lifecycle risk. Registered as coverage gap alert-engine-suppression-observability.
  • Phase 1 — reducer in shadow mode. Built beside the manager, fed the same inputs, outputs diffed continuously (the unified_eval_parity_test.go pattern). No user-visible change.
  • Phase 2 — family-by-family cutover. Worst identity offenders first (Docker, PBS/storage). Each family cutover deletes its tracking maps and the imperative bulk of its Check* path. Alert IDs and override keys stay stable until an explicit config migration. Status: complete (2026-08-27). All four per-observation transition families run on the reducer core as the authoritative state: match-spec lifecycle (evaluateCanonicalLifecycleAlert), legacy checkMetric, canonical metric threshold, and the stateful family. The legacy tracking maps (offlineConfirmations, offlineRecoveryConfirmations, nodeOfflineCount, connectionDegradedCount, dockerOfflineCount, dockerStateConfirm, pendingAlerts) are deleted; their cleanup-loop hygiene moved into the core (PruneStalePending, pending reaping on container removal, healthy observations from the online handlers). Deliberately scoped out: the unified-incidents reconciler (unifiedIncident* in the manager). It is a batch reconciliation family, not a per-observation confirmation run — its provider hands it complete incident sets and its firstSeen bookkeeping is already identity-correct — so forcing it through the per-signal reducer would add translation without retiring a defect class. It stays a follow-on: if the event log becomes the sole history authority (Phase 3+), it should emit reducer-shaped events at that boundary instead.
  • Phase 3 — declarative rule model. Scope selector + condition + policy replaces the per-platform config blocks and DisableAll* booleans, with a translator from the existing AlertConfig so persisted user configs keep working. UI migrates tab by tab. Status: engine side complete (2026-08-27). The effective alert policy for a resource is answered by one ordered fold (internal/alerts/alert_policy.go): type default block → the type's DisableAll switches → custom rules (guest-scoped, priority order) → the per-resource override through the identity-aware lookup for the kind. The persisted AlertConfig is the translator's input and keeps its shape; the engine no longer reads it piecemeal — every scattered DisableAll* read and threshold lookup routes through the fold, pinned by characterization tests against the legacy resolution before any call site moved. UI migration: deliberately deferred, demand-gated. The per-platform thresholds presentation is what users ask in — "VM CPU threshold", not "scope selector" — and the demand ledger holds no signal for a rules-first editing surface (the nearest entry warns against bolting schedules onto alert rules). The tabs now sit on a single resolution surface, so a rules-first UI, an effective-policy inspector ("why is this alert off?"), or both can be built without further engine work when a signal lands. Re-scope through repos/pulse-pro/FEATURE_REQUESTS.md at that point.

What is kept

The delivery pipeline (internal/notifications queue, DLQ, receipts, templates, delivery health), the operator-intent fabric, unified resources, the specs/evaluator layer, the UI shell, and the monitor_alerts.go fan-out boundary — which becomes an event consumer.

What is retired (by the end of Phase 23)

The manager's per-family tracking maps, the imperative per-platform check bodies, the scattered suppression checks, and — once the event log is authoritative — the JSON snapshot history file.

Status (2026-08-27): the tracking maps are deleted; the check bodies' imperative transition bulk is replaced by the reducer core (what remains of them is evidence assembly for the spec evaluator); suppression decisions emit typed, reasoned events through the Phase 0 log; policy reads go through the Phase 3 fold.

Post-plan hardening (2026-08-27, same-day follow-through):

  • The lifecycle contract suite is a release gate. Engine contracts (config-save silence, resolve-exactly-once, ack halts escalation, acknowledged recovery suppressed) in internal/alerts/lifecycle_contract_test.go; grouped-rendering contracts (N grouped ⇒ N rendered on email, apprise, and every webhook service template; cancel-before-delivery holds the recovery) in internal/notifications/grouped_rendering_contract_test.go.
  • The event log is the alert history authority. Lifecycle events carry full alert snapshots; history is projected from the log (history_projection.go), the legacy JSON entries migrate in once and the files retire to *.imported, clears are append-only tombstones, and every alert family — reconciler and system alerts included — records its fired event. The JSON snapshot history file is retired. Parity with the JSON model is pinned by history_projection_parity_test.go.
  • The identity/config migration is complete. Alert configuration now carries an additive identity schema version and the monitor runs a non-mutating, fail-closed migration plan against the live resource registry before persisting it. Proven guest, guest-disk, storage, Docker, and provider-declared succession keys move to their single write identity; ambiguous, conflicting, unknown, and temporarily absent rows are retained. Active-alert snapshots (including acknowledgement fields) are rewritten with canonical state IDs after restore. The planner is idempotent across rollback and re-upgrade, rejects future schema versions, and the frontend round-trips the marker on ordinary configuration saves.