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, andSPRINT_20260722_010FND-10 deletedStellaOps.RiskEngine.WebService,StellaOps.RiskEngine.WorkerandStellaOps.RiskEngine.Application, with their compose keys and images, on 2026-08-27. Nosrc/file outsidesrc/__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 scopesrisk-engine:readandrisk-engine:operatewere withdrawn on 2026-10-02: they are no longer declared inStellaOpsScopes, 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.
| Subsystem | What it serves | Source |
|---|---|---|
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 library | No 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
- EWS API:
src/Findings/__Libraries/StellaOps.Findings.Ledger.Application/Endpoints/ScoringEndpoints.cs,.../Endpoints/WebhookEndpoints.cs,.../Contracts/ScoringContracts.cs - EWS OpenAPI (committed):
../modules/findings/openapi/v1.json - Withdrawn Policy Engine job path (frozen, not built):
src/__Obsoleted/Policy/StellaOps.Policy.Engine/PCD-269/ - Weighted profile scoring (Policy Engine simulation):
src/Policy/StellaOps.Policy.Engine/Simulation/RiskSimulationService.cs - Risk profile:
src/Policy/StellaOps.Policy.RiskProfile/Models/RiskProfileModel.cs - Attestation schema:
src/Attestor/StellaOps.Attestor.Types/schemas/stellaops-risk-profile.v1.schema.json - Kept providers and contracts:
src/Findings/StellaOps.RiskEngine.Core/Providers/,src/Findings/StellaOps.RiskEngine.Core/Contracts/(ScoreRequest,RiskScoreResult) - Canonical scopes:
src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs
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).
| Method | Route | Policy | Description | Source |
|---|---|---|---|---|
| POST | /api/findings/v1/ledger/{findingId}/score | scoring.write | Calculate 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}/score | scoring.read | Return the cached score without recalculation; 404 if none was computed. | :54-60 |
| POST | /api/findings/v1/ledger/scores | scoring.write | Batch 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-history | scoring.read | Paginated history; query from, to, limit (default 50, clamped 1–100) and cursor. | :76-82, :236-241 |
| GET | /api/findings/v1/scoring/policy | scoring.read | Active scoring policy (ScoringPolicyResponse). | :86-91 |
| GET | /api/findings/v1/scoring/policy/{version} | scoring.read | One policy version; 404 if unknown. | :95-101 |
| GET | /api/findings/v1/scoring/policy/versions | scoring.read | All policy versions (PolicyVersionListResponse). | :106-111 |
| POST | /api/findings/v1/scoring/webhooks | scoring.admin | Register a score-change webhook; 201 with WebhookResponse. | WebhookEndpoints.cs:28-35 |
| GET | /api/findings/v1/scoring/webhooks | scoring.admin | List registered webhooks (WebhookListResponse). | :39-44 |
| GET | /api/findings/v1/scoring/webhooks/{id} | scoring.admin | One webhook; 404 if unknown. | :48-54 |
| PUT | /api/findings/v1/scoring/webhooks/{id} | scoring.admin | Replace a webhook configuration; 404 if unknown. | :58-66 |
| DELETE | /api/findings/v1/scoring/webhooks/{id} | scoring.admin | Delete 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).
| Type | Description | Value Range |
|---|---|---|
boolean | True/false signal | true / false |
numeric | Numeric signal | 0.0 to 1.0 (normalized) |
categorical | Categorical signal | String 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.
| Level | JSON Value | Priority |
|---|---|---|
| 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).
| Action | Description |
|---|---|
allow | Finding is acceptable, no action required |
review | Finding requires manual review |
deny | Finding 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 name | Class | Source |
|---|---|---|
default-transforms | DefaultTransformsProvider | Providers/DefaultTransformsProvider.cs:11 |
cvss-kev | CvssKevProvider | Providers/CvssKevProvider.cs:11 |
epss | EpssProvider | Providers/EpssProvider.cs:12 |
cvss-kev-epss | CvssKevEpssProvider | Providers/EpssProvider.cs:71 |
vex-gate | VexGateProvider | Providers/VexGateProvider.cs:11 |
fix-exposure | FixExposureProvider | Providers/FixExposureProvider.cs:12 |
fixchain | FixChainRiskProvider | Providers/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
- Reproducible scores: Same inputs always produce same outputs
- Profile versioning: Profile hash included in results for traceability
- Signal ordering: Signals processed in deterministic order
- Timestamp precision: UTC ISO-8601 with millisecond precision
Unblocks
This contract unblocks the following tasks:
- LEDGER-RISK-67-001
- LEDGER-RISK-68-001
- LEDGER-RISK-69-001
- POLICY-RISK-67-003
- POLICY-RISK-68-001
- POLICY-RISK-68-002
Related Contracts
- Advisory Key Contract - Advisory ID canonicalization
- VEX Lens Contract - VEX evidence for scoring
- Export Bundle Contract - Score digest in exports
