ADR 0009 — Risk-weighted release verdict (epss-weighted mode)¶
- Status: accepted
- Date: 2026-05-19
- Deciders: Lucas Henrique Grifoni
Context¶
The default presence-based verdict (scoring.engine.build_summary)
blocks releases when required evidence is missing. That captures
SDLC hygiene but is blind to whether the actually-present CVEs are
exploitable. After Tier 5 enriched the bundle with EPSS + KEV signal
(via --enrich), we had the data needed to re-derive the verdict by
real-world exploitability — but the data was not yet feeding the
release decision.
The classical "checklist theater" critique applies here: a bundle
with 100 % evidence coverage and three KEV-listed ransomware CVEs is
still ready under the default mode.
Decision¶
Add an opt-in risk-weighted verdict mode invoked via
sdlc-evidence run --risk-mode epss-weighted. When active, the
verdict is re-derived in scoring.risk.apply_risk_mode using the
EPSS / KEV signal:
| Condition | Effect on release_status |
|---|---|
Any CVE in CISA KEV with known_ransomware |
Forced to not_ready (overrides base) |
| Any CVE in KEV (without ransomware) OR any CVE with EPSS percentile ≥ threshold | Downgraded to at least conditional |
| No exploitable CVE | Base verdict preserved |
The threshold defaults to 0.70 (70th EPSS percentile) and is
configurable via --epss-percentile-threshold. The bundle carries
the rationale in a new optional Summary.risk_assessment block so
auditors can reconstruct the decision.
Reachability is honoured: a CVE on evidence flagged
reachability.status == "not_reachable" is removed from the
exploitable count (see ADR-0010). unknown and reachable keep
the signal.
Consequences¶
- Default off, byte-stable. When
--risk-mode off(default), the bundle is structurally identical to pre-T6.6 outputs once the structural-hash normaliser strips the nullrisk_assessmentfield (seeapplication/integrity.py). - Enriched bundles are not byte-stable across EPSS/KEV feed
refreshes. Documented in
docs/limitations.md. The structural hash deliberately capturesepss_feed_date,epss_model_version, andkev_feed_dateso feed (or EPSS model-version) updates surface as drift. - Only downgrades, never upgrades. A
not_readybase verdict is not promoted toreadyeven when no CVE is exploitable. Missing evidence is its own gap — risk weighting cannot wave hand it away. - Composable with
compare.sdlc-evidence comparecan be used to diff two enriched bundles and reason about whether the verdict drifted because of new CVEs or because the EPSS feed moved. Since 2026-08-07comparestates this explicitly: when the two bundles carry differentepss_model_versionvalues it reports EPSS model drift, in the table output and under theepsskey of the JSON contract.
The default threshold was calibrated under EPSS v4¶
EPSS v5 went to production on 2026-06-15 (feed header
#model_version:v2026.06.15). It is a re-fit of the model, not a
re-scoring under the old one, so scores and percentiles are not
comparable across model versions: a CVE can move without anything
about the CVE, the dependency, or the release having changed.
Two consequences for anyone operating this mode:
- The
0.70default for--epss-percentile-thresholdwas chosen against v4 output. It is not automatically wrong under v5, but it is not evidence-backed under v5 either — re-check it against your own finding population before treating it as tuned. - A release-over-release comparison that straddles the v4→v5 boundary
cannot attribute score movement to posture. Re-enrich both bundles
under a single model before drawing a conclusion;
comparenow warns when you have not.
Alternatives considered¶
- Always-on risk weighting. Rejected — breaks backward compatibility and forces every consumer to keep EPSS / KEV feeds fresh even when they only need the hygiene signal.
- Per-control thresholds in the catalog. Tempting but premature — surfaces complexity at the catalog level before we have evidence operators want it. A future ADR can promote thresholds into the catalog if needed.
- Score-based downgrade (coverage × confidence × exploitability). Rejected — composite scores hide the signal. The verdict is binary per CVE (exploitable or not); aggregating to a number adds no information for the auditor.
Verification¶
python -m pytest tests/unit/test_risk_mode.py exercises the
precedence (KEV ransomware → not_ready, exploitable → conditional,
clean → preserved), the reachability suppression path, threshold
override behaviour, and the "off mode returns input unchanged"
invariant.