Skip to content

R8 deferred: recentAlerts buffer can evict terminal alerts before 24h grace window (MED-HIGH) #8

Description

@SaigonAlpha

Source: R8 fresh-eyes QA round, adversarial cross-tab scope (2026-05-17)
Severity: MED-HIGH (data loss in high-flow sessions, but no panic-level user impact)
Status: Deferred from R8 — design call, not a surgical patch

Symptom

MAX_RECENT_ALERTS = 50 in app/src/store/flowStore.ts:55 caps the global alert
buffer. In high-flow sessions with 10+ active positions × 13 alert kinds, the
buffer can saturate within minutes, evicting older terminal alerts (TargetHit,
StopHit, BigCloseDetected) before the 24h grace window in
synthesizeSymbolAlerts wants them.

User-visible effect: a TargetHit from 30 min ago can disappear from the Action
Summary panel because 50 newer alerts pushed it off the end. Raw Alerts tab
also loses it (it reads from the same buffer).

Reproduction (synthetic)

  1. Open 10 positions across 5 symbols
  2. Trigger enough state changes to fire ~60 alerts in <24h (Approaching*, Target,
    Stop, BigClose, Wall, IV, Expiry, Roll — easy with realistic poll cadence)
  3. Observe: the oldest 10 alerts (including any TargetHit/StopHit) are gone
    from the buffer even though the positions are still open or in grace

Current code

// flowStore.ts:55
const MAX_RECENT_ALERTS = 50;

// flowStore.ts:697-700 (terminal alerts merged on every position update)
if (terminalAlerts.length > 0) {
  const combined = [...terminalAlerts, ...get().recentAlerts]
    .slice(0, MAX_RECENT_ALERTS);
  set({ recentAlerts: combined });
}

Newest wins. No special preservation of high-severity terminal events.

Fix options

(A) Raise the cap to ~200. Crudest fix. Lets ~1 trading day of alerts
survive at realistic firing rates. Memory cost is negligible (PositionAlert is
small). Risk: hides the underlying issue rather than addressing it.

(B) Severity-aware eviction. Preserve critical/warn over info. When
evicting, prefer dropping ApproachingX / IvDecaying (heads-up tier) before
TargetHit / StopHit / BigCloseDetected (act-now tier). Roughly:

// Sort by (severity desc, fired_at desc) before slicing
const ranked = combined.sort((a, b) => {
  const sd = SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity];
  if (sd !== 0) return sd;
  return b.fired_at - a.fired_at;
});
set({ recentAlerts: ranked.slice(0, MAX_RECENT_ALERTS) });

Caveat: this changes the array's chronological invariant. Callers (AlertsPanel
sort, Action Summary most_recent_fired_at) would need to handle a
non-chronological input or re-sort downstream.

(C) Per-position cap instead of global. Cap at e.g. 5 alerts per position,
unlimited overall. Bounds growth at 5 × |positions| which is naturally
bounded by the position lifecycle. Best fit for the actual data shape.

Recommendation

(C) is the cleanest semantically. (A) is the safest 1-line shim while (C) is
designed. Avoid (B) — the chronological invariant matters more than I initially
thought.

Cross-refs

  • R8 scorecard: H-X4
  • Related: app/src/lib/alertSynthesis.ts ALERT_GRACE_AFTER_CLOSE_MS = 24h
    assumes the buffer holds at least 24h of alerts
  • Test gap: no test currently exercises buffer saturation; add one in
    app/src/store/flowStore.test.ts before shipping any fix

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions