Risk Scoring Contract

Contract ID: CONTRACT-RISK-SCORING-002 Version: 1.2 Status: Active. The Findings evidence-weighted score API is served; the Policy Engine risk-scoring job path was withdrawn 2026-10-03, and the Risk Engine HTTP surface was deleted 2026-08-27 Last Updated: 2026-10-03

Where the Risk Engine surface went. /risk-scores/* and /exploit-maturity/* are not served. Owner ruling Q-2 (2026-08-25) retired them, and SPRINT_20260722_010 FND-10 deleted StellaOps.RiskEngine.WebService, StellaOps.RiskEngine.Worker and StellaOps.RiskEngine.Application, with their compose keys and images, on 2026-08-27. No src/ file outside src/__Obsoleted/ maps either prefix, so a request to either returns 404. That is not a defect. The capability folds into the Findings family: see Retired with the predecessors in the Findings dossier. A read surface for the kept provider scores will be designed on /api/findings/v1/* when a real consumer exists. The scopes risk-engine:read and risk-engine:operate were withdrawn on 2026-10-02: they are no longer declared in StellaOpsScopes, and Authority removes their Console catalog rows on boot (RetiredScopes). A future read surface declares its own scopes.

Overview

This contract defines the risk-scoring types and HTTP surfaces that Stella Ops serves, so that policy gates and operators can act on the findings that matter first. It covers the Findings evidence-weighted score API, risk profiles and the Policy Engine’s weighted profile score, and the scoring providers kept in the StellaOps.RiskEngine.Core library. It also records the Policy Engine risk-scoring job path withdrawn on 2026-10-03.

Audience: engineers integrating with or extending risk scoring in the Findings service and the Policy Engine.

Surface map

Every line reference below is relative to the file named in the same row or paragraph. Paths are under src/ unless stated.

SubsystemWhat it servesSource
Findings (findings-web)The Evidence-Weighted Score (EWS) API under /api/findings/v1/ledger/… and /api/findings/v1/scoring/*Findings/__Libraries/StellaOps.Findings.Ledger.Application/Endpoints/ScoringEndpoints.cs:31-111 and WebhookEndpoints.cs:22-77, mapped by Findings/StellaOps.Findings.WebService/Program.cs:427 and :434
Policy Engine (policy-engine)No risk-scoring job path: the job store, the trigger service and the job and result types were withdrawn on 2026-10-03 (see Withdrawn Policy Engine job path). The /api/risk/* routes serve risk profiles, profile schema validation, scope attachments, simulation, overrides, profile export and import, and profile events.Policy/StellaOps.Policy.Engine/Program.cs:936-948 (/api/risk/* mappers)
StellaOps.RiskEngine.Core libraryNo HTTP surface and no deployable caller. Providers and contracts only (see Providers).Findings/StellaOps.RiskEngine.Core/

There is no POST /api/v1/risk/jobs route and no route or background service that submits, reads, lists or runs risk-scoring jobs.

Implementation References

Findings evidence-weighted score API

Source: src/Findings/__Libraries/StellaOps.Findings.Ledger.Application/Endpoints/ScoringEndpoints.cs and WebhookEndpoints.cs in the same directory. findings-web maps both (src/Findings/StellaOps.Findings.WebService/Program.cs:427, :434).

Tenancy and authorization. Every route group requires a tenant (RequireTenant(), ScoringEndpoints.cs:33, :37; WebhookEndpoints.cs:24). Each route names one of the policies scoring.read, scoring.write or scoring.admin (ScoringEndpoints.cs:26-27, WebhookEndpoints.cs:18). The host registers all three with the same requirement: an authenticated Authority token carrying a scope from findings:ledger:Authority:RequiredScopes, default vuln:operate (Program.cs:301-305, :317-328; StellaOpsScopes.cs:354).

Response shape. The routes return the EWS shape (EvidenceWeightedScoreResponse, integer score 0–100). When the scoring state is not available, the score routes return 501 with title scoring_state_unavailable (ScoringEndpoints.cs:138-144, :172-178, :213-219, :253-259).

MethodRoutePolicyDescriptionSource
POST/api/findings/v1/ledger/{findingId}/scorescoring.writeCalculate and persist the evidence-weighted score; returns EvidenceWeightedScoreResponse. 404 if the finding or its evidence is missing.ScoringEndpoints.cs:41-50
GET/api/findings/v1/ledger/{findingId}/scorescoring.readReturn the cached score without recalculation; 404 if none was computed.:54-60
POST/api/findings/v1/ledger/scoresscoring.writeBatch calculate for 1 to 100 finding IDs; returns CalculateScoresBatchResponse. 400 if the list is empty or larger than 100.:64-72, :23, :188-206
GET/api/findings/v1/ledger/{findingId}/score-historyscoring.readPaginated history; query from, to, limit (default 50, clamped 1–100) and cursor.:76-82, :236-241
GET/api/findings/v1/scoring/policyscoring.readActive scoring policy (ScoringPolicyResponse).:86-91
GET/api/findings/v1/scoring/policy/{version}scoring.readOne policy version; 404 if unknown.:95-101
GET/api/findings/v1/scoring/policy/versionsscoring.readAll policy versions (PolicyVersionListResponse).:106-111
POST/api/findings/v1/scoring/webhooksscoring.adminRegister a score-change webhook; 201 with WebhookResponse.WebhookEndpoints.cs:28-35
GET/api/findings/v1/scoring/webhooksscoring.adminList registered webhooks (WebhookListResponse).:39-44
GET/api/findings/v1/scoring/webhooks/{id}scoring.adminOne webhook; 404 if unknown.:48-54
PUT/api/findings/v1/scoring/webhooks/{id}scoring.adminReplace a webhook configuration; 404 if unknown.:58-66
DELETE/api/findings/v1/scoring/webhooks/{id}scoring.adminDelete a webhook; 204, or 404 if unknown.:70-77

The committed OpenAPI document carries the same paths (../modules/findings/openapi/v1.json).

EvidenceWeightedScoreResponse(abridged; Contracts/ScoringContracts.cs:97-189):

{
 "findingId": "string",
 "score": 78,
 "bucket": "ActNow|ScheduleNext|Investigate|Watchlist",
 "inputs": { "rch": 0.9, "rts": 0.0, "bkp": 0.0, "xpl": 0.4, "src": 1.0, "mit": 0.0 },
 "weights": { "rch": 0.0, "rts": 0.0, "bkp": 0.0, "xpl": 0.0, "src": 0.0, "mit": 0.0 },
 "flags": [],
 "explanations": [],
 "caps": { "speculativeCap": false, "notAffectedCap": false, "runtimeFloor": false },
 "policyDigest": "sha256:...",
 "calculatedAt": "2026-05-05T00:00:02Z",
 "cachedUntil": null,
 "fromCache": false,
 "hardFail": false
}

Withdrawn Policy Engine job path

Until 2026-10-03 the Policy Engine carried a risk-scoring job path: RiskScoringTriggerService turned a FindingChangedEvent into a queued job for the default risk profile, the host registered PostgresRiskScoringJobStore over policy.risk_scoring_jobs as IRiskScoringJobStore, and the RiskScoringJobRequest, RiskScoringJob and RiskScoringResult records described the jobs and their per-finding results. The path had neither a producer nor a consumer: no code in src/ constructed a FindingChangedEvent or called the trigger, nothing dequeued a job, and only tests constructed a RiskScoringResult, so a running Policy Engine created no job and scored nothing.

It was withdrawn under SPRINT_20260930_003 PCD-269. The sources are frozen in src/__Obsoleted/Policy/StellaOps.Policy.Engine/PCD-269/ (not built, not shipped), the two registrations and the four policy_risk_scoring_* instruments were removed, and RiskScoringJobPathWithdrawalTests pins their absence. There is no replacement in the Policy Engine: the served risk scores are the Findings evidence-weighted score API, and the weighted profile score is computed by the risk simulation.

The table policy.risk_scoring_jobs stays, because applied migrations are forward-only. It has no writer, and policy.prune_retention_windows still applies the terminal-row window to it (src/Policy/__Libraries/StellaOps.Policy.Persistence/Migrations/012_policy_retention_windows.sql).

Risk Profile Model

RiskProfileModel

Defines how findings are scored and prioritized (RiskProfileModel.cs:8).

{
 "id": "default-profile",
 "version": "1.0.0",
 "description": "Default risk profile for vulnerability prioritization",
 "extends": "base-profile (optional)",
 "signals": [
 {
 "name": "cvss",
 "source": "nvd",
 "type": "numeric",
 "path": "/cvss/base_score",
 "transform": "normalize_10",
 "unit": "score"
 },
 {
 "name": "kev",
 "source": "cisa",
 "type": "boolean",
 "path": "/kev/in_catalog"
 },
 {
 "name": "reachability",
 "source": "scanner",
 "type": "numeric",
 "path": "/reachability/score"
 }
 ],
 "weights": {
 "cvss": 0.4,
 "kev": 0.3,
 "reachability": 0.3
 },
 "overrides": {
 "severity": [
 {
 "when": { "kev": true },
 "set": "critical"
 }
 ],
 "decisions": [
 {
 "when": { "kev": true, "reachability": { "$gt": 0.8 } },
 "action": "deny",
 "reason": "KEV with high reachability"
 }
 ]
 },
 "metadata": {}
}

Signal Types

The three types match the RiskSignalType enum (RiskProfileModel.cs:105-115).

TypeDescriptionValue Range
booleanTrue/false signaltrue / false
numericNumeric signal0.0 to 1.0 (normalized)
categoricalCategorical signalString values

Severity Levels

The five canonical levels below match the RiskSeverity enum (RiskProfileModel.cs:181-197) and the RiskLevel enum in the attestation schema (stellaops-risk-profile.v1.schema.json:52). The “Priority” column is illustrative ordering for documentation only — it is not a field emitted by code.

LevelJSON ValuePriority
Critical"critical"1 (highest)
High"high"2
Medium"medium"3
Low"low"4
Informational"informational"5 (lowest)

Decision Actions

The three actions match the RiskAction enum (RiskProfileModel.cs:203-213).

ActionDescription
allowFinding is acceptable, no action required
reviewFinding requires manual review
denyFinding is not acceptable, blocks promotion

Scoring Algorithm

Weighted profile score (Policy Engine risk simulation)

The served weighted computation over a RiskProfileModel is RiskSimulationService.ComputeFindingScore (src/Policy/StellaOps.Policy.Engine/Simulation/RiskSimulationService.cs:108), which Simulate calls for each finding (:54, :74). The /api/risk/simulation routes call Simulate (Endpoints/RiskSimulationEndpoints.cs:19-23, :119, :164). For each profile signal present on the finding, the service adds the signal value multiplied by its profile weight, with weight 0 when the profile has none (:118-128). It then normalizes to 0–100 (:142) and applies the profile’s severity overrides (:145-146):

raw_score = Σ(signal_value × weight) for the profile signals present on the finding
normalized_score = clamp(raw_score × 10, 0, 100)

The simulation returns its own FindingScore records, whose normalized_score is this 0–100 value.

Providers in the kept StellaOps.RiskEngine.Core library

The providers operate on the ScoreRequest.Signals numeric dictionary (src/Findings/StellaOps.RiskEngine.Core/Contracts/ScoreRequest.cs:6-9). No deployable registers or calls them. findings-worker’s FindingsScoringWorker is a placeholder that logs and returns (src/Findings/StellaOps.Findings.Worker/Program.cs:54, :71-88). Policy has no reference to this library. PCD-270 removed the unused project edge; PCD-342 withdrew the empty StellaOps.Policy.Predicates project and its test project on 2026-10-05.

Provider nameClassSource
default-transformsDefaultTransformsProviderProviders/DefaultTransformsProvider.cs:11
cvss-kevCvssKevProviderProviders/CvssKevProvider.cs:11
epssEpssProviderProviders/EpssProvider.cs:12
cvss-kev-epssCvssKevEpssProviderProviders/EpssProvider.cs:71
vex-gateVexGateProviderProviders/VexGateProvider.cs:11
fix-exposureFixExposureProviderProviders/FixExposureProvider.cs:12
fixchainFixChainRiskProviderProviders/FixChain/FixChainRiskProvider.cs:192

The first six round their result to 6 decimal places with banker’s rounding (MidpointRounding.ToEven; for example VexGateProvider.cs:33, CvssKevProvider.cs:47).

VEX Gate Provider

VexGateProvider (ProviderName = "vex-gate") short-circuits scoring when the HasDenial signal is set (>= 1); otherwise it returns the clamped max of the remaining signals (VexGateProvider.cs:19-33):

if (signals["HasDenial"] >= 1)
 return 0.0; // Fully mitigated

// Otherwise, max of remaining signals, clamped to [0,1]
return clamp01(signals.Where(k != "HasDenial").Max());

CVSS + KEV Provider

CvssKevProvider (ProviderName = "cvss-kev") reads inline Cvss and Kev/IsKev signals (falling back to ICvssSource/IKevSource when no inline signals are supplied) (CvssKevProvider.cs:28-47):

score = min(1.0, (clamp(cvss, 0, 10) / 10.0) + kevBonus)
where kevBonus = kev ? 0.2 : 0.0

Kept contracts

ScoreRequest(Contracts/ScoreRequest.cs:6-9):

{
 "provider": "cvss-kev",
 "subject": "CVE-2024-1234",
 "signals": { "Cvss": 7.5, "Kev": 1 }
}

RiskScoreResult(Contracts/RiskScoreResult.cs:6-14):

{
 "jobId": "guid",
 "provider": "cvss-kev",
 "subject": "CVE-2024-1234",
 "score": 0.95,
 "success": true,
 "error": null,
 "signals": { "Cvss": 7.5, "Kev": 1 },
 "completedAtUtc": "2026-05-05T00:00:02Z"
}

PostgresRiskScoreResultStore (src/Findings/__Libraries/StellaOps.RiskEngine.Infrastructure/Stores/PostgresRiskScoreResultStore.cs:12) stores these results in riskengine.risk_score_results, which the Findings baseline migration creates in stellaops_findings (src/Findings/__Libraries/StellaOps.Findings.Persistence/Migrations/001_findings_consolidated_baseline.sql:951). Only tests reference the store’s project.

Determinism Guarantees

  1. Reproducible scores: Same inputs always produce same outputs
  2. Profile versioning: Profile hash included in results for traceability
  3. Signal ordering: Signals processed in deterministic order
  4. Timestamp precision: UTC ISO-8601 with millisecond precision

Unblocks

This contract unblocks the following tasks: