Pulse/docs/ALERT_ENGINE_EVOLUTION.md

164 lines
8.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Alert Engine Evolution
Status: Active
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 N−1 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 1–2)
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 2–3)
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.