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 under StellaOps.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:

SystemPurposeMaturity
EWS6-dimension legacy formula + advisory/attested-reduction variants, with guardrailsProduction (determinism suite exercises 1,000- and 10,000-iteration runs)
DeterminizationWeighted entropy, exponential confidence decay, conflict taxonomyProduction (separate StellaOps.Policy.Determinization library)
RiskEngineSignal-specific providers (CVSS/KEV/EPSS)Production

The unified score facade does not replace these systems. Instead, it:

  1. 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)
  2. Externalizes EWS weights to versioned manifest files for auditing
  3. 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 Gaps collection (IReadOnlyList<SignalGap>). There is no SignalGap type in src/Signals; missing-signal information is surfaced instead via DeltaIfPresent (per-signal potential impact) and the unknowns list on the API response. Treat Gaps as 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 a signalWeightsForEntropy block (vex 0.25, reachability 0.25, epss 0.15, runtime 0.15, backport 0.10, sbomLineage 0.10). The live UnifiedScoreService facade does not consume those per-signal weights yet — it uses the equal-weight present/6 form above. Do not assume the manifest entropy weights influence the facade’s UnknownsFraction until that wiring lands.

Band mapping (UnifiedScoreService.MapEntropyToBand):

U RangeBandMeaningAction
0.0 – 0.2CompleteAll / nearly all signals presentAutomated decisions
0.2 – 0.4AdequateSufficient for evaluationAutomated decisions
0.4 – 0.6SparseSignal gaps existManual review recommended
0.6 – 1.0InsufficientCritical data missingBlock pending more signals

The weight manifest also carries Determinization thresholds that align with these bands:

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; weight for the additive dimensions comes from the active weight manifest (Rch/Rts/Bkp), while the VEX delta uses a fixed 0.15 override 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 in etc/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

  1. Immutable once published – Manifest content is treated as fixed after creation.
  2. Hash verification – When VerifyHashes is enabled (default), the loader recomputes the SHA-256 of the raw file and logs a warning on mismatch (it does not reject the manifest).
  3. Profile pinning – Score evaluation can pin a manifest via the weight_set_id request option (CLI --weights-version); the facade falls back to the latest manifest when none is given.
  4. 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:

In addition to the standard path, the calculator has two other routes selected before CalculateStandard:

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 UnknownsFraction is 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:

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 with score.read. stella score replay|bundle|verify retain their Scanner proof-bundle semantics. Weight-manifest reads live under stella gate score weights and 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.GetReplayAsync builds a DSSE-shaped envelope with an empty signatures array; there is no key, no signature, and rekor_inclusion is always null. The reusable UnifiedScore/Replay/ReplayVerifier.VerifySignedAsync does perform cryptographic DSSE verification through the shared verifier and validates an attached Rekor proof offline. However, the HTTP /api/v1/score/verify path does not call that verifier: ScoreEvaluationService currently treats non-empty sig strings 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, not application/vnd.stella.score+json.
  • The illustrative transforms from the earlier draft (canonicalize_spdx v1.1, age_decay lambda 0.02) do not match code. The Signals ReplayLogBuilder emits transforms named ews_scoring (v2.0.0), bucket_classification, entropy_calculation, and unknowns_band_mapping; the live score response emits a single evidence_weighted_score transform tagged with the weights version and an empty algebra_steps list.
  • Replay proofs are NOT stored as OCI referrers (“StellaBundle”). The replay envelope is reconstructed on demand from the PostgreSQL ScoreHistoryStore record (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):

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:

AspectGuarantee
EWS scoreIdentical inputs + policy → identical score (EvidenceWeightedScoreDeterminismTests runs 1,000- and 10,000-iteration loops, including Parallel.For)
EWS digestEvidenceWeightedScoreResult.ComputeDigest() over canonical JSON (snake_case, sorted flags), excluding the digest field itself
EntropyIdentical signal presence → identical U (present/6)
Determinization fingerprintContent-addressed SHA-256 of the present/absent bitmap + entropy, truncated to 16 hex chars
Weight manifestTreated 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

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: