Unified Trust Score Architecture
Ownership: Policy Guild • Signals Guild Code namespaces:
StellaOps.Signals.UnifiedScore(facade),StellaOps.Signals.EvidenceWeightedScore(EWS core),StellaOps.Policy.Determinization(entropy/decay). Signals owns the HTTP surface and replay/verify orchestration underStellaOps.Signals.Score. Related docs: Policy architecture, EWS migration, Score Proofs API, Score Replay API
Audience: Policy and Signals engineers, and integrators consuming the score API/CLI.
This document describes the unified trust score facade — the single entry point for accessing risk scores, uncertainty metrics, and evidence from the underlying Evidence-Weighted Score (EWS) and Determinization systems. It covers the facade’s result shape, the unknowns metric, the weight-manifest schema, the HTTP/CLI surface, and the determinism guarantees.
Reconciled against source (
src/Signals,src/Policy,src/Platform). Field names, the entropy formula, the weight-manifest schema, the API routes, and the CLI verbs below have been verified against code. Where the live implementation diverges from an earlier/aspirational design (notably DSSE signing, Rekor anchoring, OCI-referrer storage, and the weighted entropy formula), the gap is called out inline.
1 · Design Principle: Facade Over Rewrite
Stella Ops has mature, battle-tested scoring systems:
| System | Purpose | Maturity |
|---|---|---|
| EWS | 6-dimension legacy formula + advisory/attested-reduction variants, with guardrails | Production (determinism suite exercises 1,000- and 10,000-iteration runs) |
| Determinization | Weighted entropy, exponential confidence decay, conflict taxonomy | Production (separate StellaOps.Policy.Determinization library) |
| RiskEngine | Signal-specific providers (CVSS/KEV/EPSS) | Production |
The unified score facade does not replace these systems. Instead, it:
- Combines the EWS score with an unknowns/uncertainty metric in a single result (the facade computes its own simplified entropy; see §3.2 for how this relates to the Determinization library’s weighted entropy)
- Externalizes EWS weights to versioned manifest files for auditing
- Exposes the unknowns fraction (U) as a first-class metric
2 · Architecture
┌─────────────────────────────────────────────────────────────┐
│ StellaOps.Signals (HTTP + replay/verify) │
│ ScoreEndpoints → ScoreEvaluationService │
│ • /api/v1/score/{evaluate,explain,replay,verify,weights} │
│ • persists ScoreHistoryStore; builds replay envelopes │
└─────────────┬───────────────────────────────────────────────┘
│ IUnifiedScoreService.ComputeAsync
▼
┌─────────────────────────────────────────────────────────────┐
│ IUnifiedScoreService │
│ (UnifiedScoreService facade) │
├─────────────────────────────────────────────────────────────┤
│ • ComputeAsync(request) → UnifiedScoreResult │
│ • Calls EWS calculator; loads weights from manifest │
│ • Computes its OWN simplified entropy (present/6), │
│ band mapping, delta-if-present, and 2 conflict heuristics │
└─────────────┬───────────────────────────────────────────────┘
│
▼
┌─────────────────────────┐
│ EvidenceWeightedScore │ (the Determinization library —
│ Calculator │ weighted entropy + decay — is a
├─────────────────────────┤ SEPARATE subsystem; the facade does
│ • legacy 6-dim formula │ NOT call it for U today, see §3.2)
│ • advisory / attested │
│ • guardrails (caps/floor)│
│ • policy digest │
└─────────────┬───────────┘
│ IWeightManifestLoader
▼
┌─────────────────────────────────────────────────────────────┐
│ etc/weights/*.weights.json │
│ (Versioned Weight Manifests) │
└─────────────────────────────────────────────────────────────┘
3 · What the Facade Provides
3.1 · Unified Score Result
The actual record (src/Signals/StellaOps.Signals/UnifiedScore/UnifiedScoreModels.cs) uses init-only properties and JSON names. Note the nullability: every Determinization-derived field is nullable because the entropy block is only computed when the caller supplies a SignalSnapshot.
public sealed record UnifiedScoreResult
{
// From EWS (always present)
public required int Score { get; init; } // 0-100
public required ScoreBucket Bucket { get; init; } // enum, not string
public required IReadOnlyList<DimensionContribution> Breakdown { get; init; }
public required AppliedGuardrails Guardrails { get; init; }
public required string EwsDigest { get; init; } // SHA-256 of canonical EWS result
public required WeightManifestRef WeightManifestRef { get; init; } // record (version + contentHash + …), not a string
public required DateTimeOffset ComputedAt { get; init; }
// From Determinization (null when no SignalSnapshot was supplied)
public double? UnknownsFraction { get; init; } // U metric (0.0 = complete, 1.0 = no data)
public UnknownsBand? UnknownsBand { get; init; } // Complete, Adequate, Sparse, Insufficient
public string? DeterminizationFingerprint { get; init; }
// Combined / optional
public IReadOnlyList<SignalConflict>? Conflicts { get; init; }
public IReadOnlyList<SignalDelta>? DeltaIfPresent { get; init; } // impact if a missing signal arrives
}
ScoreBucket is an enum (ActNow, ScheduleNext, Investigate, Watchlist); it serializes to its string name. WeightManifestRef is a record carrying Version, ContentHash, and optional EffectiveFrom/Profile.
NOT IMPLEMENTED: an earlier draft of this record exposed a
Gapscollection (IReadOnlyList<SignalGap>). There is noSignalGaptype insrc/Signals; missing-signal information is surfaced instead viaDeltaIfPresent(per-signal potential impact) and theunknownslist on the API response. TreatGapsas removed.
3.2 · Unknowns Fraction (U)
The UnknownsFraction (U) measures how much of the evidence the score lacked. It is computed only when the request carries a SignalSnapshot; otherwise it is null.
As implemented in the facade (UnifiedScoreService.CalculateEntropy), U is an unweighted count over the six signal slots (VEX, EPSS, Reachability, Runtime, Backport, SBOM lineage):
U = 1 - (present_signals / 6)
A signal counts as present unless its SignalState.IsNotQueried flag is set.
Doc-vs-code nuance: the design intent (and the Determinization library, §5.2) is a weighted entropy
U = 1 - (present_weight / total_weight), and the weight manifest even ships asignalWeightsForEntropyblock (vex 0.25, reachability 0.25, epss 0.15, runtime 0.15, backport 0.10, sbomLineage 0.10). The liveUnifiedScoreServicefacade does not consume those per-signal weights yet — it uses the equal-weightpresent/6form above. Do not assume the manifest entropy weights influence the facade’sUnknownsFractionuntil that wiring lands.
Band mapping (UnifiedScoreService.MapEntropyToBand):
| U Range | Band | Meaning | Action |
|---|---|---|---|
| 0.0 – 0.2 | Complete | All / nearly all signals present | Automated decisions |
| 0.2 – 0.4 | Adequate | Sufficient for evaluation | Automated decisions |
| 0.4 – 0.6 | Sparse | Signal gaps exist | Manual review recommended |
| 0.6 – 1.0 | Insufficient | Critical data missing | Block pending more signals |
The weight manifest also carries Determinization thresholds that align with these bands:
determinizationThresholds.refreshEntropy: 0.40→ triggers signal refreshdeterminizationThresholds.manualReviewEntropy: 0.60→ requires human review
3.3 · Delta-If-Present
When signals are missing (SignalState.IsNotQueried), the facade calculates the potential score impact if each were to arrive (UnifiedScoreService.CalculateDeltaIfPresent). Each entry is a SignalDelta with signal, weight, minImpact, maxImpact, and description.
The current logic is intentionally simple — maxImpact is the dimension weight scaled to 100 points, minImpact is 0 for the additive dimensions, and the VEX case is special-cased to a downward range:
{
"deltaIfPresent": [
{
"signal": "Reachability",
"weight": 0.30,
"minImpact": 0,
"maxImpact": 30,
"description": "If reachability confirmed, score could increase by up to 30 points"
},
{
"signal": "Runtime",
"weight": 0.25,
"minImpact": 0,
"maxImpact": 25,
"description": "If runtime witness present, score could increase by up to 25 points"
},
{
"signal": "VEX",
"weight": 0.15,
"minImpact": -100,
"maxImpact": 0,
"description": "If VEX states not_affected, score would be reduced to watchlist"
}
]
}
Deltas are only emitted for signals that are not queried;
weightfor the additive dimensions comes from the active weight manifest (Rch/Rts/Bkp), while the VEX delta uses a fixed0.15override weight in code rather than a manifest value.
4 · Weight Manifests
4.1 · Location
Weight manifests are loaded from the etc/weights/ directory (configurable via FileBasedWeightManifestLoaderOptions.WeightsDirectory; relative paths resolve against the app base directory). The loader globs *.weights.json and treats the filename stem as the version, sorting newest-first by ordinal string comparison:
etc/weights/
└── v2026-01-22.weights.json # filename = <version>.weights.json
As-built: only a single manifest,
v2026-01-22.weights.json, currently ships inetc/weights/. The directory is designed to hold many versions, but no second manifest exists in-repo today — examples showing additional dated files are illustrative, not present.
4.2 · Schema
The on-disk schema (WeightManifest in src/Signals/.../EvidenceWeightedScore/WeightManifest.cs) is richer than a flat weight map. Weights are split into a legacy (6-dimension EWS) block and an advisory (5-dimension) block, and the integrity field is contentHash (not hash). The loader auto-computes the hash when contentHash is "sha256:auto" or empty. A representative manifest:
{
"$schema": "https://stella-ops.org/schemas/weight-manifest/v1.0.0",
"schemaVersion": "1.0.0",
"version": "v2026-01-22",
"effectiveFrom": "2026-01-22T00:00:00Z",
"profile": "production",
"description": "EWS default weights",
"contentHash": "sha256:auto",
"weights": {
"legacy": { "rch": 0.30, "rts": 0.25, "bkp": 0.15, "xpl": 0.15, "src": 0.10, "mit": 0.10 },
"advisory": { "cvss": 0.25, "epss": 0.30, "reachability": 0.20, "exploitMaturity": 0.10, "patchProof": 0.15 }
},
"subtractiveDimensions": ["mit", "patchProof"],
"guardrails": {
"notAffectedCap": { "enabled": true, "maxScore": 15, "requiresBkpMin": 1.0, "requiresRtsMax": 0.6 },
"runtimeFloor": { "enabled": true, "minScore": 60, "requiresRtsMin": 0.8 },
"speculativeCap": { "enabled": true, "maxScore": 45, "requiresRchMax": 0.0, "requiresRtsMax": 0.0 }
},
"buckets": { "actNowMin": 90, "scheduleNextMin": 70, "investigateMin": 40 },
"determinizationThresholds": { "manualReviewEntropy": 0.60, "refreshEntropy": 0.40 },
"signalWeightsForEntropy": {
"vex": 0.25, "reachability": 0.25, "epss": 0.15, "runtime": 0.15, "backport": 0.10, "sbomLineage": 0.10
}
}
subtractiveDimensions, dimensionNames, and signalWeightsForEntropy are present in the shipped manifest JSON but are not all bound to typed properties on WeightManifest (e.g. signalWeightsForEntropy is read by neither the manifest record nor the facade today — see §3.2).
4.3 · Versioning Rules
- Immutable once published – Manifest content is treated as fixed after creation.
- Hash verification – When
VerifyHashesis enabled (default), the loader recomputes the SHA-256 of the raw file and logs a warning on mismatch (it does not reject the manifest). - Profile pinning – Score evaluation can pin a manifest via the
weight_set_idrequest option (CLI--weights-version); the facade falls back to the latest manifest when none is given. - Fallback – If no manifest is found, the facade synthesizes one from
EvidenceWeights.Default(version = "default").
5 · Existing Systems (Unchanged)
5.1 · EWS Formula (Preserved)
The legacy EWS formula (EvidenceWeightedScoreCalculator.CalculateStandard) is unchanged. Inputs are clamped to [0,1] first; MIT is the only subtractive dimension:
rawScore = (RCH × w_rch) + (RTS × w_rts) + (BKP × w_bkp) +
(XPL × w_xpl) + (SRC × w_src) - (MIT × w_mit)
finalScore = round(clamp(rawScore, 0, 1) × 100)
Guardrails are applied after the weighted sum, in order — caps before the floor (ApplyGuardrails). Defaults shown; all are configurable per manifest:
- Speculative cap (max 45): applies when
RCH ≤ 0andRTS ≤ 0. - Not-affected cap (max 15): applies when
BKP ≥ 1.0andRTS < 0.6andVexStatus == "not_affected". - Runtime floor (min 60): applies when
RTS ≥ 0.8.
In addition to the standard path, the calculator has two other routes selected before CalculateStandard:
- Authoritative VEX override — a trusted/in-project
not_affectedorfixedVEX statement short-circuits the score to 0 (bucketWatchlist). - Advisory formula (
FormulaMode.Advisory) and attested-reduction scoring (policy.AttestedReduction.Enabled) use different dimension sets/formulas (CVSS/EPSS/exploit-maturity/patch-proof, and an EPSS-anchored reduction respectively). These coexist with the legacy formula; the facade currently builds its requests through the legacy 6-dimension input.
Bucket thresholds (GetBucket): score ≥ 90 → ActNow, ≥ 70 → ScheduleNext, ≥ 40 → Investigate, else Watchlist.
5.2 · Determinization (Preserved)
The Determinization library (StellaOps.Policy.Determinization) keeps its weighted entropy and exponential half-life decay:
entropy = 1 - (present_weight / total_weight)
decayFactor = clamp(exp(-ln(2) × age_days / half_life_days), 0, 1)
decayed = max(floor, base_confidence × decayFactor)
DecayedConfidenceCalculator defaults to half_life_days = 14.0 and floor = 0.1 (verified in Scoring/DecayedConfidenceCalculator.cs).
The facade’s own
UnknownsFractionis the unweighted form (§3.2); the weighted entropy above is the Determinization-library calculation. They are not currently the same code path.
Conflict detection. The facade (UnifiedScoreService.DetectConflicts) currently implements two lightweight heuristics over the EWS input:
- High reachability and high backport (
Rch > 0.8 && Bkp > 0.8) →mutual_exclusion. - Runtime witness and low source confidence (
Rts > 0.8 && Src < 0.2) →inconsistency.
The richer Determinization conflict taxonomy (VEX vs reachability, static vs runtime, multiple-VEX-status, backport vs status) lives in the Determinization subsystem and is not what the unified-score facade emits in Conflicts. Treat the four-way list as the Determinization library’s surface, not the facade’s.
6 · Integration Points
6.1 · CLI Commands
Two command groups exist (src/Cli/StellaOps.Cli/Commands/):
stella gate score(CI/CD gating — ScoreGateCommandGroup) → subcommands evaluate, batch, weights {list, show, diff}:
stella gate score evaluate --finding-id CVE-2024-1234@pkg:npm/lodash@4.17.20 \
--cvss 7.5 --epss 0.15 --reachability function \
--show-unknowns --show-deltas
stella gate score weights list
stella gate score weights show v2026-01-22
stella gate score weights diff v2026-01-22 v2026-02-01
evaluate takes --cvss and --epss as required options; --reachability/--exploit-maturity are enums (none|package|function|caller, none|poc|functional|high).
Unwired direct-scoring design (ScoreCommandGroup) — this class contains compute, explain, replay, verify, history, and compare, but CommandFactory does not register it. The examples below describe that dormant command tree, not the shipped CLI surface:
# compute takes per-signal options (0..1), not --cvss/--epss
stella score compute -r 0.8 -t 0.5 -x 0.15 \
--cve CVE-2024-1234 --purl pkg:npm/lodash --breakdown --deltas
# explain / replay / verify take a SCORE ID argument (not a CVE)
stella score explain <score-id>
stella score replay <score-id> --save-to proof.json
stella score verify <score-id>
stella score history --cve CVE-2024-1234 --purl pkg:npm/lodash
stella score compare --cve CVE-2024-1234 --before <id> --after <id>
Shipped CLI:
stella score explain <digest>calls the Signals explanation route through the authenticated Backend Router withscore.read.stella score replay|bundle|verifyretain their Scanner proof-bundle semantics. Weight-manifest reads live understella gate score weightsand use the same authenticated score-read client. Direct compute/history command convergence is still pending because the dormant command tree collides with those public verbs.
6.2 · API Endpoints
The score surface is served by Signals (StellaOps.Signals) under /api/v1/score; Router exposes that route to external clients. The group requires RequireTenant() plus policy signals.score.read (canonical claim score.read). POST /evaluate additionally requires policy signals.score.evaluate (claim score.evaluate), so evaluation requires both scopes. POST /verify remains read-scoped. Both claim values are canonical StellaOpsScopes entries and are granted additively to the first-party CLI clients.
POST /api/v1/score/evaluate # Compute unified score (scope score.evaluate; audited)
GET /api/v1/score/{scoreId} # Get a previously computed score by id
GET /api/v1/score/history # Score history for a CVE (?cve_id=, ?purl=, ?limit=)
GET /api/v1/score/explain/{digest} # Deterministic explanation contract for a score digest
GET /api/v1/score/{scoreId}/replay # Fetch replay envelope for a score
POST /api/v1/score/verify # Re-execute + compare a replay envelope (scope score.read; audited)
GET /api/v1/score/weights # List weight manifests
GET /api/v1/score/weights/{version} # Get a specific manifest
GET /api/v1/score/weights/effective # Manifest effective at a date (?as_of=)
Replay Endpoint Response
The /score/{scoreId}/replay endpoint returns a DSSE-shaped envelope. The DSSE payload type is application/vnd.stellaops.score-replay+json (set in ScoreEvaluationService.ReplayPayloadType), and the response keys are snake_case:
{
"signed_replay_log_dsse": "BASE64",
"rekor_inclusion": null,
"canonical_inputs": [
{"name": "replay_payload", "sha256": "sha256:…"},
{"name": "signal_snapshot", "sha256": "sha256:…"}
],
"transforms": [
{"name": "evidence_weighted_score", "version": "v2026-01-22"}
],
"algebra_steps": [],
"final_score": 85,
"computed_at": "…"
}
IMPORTANT — overstated in the original draft:
- The replay envelope is not actually signed today.
ScoreEvaluationService.GetReplayAsyncbuilds a DSSE-shaped envelope with an emptysignaturesarray; there is no key, no signature, andrekor_inclusionis alwaysnull. The reusableUnifiedScore/Replay/ReplayVerifier.VerifySignedAsyncdoes perform cryptographic DSSE verification through the shared verifier and validates an attached Rekor proof offline. However, the HTTP/api/v1/score/verifypath does not call that verifier:ScoreEvaluationServicecurrently treats non-emptysigstrings as structurally valid without cryptographic verification. That HTTP verification gap must be fixed before the endpoint can claim signature authenticity.- The payload type is
application/vnd.stellaops.score-replay+json, notapplication/vnd.stella.score+json.- The illustrative
transformsfrom the earlier draft (canonicalize_spdxv1.1,age_decaylambda 0.02) do not match code. The SignalsReplayLogBuilderemits transforms namedews_scoring(v2.0.0),bucket_classification,entropy_calculation, andunknowns_band_mapping; the live score response emits a singleevidence_weighted_scoretransform tagged with the weights version and an emptyalgebra_stepslist.- Replay proofs are NOT stored as OCI referrers (“StellaBundle”). The replay envelope is reconstructed on demand from the PostgreSQL
ScoreHistoryStorerecord (score, weights version, signal-snapshot hash, EWS digest). There is no OCI-referrer attachment to the scored artifact in this path.
6.3 · Console UI
Finding rows / detail views surface the unified-score fields via dedicated shared components in src/Web/StellaOps.Web/src/app/shared/components/score/ (wired from finding-row.component.ts):
- Score with bucket (existing)
- Unknowns fraction (U) with color-coded band —
unknowns-band.component.ts/unknowns-tooltip.component.ts - Delta-if-present for missing signals —
delta-if-present.component.ts - Score breakdown by dimension —
score-breakdown-popover.component.ts - Score history trend —
score-history-chart.component.ts
Note: the Console’s score comparison feature (
/api/v1/scores/..., plural) is a separate, scan-level surface (summary / compare / timeseries) distinct from the per-finding unified-score endpoints (/api/v1/score/..., singular) documented in §6.2.
7 · Determinism Guarantees
The facade inherits determinism from the underlying calculator:
| Aspect | Guarantee |
|---|---|
| EWS score | Identical inputs + policy → identical score (EvidenceWeightedScoreDeterminismTests runs 1,000- and 10,000-iteration loops, including Parallel.For) |
| EWS digest | EvidenceWeightedScoreResult.ComputeDigest() over canonical JSON (snake_case, sorted flags), excluding the digest field itself |
| Entropy | Identical signal presence → identical U (present/6) |
| Determinization fingerprint | Content-addressed SHA-256 of the present/absent bitmap + entropy, truncated to 16 hex chars |
| Weight manifest | Treated as immutable; integrity via contentHash |
The facade itself adds one wall-clock field — ComputedAt (via injected TimeProvider) — which is excluded from the EWS digest, so the digest stays deterministic.
8 · What We’re NOT Doing
- ❌ Replacing the EWS formula
- ❌ Replacing the Determinization entropy/decay calculations
- ❌ Changing guardrail logic
- ❌ Breaking existing CLI commands
- ❌ Breaking existing API contracts
The facade is additive – existing functionality continues to work unchanged.
8.1 · Known gaps (roadmap, not yet shipped)
These are surfaced by the contract but not yet implemented; do not present them as live guarantees:
- DSSE signing of replay logs — the producer ships the replay envelope with an empty
signaturesarray. The reusableReplayVerifier.VerifySignedAsyncperforms cryptographic verification, but the HTTP/api/v1/score/verifypath is not wired to it and currently checks signature fields structurally only. - Rekor transparency-log anchoring — producer output always has
rekor_inclusion = null. The reusable replay verifier can validate an attached proof offline, but the HTTP verification path does not establish Rekor authenticity. - OCI-referrer (“StellaBundle”) storage of score proofs — replay is reconstructed from the PostgreSQL score-history record, not stored as an OCI referrer on the artifact.
- Weighted facade entropy — the manifest’s
signalWeightsForEntropyblock is not consumed byUnifiedScoreService; U is the unweightedpresent/6form (§3.2).
