|
| 1 | +# ADR 022: Confidence Decay for Signal Groups |
| 2 | + |
| 3 | +**Status:** Accepted |
| 4 | +**Date:** 2026-05-11 |
| 5 | +**Extends:** [ADR 018 — Multi-Signal Correlation Engine](018-multi-signal-correlation-engine.md), [ADR 021 — Corroborating Signals](021-corroborating-signals.md) |
| 6 | + |
| 7 | +## Context |
| 8 | + |
| 9 | +The correlation engine (ADR 018) computes a signal group's |
| 10 | +`derived_confidence` as the source-weighted average of per-event |
| 11 | +confidence values: |
| 12 | + |
| 13 | +``` |
| 14 | +derived = Σ(confidence_i · weight_i) / Σ(weight_i) |
| 15 | +``` |
| 16 | + |
| 17 | +Once a group's `derived_confidence` clears the configured |
| 18 | +`confidence_threshold` and `min_sources` is satisfied, |
| 19 | +`corroboration_met` is flipped to `true` and the group is allowed to |
| 20 | +trigger mitigations (or, in the ADR 021 corroborating-only flow, |
| 21 | +strengthen open groups). |
| 22 | + |
| 23 | +This works well during an active attack — fresh telemetry keeps arriving |
| 24 | +and the weighted average reflects current reality. It is less honest |
| 25 | +once an incident winds down: |
| 26 | + |
| 27 | +1. **Long correlation windows hold stale evidence.** Operators routinely |
| 28 | + configure `window_seconds: 3600` to absorb burst-and-recover patterns. |
| 29 | + A high-confidence event ingested 50 minutes ago still contributes to |
| 30 | + the average at full weight, even though everything since has been |
| 31 | + benign. |
| 32 | +2. **Corroborating sources from ADR 021 amplify the problem.** A |
| 33 | + `mode: corroborating` source that fired hours ago continues to inflate |
| 34 | + `derived_confidence` long after its operational signal has gone |
| 35 | + silent. |
| 36 | +3. **Operators cannot express "fresh evidence matters more"** without |
| 37 | + abandoning windowed correlation entirely. |
| 38 | + |
| 39 | +The result: groups whose underlying attack has already abated continue |
| 40 | +to read as "highly corroborated" for the remainder of the window. Any |
| 41 | +ADR-021 corroborator that fires in that window — even on totally |
| 42 | +unrelated telemetry — sees a green light from the cached confidence and |
| 43 | +nudges the group toward (re-)mitigation. |
| 44 | + |
| 45 | +A naive fix ("drop events older than X seconds from the average") loses |
| 46 | +useful history and produces step-function discontinuities in the score. |
| 47 | + |
| 48 | +## Decision |
| 49 | + |
| 50 | +Introduce **exponential confidence decay** on the |
| 51 | +weighted-average computation. Each event's contribution is multiplied by |
| 52 | +`0.5 ^ (age_seconds / half_life_seconds)` before being summed, so older |
| 53 | +events smoothly lose weight without ever being discarded outright: |
| 54 | + |
| 55 | +``` |
| 56 | +weight_eff_i = weight_i · 0.5 ^ (age_i / H) |
| 57 | +derived = Σ(confidence_i · weight_eff_i) / Σ(weight_eff_i) |
| 58 | +``` |
| 59 | + |
| 60 | +Where: |
| 61 | + |
| 62 | +- `age_i = now - ingested_at_i` (clamped to ≥ 0) |
| 63 | +- `H = effective_decay_half_life_seconds` (resolved per-playbook, see below) |
| 64 | +- `H = 0` disables decay (default; preserves ADR 018 behavior) |
| 65 | + |
| 66 | +### Configuration |
| 67 | + |
| 68 | +A new global field on `CorrelationConfig`: |
| 69 | + |
| 70 | +```yaml |
| 71 | +correlation: |
| 72 | + enabled: true |
| 73 | + window_seconds: 3600 |
| 74 | + min_sources: 2 |
| 75 | + confidence_threshold: 0.7 |
| 76 | + confidence_decay_half_life_seconds: 300 # 5-minute half-life |
| 77 | +``` |
| 78 | +
|
| 79 | +Per-playbook override on `PlaybookCorrelationOverride`: |
| 80 | + |
| 81 | +```yaml |
| 82 | +playbooks: |
| 83 | + - vector: udp_flood |
| 84 | + correlation_override: |
| 85 | + confidence_decay_half_life_seconds: 60 # faster decay for noisy vector |
| 86 | + - vector: dns_amplification |
| 87 | + correlation_override: |
| 88 | + confidence_decay_half_life_seconds: 0 # explicitly disable for this playbook |
| 89 | +``` |
| 90 | + |
| 91 | +Override resolution (`effective_decay_half_life()`): |
| 92 | + |
| 93 | +- `Some(0)` ⇒ decay explicitly disabled for this playbook |
| 94 | +- `Some(n)` ⇒ use `n` |
| 95 | +- `None` ⇒ fall through to global `confidence_decay_half_life_seconds` |
| 96 | + |
| 97 | +Validation: `0 ≤ H ≤ 10 × window_seconds`. The upper bound prevents |
| 98 | +configuration mistakes where a half-life longer than the correlation |
| 99 | +window would render decay effectively a no-op. |
| 100 | + |
| 101 | +### Compute Paths |
| 102 | + |
| 103 | +Two recompute paths use the decayed variant: |
| 104 | + |
| 105 | +1. **`POST /v1/events` ingestion.** Every event that lands in an open |
| 106 | + group recomputes `derived_confidence` with decay applied. |
| 107 | +2. **Reconcile loop (every tick, 30 s).** A new |
| 108 | + `refresh_decayed_confidence` step iterates every open signal group |
| 109 | + (`list_open_signal_groups`) and recomputes `derived_confidence` from |
| 110 | + the current event set. This is what actually delivers the decay to |
| 111 | + groups that aren't receiving fresh events. |
| 112 | + |
| 113 | +The reconcile step is a no-op when `confidence_decay_half_life_seconds` |
| 114 | +is 0 (so users not opting in pay no extra DB cost). |
| 115 | + |
| 116 | +### One-Shot Corroboration (Sticky `corroboration_met`) |
| 117 | + |
| 118 | +When `derived_confidence` falls below `confidence_threshold` due to |
| 119 | +decay, `corroboration_met` **must not** flap back to `false`. The flag |
| 120 | +is sticky once set: |
| 121 | + |
| 122 | +```rust |
| 123 | +corroboration_met = met_now || was_met |
| 124 | +``` |
| 125 | + |
| 126 | +This preserves the operational invariant that "once mitigation was |
| 127 | +authorized for this group, it stays authorized for the lifetime of the |
| 128 | +group" — decay only shapes future authorizations on *other* groups, |
| 129 | +never revokes one already granted. |
| 130 | + |
| 131 | +### Observability |
| 132 | + |
| 133 | +- **Metric:** `prefixd_signal_group_decay_refreshes_total` counter, |
| 134 | + ticks once per `refresh_decayed_confidence` invocation (whether or not |
| 135 | + any groups were refreshed). Lets operators alert on "decay loop not |
| 136 | + running". |
| 137 | +- **UI:** The group detail page surfaces "decayed, half-life Ns" next to |
| 138 | + the `derived_confidence` value when decay is active for the group's |
| 139 | + effective playbook, so operators can interpret the score correctly. |
| 140 | + |
| 141 | +## Consequences |
| 142 | + |
| 143 | +### Positive |
| 144 | + |
| 145 | +- Stale corroboration evidence loses weight smoothly without |
| 146 | + discontinuities. |
| 147 | +- ADR-021 corroborating sources from earlier in the window no longer |
| 148 | + hold groups at artificially high confidence. |
| 149 | +- Per-playbook tuning lets operators dial decay speed per vector (e.g. |
| 150 | + faster decay for noisy UDP floods, slower for slow-and-low credential |
| 151 | + stuffing). |
| 152 | +- Sticky `corroboration_met` prevents flap-back of authorized |
| 153 | + mitigations even under aggressive decay configs. |
| 154 | +- Defaults to disabled (`H = 0`) — zero behavior change for existing |
| 155 | + deployments. |
| 156 | + |
| 157 | +### Negative |
| 158 | + |
| 159 | +- Reconcile loop now does O(open_groups · events_per_group) DB reads |
| 160 | + per tick when decay is enabled. For typical deployments (< 100 open |
| 161 | + groups, < 10 events each) this is negligible, but pathological |
| 162 | + configurations would notice. |
| 163 | +- `derived_confidence` is no longer a pure function of "events on the |
| 164 | + group" — it now also depends on wall-clock time. This complicates |
| 165 | + reproducing a group's score offline; the trade-off is acceptable |
| 166 | + given the operational gain. |
| 167 | +- Decay does not change `source_count` (still a raw distinct-source |
| 168 | + count). Operators relying on `source_count` for thresholding will not |
| 169 | + see decay affect their gate; only `confidence_threshold` benefits. |
| 170 | + |
| 171 | +### Single-Event Math Note |
| 172 | + |
| 173 | +For a group with exactly one event, `derived_confidence` is unaffected |
| 174 | +by decay (the decay factor cancels in `Σ(c·w_eff) / Σ(w_eff)`). Decay |
| 175 | +only meaningfully shifts the score when a group has events at different |
| 176 | +ages. This is mathematically correct and matches operator intuition: |
| 177 | +"one piece of evidence is one piece of evidence, regardless of how old". |
| 178 | + |
| 179 | +## Alternatives Considered |
| 180 | + |
| 181 | +1. **Hard cutoff (drop events older than X).** Rejected: step-function |
| 182 | + discontinuities in score, and loses useful history for slow-attack |
| 183 | + detection. |
| 184 | +2. **Linear decay.** Considered. Rejected in favor of exponential |
| 185 | + because half-life is the unit operators reason about intuitively |
| 186 | + ("after 5 minutes, evidence is worth half what it was") and matches |
| 187 | + industry convention for time-decayed metrics. |
| 188 | +3. **Per-source decay rates.** Considered. Deferred: introduces another |
| 189 | + tuning knob whose value isn't obvious to operators and overlaps with |
| 190 | + per-source `weight`. Global + per-playbook covers the immediate |
| 191 | + need. |
| 192 | +4. **Decay only on corroborating signals.** Considered. Rejected |
| 193 | + because primary detectors also produce stale evidence (a firing |
| 194 | + Prometheus alert that has been resolved for 40 minutes shouldn't |
| 195 | + keep contributing at full weight either). |
| 196 | + |
| 197 | +## Migration |
| 198 | + |
| 199 | +No migration required. Default `confidence_decay_half_life_seconds: 0` |
| 200 | +preserves ADR 018 behavior bit-for-bit. Operators opt in by setting a |
| 201 | +non-zero value in `correlation.yaml`. |
0 commit comments