ADR — Findings Runtime Score Formula

Formerly docs/architecture/ADR-FINDINGS-RUNTIME-SCORE-FORMULA.md.

FieldValue
StatusAccepted (signed off 2026-04-29 — Architecture lead via user approval)
Date2026-04-29
AuthorsFindings Ledger Runtime sub-team
Modulesrc/Findings/__Libraries/StellaOps.Findings.Ledger.Runtime/
RelatedSPRINT_20260429_013_FindingsLedger_runtime_score (formula), SPRINT_20260429_015_FindingsLedger_runtime_documentation (ADR sweep)

In one line: Stella Ops reduces a finding’s runtime evidence to one comparable 0–100 runtime score — a deterministic, explainable weighted sum of four normalized components (hits 0.40, entry-point 0.30, coverage 0.20, recency 0.10) — so operators can rank otherwise-equivalent findings by what is actually being exercised in production.

Audience: Findings Ledger engineers implementing or tuning the score, and operators interpreting it. The four component values are surfaced for explainability; the weights are config-tunable without redeploying the library.

Context

Findings Ledger persists runtime instrumentation traces (eBPF / syscall / APM probes) correlated to findings. Operators need a single comparable number — the runtime score — to answer the question “which of these otherwise-equivalent findings is actually being exercised in production?”

Wire shape (see Models/RuntimeModels.cs):

RuntimeScore {
    string TenantId;
    Guid   FindingId;
    double Score;             // 0..100
    DateTimeOffset ComputedAt;
    TimeSpan Window;
    IReadOnlyDictionary<string, double> Components;  // hits / entryPoint / coverage / recency
}

The persisted domain score is 0..100. The HTTP runtime-score endpoint adapts that value to a normalized 0..1 wire value for the existing UI contract; clients that render a gauge multiply by 100.

Constraints we are designing under:

Decision

The runtime score is a four-component weighted sum, each component normalized to [0, 1], then clamped and scaled to [0, 100]:

score = clamp01(
      W_hits     * normalizedHits(aggregates)
    + W_entry    * entryPointPresence(aggregates)
    + W_cover    * vulnerableFunctionCoverage(recentTraces)
    + W_recency  * recencyWeight(aggregates, window, now)
) * 100

Component definitions

ComponentSymbolWeight (default)RangeFormula
Hitshits0.40[0, 1]min(1, log10(1 + Σ HitCount) / log10(1 + S))
Entry-pointentryPoint0.30{0, 1}1 if any aggregate has HasEntryPoint=true else 0
Coveragecoverage0.20[0, 1]#distinct vulnerable functions / #distinct functions
Recencyrecency0.10[0, 1]1 - (now - lastSeen) / window, clamped

Where:

Boundary behavior (all asserted by unit tests)

Rationale

Why log-scale hits? Linear scaling makes a 10× more popular finding look 10× more important, which over-weights the most-trafficked path at the expense of less-trafficked but reachable code. Log scaling keeps the score sensitive to “any traffic at all” while flattening at high volume.

Why binary entry-point? Entry-point presence is qualitative — either the vulnerability is reachable from a user-visible surface or it isn’t. Counting the number of entry points would reward apps with bigger surface areas, which is the wrong signal.

Why ratio-based coverage? A vulnerability that’s the only function the trace stream sees is more concerning than one that’s one of fifty functions exercised. The ratio captures “is this the hot path or peripheral noise?”

Why linear recency decay? It maps cleanly to the operational “sliding window of N hours” mental model and is easy to explain. We considered exponential decay; linear was chosen for explainability over mathematical elegance — operators reading “this trace is 12 hours into a 24-hour window, so recency = 0.5” is more useful than reading “recency = e^(-12/τ)”.

Why these specific weights (40/30/20/10)? They reflect the prioritization the product team validated:

  1. Hits dominate because actual exercise of the code is the strongest signal we have. 0.40 is the largest weight.
  2. Entry-point next because reachability from user-facing surface converts a theoretical risk to a real one.
  3. Coverage is a secondary signal: even a hit-heavy / reachable vulnerability that’s drowning in unrelated traffic is lower priority than one that is the trace stream.
  4. Recency is the smallest component because once a finding has been exercised at all, its severity does not really decay — we just slightly down-weight stale evidence to favor still-active code paths.

Weights are configurable via RuntimeScoreCalculatorOptions so that post-launch tuning does not require a re-deploy of the runtime library.

Alternatives considered

Consequences

Test coverage

22 unit tests in src/Findings/__Tests/StellaOps.Findings.Ledger.Runtime.Tests/Scoring/RuntimeScoreCalculatorTests.cs, covering:

References

Architecture-lead sign-off

Sign-off Window

Accepted on 2026-04-29 by Architecture lead. The 14-day default-accept window from sprint 024 (deadline 2026-05-13) closed early via explicit approval rather than the timeout path. Original window text retained below for history.

Following the project’s RFC default-accept process: if this ADR remains Proposed with no objection logged in this document by 2026-05-13 (14 days from sprint 024 commit), it is automatically promoted to Accepted. Architecture-lead may flip status earlier on review. Objections should be added under “Open Issues” below with name + date.

Open Issues

No objections logged during the sign-off window.