Skip to content

Commit a51165a

Browse files
committed
feat(correlation): confidence decay over time (ADR 022)
Exponential time decay applied to per-event confidence contributions when computing derived_confidence. An event's effective weight is multiplied by 0.5^(age_seconds / half_life_seconds), so older corroborating evidence smoothly loses influence without ever being discarded. - Global config: correlation.confidence_decay_half_life_seconds: u32 (default 0, disabled). Validated 0 <= H <= 10 * window_seconds. - Per-playbook override: confidence_decay_half_life_seconds: Option<u32>. Some(0) explicitly disables for the playbook; None falls through. - Reconcile loop step refresh_decayed_confidence iterates open groups every tick and recomputes derived_confidence with decay applied. - Sticky corroboration_met: once true, decay never flips it back to false (one-shot authorization). - Metric prefixd_signal_group_decay_refreshes_total counter. - UI label "decayed, half-life Ns" on group detail page when active. Default disabled, zero behavior change for existing deployments. Tests: 17 new (7 engine + 7 config + 3 integration) covering decay math, override resolution, validation, disabled paths, and sticky corroboration semantics.
1 parent 8dd6785 commit a51165a

21 files changed

Lines changed: 1009 additions & 35 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ tests/
122122
10. **Route-group auth guard** - Next.js `(dashboard)/layout.tsx` wraps all protected pages
123123
11. **Mode-aware auth** - `none`/`bearer`/`credentials`/`mtls` with role checks on protected endpoints
124124

125-
See `docs/adr/` for all 19 Architecture Decision Records.
125+
See `docs/adr/` for all 22 Architecture Decision Records.
126126

127127
## API Endpoints
128128

@@ -258,8 +258,8 @@ Completed:
258258
- Nginx reverse proxy (single-origin deployment)
259259
- ErrorBoundary wrapping all dashboard pages
260260
- Cross-entity navigation (command palette → detail pages, event↔mitigation linking, audit log → mitigations, clickable stat cards)
261-
- Multi-signal correlation engine with signal groups, Alertmanager/FastNetMon adapters, a generic JSONPath-driven webhook adapter (ADR 020), and corroborating-only signals from coarse telemetry (ADR 021)
262-
- 21 Architecture Decision Records
261+
- Multi-signal correlation engine with signal groups, Alertmanager/FastNetMon adapters, a generic JSONPath-driven webhook adapter (ADR 020), corroborating-only signals from coarse telemetry (ADR 021), and exponential confidence decay over time (ADR 022)
262+
- 22 Architecture Decision Records
263263
- CLI tool (prefixdctl) for all API operations
264264
- OpenAPI spec with utoipa annotations
265265
- 179 backend unit tests + 99 integration + 16 postgres tests (+ 17 ignored requiring GoBGP/Docker)

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.18.0] - 2026-05-11
11+
12+
### Added
13+
14+
- **Confidence decay for signal groups (ADR 022).** Exponential time decay applied to per-event confidence contributions when computing `derived_confidence`. Each event's effective weight is multiplied by `0.5 ^ (age_seconds / half_life_seconds)`, so older corroborating evidence smoothly loses influence without ever being discarded. Default is disabled (`confidence_decay_half_life_seconds: 0`); zero behavior change for existing deployments.
15+
- **Global config:** `correlation.confidence_decay_half_life_seconds: u32` (default 0). Validated to `0 ≤ H ≤ 10 × window_seconds`.
16+
- **Per-playbook override:** `correlation_override.confidence_decay_half_life_seconds: Option<u32>`. `Some(0)` explicitly disables decay for the playbook; `None` falls through to global.
17+
- **Reconcile loop step.** New `refresh_decayed_confidence` step iterates every open signal group each tick (default 30 s) and recomputes `derived_confidence` from current events with decay applied. No-op when decay is disabled.
18+
- **One-shot `corroboration_met`.** When `derived_confidence` falls below the threshold due to decay, `corroboration_met` is now sticky (`met_now || was_met`) — once a group has authorized mitigation, decay never revokes that authorization for the group's lifetime.
19+
- **Metric:** `prefixd_signal_group_decay_refreshes_total` counter ticks once per `refresh_decayed_confidence` invocation. Alert on "decay loop not running" by watching for the counter going flat.
20+
- **UI:** Group detail page surfaces "decayed, half-life Ns" next to `derived_confidence` when decay is active for the group's effective playbook.
21+
- 17 new tests (7 engine unit + 7 config unit + 3 integration) covering decay math, override resolution, validation, disabled paths, and one-shot stickiness.
22+
1023
## [0.17.1] - 2026-05-11
1124

1225
### Changed

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ Example: FastNetMon says UDP flood at 0.6 confidence + router CPU spiking + host
301301
### Confidence Model
302302

303303
- [x] Derived confidence from traffic patterns
304-
- [ ] Confidence decay over time
304+
- [x] Confidence decay over time
305305
- [x] Per-playbook thresholds
306306

