Risk Scoring Contract
Contract ID: CONTRACT-RISK-SCORING-002 Version: 1.0 Status: Published (API Endpoints section reconciled against source 2026-05) Last Updated: 2026-05-30
Overview
This contract defines how Stella Ops scores and prioritizes vulnerability findings so that policy gates and operators can act on what matters first. It covers the scoring job requests, results, risk profiles, and signal definitions that drive prioritization.
Audience: engineers integrating with or extending risk scoring — the Policy Engine, the Risk Engine service, and the Findings Ledger.
Three subsystems, three surfaces. Risk scoring in Stella Ops is not a single service. The Policy Engine owns the job/result/profile DTOs below and creates jobs internally (no public submission endpoint); the Risk Engine service exposes provider-based scoring over HTTP (
/risk-scores/*); and the Findings Ledger exposes the Evidence-Weighted Score (EWS) API (/api/v1/findings/{findingId}/score,/api/v1/scoring/*). See API Endpoints and the surface map under Implementation References for the authoritative breakdown.
Implementation References
- Job/Result Models (Policy Engine):
src/Policy/StellaOps.Policy.Engine/Scoring/RiskScoringModels.cs - Trigger Service (queue/event-driven job creation):
src/Policy/StellaOps.Policy.Engine/Scoring/RiskScoringTriggerService.cs - Job Persistence (Policy Engine):
src/Policy/StellaOps.Policy.Engine/Scoring/PostgresRiskScoringJobStore.cs(tablepolicy.risk_scoring_jobs) - Risk Profile:
src/Policy/StellaOps.Policy.RiskProfile/Models/RiskProfileModel.cs - Attestation Schema:
src/Attestor/StellaOps.Attestor.Types/schemas/stellaops-risk-profile.v1.schema.json - Risk Engine service (HTTP scoring providers):
src/Findings/StellaOps.RiskEngine.WebService/Program.cs,src/Findings/StellaOps.RiskEngine.Core/Contracts/(ScoreRequest,RiskScoreResult) - Findings Ledger Evidence-Weighted Scoring API:
src/Findings/StellaOps.Findings.Ledger.WebService/Endpoints/ScoringEndpoints.cs,.../Contracts/ScoringContracts.cs - Canonical scopes:
src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs
Surface map (verify against code). Three distinct subsystems contribute to risk scoring and they do not share a single HTTP surface:
- Policy Engine owns the
RiskScoringJobRequest/RiskScoringJob/RiskScoringResult/FindingChangedEventDTOs documented below. Jobs are created internally byRiskScoringTriggerService.HandleFindingChangedAsync/CreateJobAsync(queue/event-driven) and persisted topolicy.risk_scoring_jobs. There is no publicPOST /api/v1/risk/jobsHTTP endpoint — the Policy Engine/api/risk/*routes cover profiles, simulation, scopes, overrides, events, and schema validation only.- Risk Engine service (
StellaOps.RiskEngine.WebService) exposes provider-based scoring under/risk-scores/*.- Findings Ledger exposes the Evidence-Weighted Score (EWS) API under
/api/v1/findings/{findingId}/scoreand/api/v1/scoring/*.
Data Models
RiskScoringJobRequest
Request to create a risk scoring job.
{
"tenant_id": "string",
"context_id": "string",
"profile_id": "string",
"findings": [
{
"finding_id": "string",
"component_purl": "pkg:npm/lodash@4.17.20",
"advisory_id": "CVE-2024-1234",
"trigger": "created|updated|enriched|vex_applied"
}
],
"priority": "low|normal|high|emergency",
"correlation_id": "string (optional)",
"requested_at": "2025-12-05T00:00:00Z (optional)"
}
RiskScoringJob
A queued or completed risk scoring job.
{
"job_id": "string",
"tenant_id": "string",
"context_id": "string",
"profile_id": "string",
"profile_hash": "sha256:...",
"findings": [...],
"priority": "normal",
"status": "queued|running|completed|failed|cancelled",
"requested_at": "2025-12-05T00:00:00Z",
"started_at": "2025-12-05T00:00:01Z (optional)",
"completed_at": "2025-12-05T00:00:02Z (optional)",
"correlation_id": "string (optional)",
"error_message": "string (optional)"
}
RiskScoringResult
Result of scoring a single finding (Policy Engine RiskScoringResult record).
{
"finding_id": "string",
"profile_id": "string",
"profile_version": "1.0.0",
"raw_score": 0.75,
"normalized_score": 0.85,
"severity": "high",
"signal_values": {
"cvss": 7.5,
"kev": true,
"reachability": 0.9
},
"signal_contributions": {
"cvss": 0.4,
"kev": 0.3,
"reachability": 0.3
},
"override_applied": "kev-boost (optional)",
"override_reason": "Known Exploited Vulnerability (optional)",
"scored_at": "2025-12-05T00:00:02Z",
"scoring_profile": "Simple|Advanced|Custom (optional)",
"explain": []
}
normalized_scoreis DEPRECATED. Per the XML doc onRiskScoringResult.NormalizedScore, this field is a legacy normalized score (0–1) scheduled for removal in v2.0 (DESIGN-POLICY-NORMALIZED-FIELD-REMOVAL-001). Consumers should rely onseverityinstead.
scoring_profile(string?) records which engine produced the result (Simple,Advanced, orCustom).
explainis a deterministically sorted list ofScoreExplanationentries describing per-factor contributions (src/Policy/__Libraries/StellaOps.Policy/Scoring/ScoreExplanation.cs). It defaults to an empty array.
Risk Profile Model
RiskProfileModel
Defines how findings are scored and prioritized.
{
"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
| 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) and the RiskLevel enum in the attestation schema. 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
| Action | Description |
|---|---|
allow | Finding is acceptable, no action required |
review | Finding requires manual review |
deny | Finding is not acceptable, blocks promotion |
Scoring Algorithm
Score Calculation (weighted profile model)
For the Policy Engine RiskProfileModel weighted approach, each signal value is multiplied by its profile weight and summed, then clamped:
raw_score = Σ(signal_value × weight) for all signals
normalized_score = clamp(raw_score, 0.0, 1.0)
Note: in RiskScoringResult, signal_values is a mixed-type map (IReadOnlyDictionary<string, object?> — booleans, numerics, or categoricals), while signal_contributions is IReadOnlyDictionary<string, double>. The standalone Risk Engine providers (below) instead consume a flat IReadOnlyDictionary<string, double> of numeric signals.
These providers live in the Risk Engine service (src/Findings/StellaOps.RiskEngine.Core/Providers/) and operate on the ScoreRequest.Signals numeric dictionary. Each clamps and rounds its result to 6 decimal places (banker’s rounding).
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:
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):
score = min(1.0, (clamp(cvss, 0, 10) / 10.0) + kevBonus)
where kevBonus = kev ? 0.2 : 0.0
API Endpoints
Corrected 2026-05. Earlier revisions of this contract documented a
POST /api/v1/risk/jobs,GET /api/v1/risk/jobs/{job_id}, andGET /api/v1/risk/findings/{finding_id}/scoresurface. None of those routes exist in the code. The job/result DTOs above are created internally by the Policy EngineRiskScoringTriggerService(no public submission endpoint). The real HTTP surfaces are the Risk Engine service (/risk-scores/*) and the Findings Ledger Evidence-Weighted Score API (/api/v1/findings/{findingId}/score,/api/v1/scoring/*), documented below.
Risk Engine service — /risk-scores/*
Source: src/Findings/StellaOps.RiskEngine.WebService/Program.cs. All routes require an Authority bearer token; scopes are risk-engine:read (StellaOpsScopes.RiskEngineRead) for reads and risk-engine:operate (StellaOpsScopes.RiskEngineOperate) for writes/simulations.
| Method | Route | Scope | Description |
|---|---|---|---|
| GET | /risk-scores/providers | risk-engine:read | Sorted list of registered provider names. |
| POST | /risk-scores/jobs | risk-engine:operate | Enqueues a ScoreRequest, runs it synchronously, returns 202 Accepted with { jobId, result }. Result is persisted. |
| GET | /risk-scores/jobs/{jobId:guid} | risk-engine:read | Returns the stored RiskScoreResult for a job, or 404. |
| POST | /risk-scores/simulations | risk-engine:operate | Evaluates a collection of ScoreRequest; returns { results }. Not persisted. |
| POST | /risk-scores/simulations/summary | risk-engine:operate | As above plus an aggregate { averageScore, minScore, maxScore, topMovers } summary. |
Health probes GET /healthz and GET /readyz are anonymous.
ScoreRequest(StellaOps.RiskEngine.Core.Contracts.ScoreRequest):
{
"provider": "cvss-kev",
"subject": "CVE-2024-1234",
"signals": { "Cvss": 7.5, "Kev": 1 }
}
RiskScoreResult(StellaOps.RiskEngine.Core.Contracts.RiskScoreResult):
{
"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"
}
Registered providers (Program.cs): default-transforms, cvss-kev, epss, cvss-kev-epss, vex-gate, fix-exposure.
Findings Ledger — Evidence-Weighted Score (EWS) API
Source: src/Findings/StellaOps.Findings.Ledger.WebService/Endpoints/ScoringEndpoints.cs. All routes require a tenant (RequireTenant()) and an Authority token carrying the configured scope (findings:ledger:Authority:RequiredScopes, default vuln:operate). This API returns the EWS shape (EvidenceWeightedScoreResponse, score 0–100), which is distinct from the Policy Engine RiskScoringResult above.
| Method | Route | Description |
|---|---|---|
| POST | /api/v1/findings/{findingId}/score | Calculate and persist the evidence-weighted score; returns EvidenceWeightedScoreResponse. 404 if finding/evidence missing. |
| GET | /api/v1/findings/{findingId}/score | Return the cached score without recalculation; 404 if none computed. |
| POST | /api/v1/findings/scores | Batch calculate (max 100 finding IDs); returns CalculateScoresBatchResponse. 400 if batch too large. |
| GET | /api/v1/findings/{findingId}/score-history | Paginated history (from, to, limit clamped 1–100, cursor). |
| GET | /api/v1/scoring/policy | Active scoring policy (ScoringPolicyResponse). |
| GET | /api/v1/scoring/policy/{version} | A specific policy version; 404 if unknown. |
| GET | /api/v1/scoring/policy/versions | List of all policy versions (PolicyVersionListResponse). |
EvidenceWeightedScoreResponse(abridged):
{
"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
}
Finding Change Events
Events that trigger rescoring:
| Event | JSON Value | Description |
|---|---|---|
| Created | "created" | New finding discovered |
| Updated | "updated" | Finding metadata changed |
| Enriched | "enriched" | New signals available |
| VEX Applied | "vex_applied" | VEX status changed |
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
