[Bug]: Gap, waived, and decommissioned controls retain stale residual-risk credit
- Dominant language
- TypeScript
- Stars
- 155
- Forks
- 37
- Avg merge
- 1d 17h
- Merged PRs (30d)
- 82
Description
## Summary
A countermeasure changed to **Gap**, **Waived**, or **Decommissioned** can retain its previously entered effectiveness and continue reducing residual risk. The displayed lifecycle state says the control is unavailable/not being relied on, while scoring still grants it mitigation credit.
## Steps to reproduce
1. Create a risk with inherent score 100 and link a threat.
2. Add a countermeasure to that threat and set effectiveness to 90%.
3. Change its status to Gap, Waived, or Decommissioned.
4. Recalculate the risk.
## Actual behavior
The explicit 0.9 effectiveness wins over the status fallback and the residual score becomes 10 for all three statuses.
## Expected behavior
Statuses that represent an absent, waived, or retired control should not retain mitigation credit from a stale explicit value. A lifecycle change should clear/ignore effectiveness according to the agreed scoring policy and recalculate linked risks.
## Reproduction against current `main`
Reproduced against [`4f429a8`](https://github.com/precogly/precogly/commit/4f429a8d4e7c9b0192cf5b6cd0826db702bbebf4) using real migrated PostgreSQL models. A focused regression ran the same one-threat/one-control risk for all three statuses:
```text
gap: expected 100, received 10
waived: expected 100, received 10
decommissioned: expected 100, received 10
```
The fallback table correctly assigns zero to these statuses, but `compute_residual_score()` always chooses a non-null explicit effectiveness first:
https://github.com/precogly/precogly/blob/4f429a8d4e7c9b0192cf5b6cd0826db702bbebf4/backend/apps/threats/services.py#L11-L20
https://github.com/precogly/precogly/blob/4f429a8d4e7c9b0192cf5b6cd0826db702bbebf4/backend/apps/threats/services.py#L93-L131
## Impact
Residual scores can materially understate exposure while the UI and reports declare a control to be a gap, waived, or retired. This creates contradictory and potentially unsafe risk evidence.
## Existing issue check
- #495 covers credit leaking across different linked threats.
- Closed #388 covered stale frontend cache after backend recalculation.
- Closed #207 covered implemented/in-progress status and fallback disagreement.
This occurs with one threat and one countermeasure and is distinct from all three.
Contributor guide
Research direction
Start in backend/apps/threats/services.py, reading the fallback table and compute_residual_score() around the referenced lines. Run the focused regression with one threat and one countermeasure across Gap, Waived, and Decommissioned statuses. Done means each status produces an inherent residual score of 100 rather than retaining stale effectiveness credit, with linked risks recalculated.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- postgresql, python
- Domain
- backend, security
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 76/100