307307
---

docs/adr/022-confidence-decay.md

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
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`.

docs/adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,6 @@ Format follows [Michael Nygard's template](https://cognitect.com/blog/2011/11/15
2929
| [019](019-signal-adapter-architecture.md) | Signal Adapter Architecture | Accepted | 2026-03-19 |
3030
| [020](020-generic-webhook-adapter.md) | Generic Webhook Adapter | Accepted | 2026-04-18 |
3131
| [021](021-corroborating-signals.md) | Corroborating Signals | Accepted | 2026-04-19 |
32+
| [022](022-confidence-decay.md) | Confidence Decay for Signal Groups | Accepted | 2026-05-11 |
3233

3334
ADRs are numbered sequentially as written. Retroactive ADRs (009-013) were documented on 2026-02-18 but dated to when the decision was originally made.

docs/api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -546,7 +546,7 @@ GET /v1/signal-groups/{id}
546546
Authorization: Bearer <token>
547547
```
548548

549-
Returns group metadata and all contributing events with source, confidence, and source weight.
549+
Returns group metadata and all contributing events with source, confidence, and source weight. When `correlation.confidence_decay_half_life_seconds` (or the playbook override) is non-zero, `derived_confidence` is computed with exponential time decay applied — older events contribute proportionally less weight. See [ADR 022](adr/022-confidence-decay.md). The `corroboration_met` flag is sticky: once set to `true` it never reverts to `false`, even if decay drives `derived_confidence` back under the threshold.
550550

551551
**Response:**
552552

docs/configuration.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,13 @@ correlation:
284284
# Global minimum derived confidence threshold (0.0-1.0).
285285
# A signal group must reach this threshold (in addition to min_sources) before triggering.
286286
confidence_threshold: 0.5
287+
288+
# Exponential half-life applied to per-event confidence contributions when
289+
# computing derived_confidence. 0 disables decay (default). When set, an
290+
# event's effective weight is multiplied by 0.5^(age_seconds / H), so
291+
# older corroborating evidence smoothly loses influence. See ADR 022.
292+
# Must satisfy 0 <= H <= 10 * window_seconds.
293+
confidence_decay_half_life_seconds: 0
287294
288295
# Default weight for sources not listed below
289296
default_weight: 1.0
@@ -307,6 +314,7 @@ correlation:
307314
| `window_seconds` | integer | `300` | Time window for grouping signals (seconds) |
308315
| `min_sources` | integer | `1` | Minimum distinct sources to trigger mitigation |
309316
| `confidence_threshold` | float | `0.5` | Minimum derived confidence to trigger |
317+
| `confidence_decay_half_life_seconds` | integer | `0` | Exponential half-life (seconds) for time-decaying per-event confidence contributions; 0 disables decay. Bounded by `10 × window_seconds`. See [ADR 022](adr/022-confidence-decay.md). |
310318
| `default_weight` | float | `1.0` | Weight for unknown/unconfigured sources |
311319
| `sources` | map | `{}` | Per-source weight and type configuration |
312320

@@ -367,6 +375,7 @@ playbooks:
367375
correlation:
368376
min_sources: 2 # Require corroboration for UDP floods
369377
confidence_threshold: 0.7
378+
confidence_decay_half_life_seconds: 60 # Faster decay for noisy vector
370379
steps:
371380
- action: police
372381
rate_bps: 5000000
@@ -379,6 +388,7 @@ When a playbook has no `correlation` override, the global defaults from `prefixd
379388
|----------------|------|-------------|
380389
| `min_sources` | integer | Override global min_sources for this playbook |
381390
| `confidence_threshold` | float | Override global confidence_threshold for this playbook |
391+
| `confidence_decay_half_life_seconds` | integer (optional) | Override global half-life. `0` explicitly disables decay for this playbook even when the global is non-zero; omit to inherit the global value. |
382392

383393
#### Hot Reload
384394

frontend/app/(dashboard)/correlation/groups/[id]/page.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,14 @@ export default function SignalGroupDetailPage({
449449
<div>
450450
<p className="text-xs text-muted-foreground mb-1">
451451
Derived Confidence
452+
{correlationConfig &&
453+
correlationConfig.confidence_decay_half_life_seconds &&
454+
correlationConfig.confidence_decay_half_life_seconds > 0 ? (
455+
<span className="ml-1 text-muted-foreground/70">
456+
(decayed, half-life{" "}
457+
{correlationConfig.confidence_decay_half_life_seconds}s)
458+
</span>
459+
) : null}
452460
</p>
453461
<div className="flex items-center gap-2">
454462
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">

frontend/lib/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,7 @@ export interface CorrelationConfig {
808808
min_sources: number
809809
confidence_threshold: number
810810
default_weight: number
811+
confidence_decay_half_life_seconds?: number
811812
sources: Record<string, SourceConfig>
812813
webhook_adapters?: WebhookAdapter[]
813814
}

0 commit comments

Comments
 (0)