Findings Ledger — Architecture

The Findings Ledger is the append-only, content-addressed system of record for every vulnerability finding, VEX decision, and risk score the Stella Ops release control plane produces. This page is the architecture-level view of the consolidated Findings module (src/Findings/): the append-only Findings Ledger event spine, the RiskEngine scoring service, the merged VulnExplorer read/write surface, and the runtime instrumentation subsystem that landed across sprints SPRINT_20260429_011..015. It is written for engineers integrating with the ledger and for operators reasoning about its runtime, persistence, and determinism guarantees.

Companion pages. For module overview and runtime-cutover status see README.md. For low-level algorithm semantics see runtime-instrumentation.md. For the on-disk schema see schema.mdand schema-catalog.md. The Console exposure/aggregate contract owned by the dedicated Findings.Security host is documented in security-read-model.md. The Findings.VulnCorrelation worker — owner of the analytics.* schema and of the SBOM/attestation/advisory ingestion lanes — has its own dossier: vuln-correlation.md.


Module composition (Sprint 207 consolidation)

src/Findings/ is the unified home for all findings-related services. Four deployable hosts (three HTTP, one worker) plus their shared libraries live side by side. Each schema-owning host auto-migrates its own schema on startup (AGENTS.md §2.7): findings (Ledger), findings_security (Security), riskengine (RiskEngine), analytics (VulnCorrelation).

Host / libraryProjectRole
Findings Ledger coresrc/Findings/StellaOps.Findings.Ledger/Domain types, hashing, deterministic ids, projection reducer, services (write, chain verify, snapshot, attestation pointer, alert, decision, evidence bundle, VEX consensus, air-gap, orchestrator export).
Findings Ledger WebServicesrc/Findings/StellaOps.Findings.Ledger.WebService/HTTP host. Also serves the merged VulnExplorer endpoints and a compatibility scoring/webhook surface. Owns the findings schema.
Findings Security WebServicesrc/Findings/StellaOps.Findings.Security.WebService/Tenant/scoped Console exposure read model: flat findings, facets, pivots, disposition overlays, image context, aggregate risk, and tenant-scoped freshness status. It subscribes to disposition changes and runs a periodic authoritative repair. Owns the findings_security schema through its persistence library.
Findings VulnCorrelation WebServicesrc/Findings/StellaOps.Findings.VulnCorrelation.WebService/Worker host (no gateway route). Runs the SBOM/attestation/advisory ingestion + correlation BackgroundServices and owns the analytics.* schema. Relocated out of platform-web by PAC-5. → vuln-correlation.md.
RiskEngine Coresrc/Findings/StellaOps.RiskEngine.Core/Score providers (CVSS/KEV, EPSS, combined, VEX gate, fix-exposure, fix-chain), exploit-maturity service, in-process score queue + worker.
RiskEngine WebServicesrc/Findings/StellaOps.RiskEngine.WebService/HTTP host for risk-score jobs/simulations and exploit-maturity assessment.
RiskEngine Workersrc/Findings/StellaOps.RiskEngine.Worker/Background snapshot exporter: ExecuteAsync exports on startup and on a PeriodicTimer (Worker:ExportIntervalSeconds, default 3600 s), reading Concelier’s vuln.advisory_cvss / vuln.kev_flags and writing cvss-snapshot.gz + kev-snapshot.gz to Worker:BundleOutputDirectory (Worker.cs:43-106). It does not process score jobs — those run in-line in the WebService.
RiskEngine Infrastructuresrc/Findings/__Libraries/StellaOps.RiskEngine.Infrastructure/riskengine schema stores (PostgresRiskScoreResultStore, in-memory test store) and migration 001_initial_schema.sql.
VulnCorrelation persistencesrc/Findings/__Libraries/StellaOps.Findings.VulnCorrelation.Persistence/analytics schema ownership: AddVulnCorrelationMigrations + the embedded 001_v1_analytics_baseline.sql.
Findings disposition contractssrc/Findings/__Libraries/StellaOps.Findings.Disposition.Contracts/Domain-agnostic FindingDisposition contract shared by the Security read model and the VulnCorrelation ingestion lane.
Runtime processing librarysrc/Findings/__Libraries/StellaOps.Findings.Ledger.Runtime/Pure-function privacy redactor, trace aggregator, score calculator.
DORA/ROI taxonomysrc/Findings/__Libraries/StellaOps.Findings.DoraRoi/DORA-ROI taxonomy validation, signing contract, template encoder.

The Findings Security host also owns the bounded query-specific GET /api/v2/security/findings/cve-exposure read. It computes exact tenant/CVE and optional region/environment/artifact counts inside PostgreSQL over the owned materialized projection, then returns at most 50 deterministic reachable finding rows. Its verdict is exposed, not_exposed, or unknown; only an all-not-present exact match set proves not_exposed. The projection does not store digest, service, or owner identity, so the endpoint accepts no digest query and reports service/owner as not_recorded rather than inferring them. See security-read-model.mdfor the full contract and successor boundary.

Findings Ledger core surface

The ledger is the append-only event spine. The WebService exposes (non-exhaustive):

The compatibility EvidenceSubgraphAdapter maps a Ledger EvidenceGraphResponse into the VulnExplorer tree contract without discarding evidence. It uses graph.VulnerabilityId, builds children from inbound edges starting at RootNodeId, orders IDs ordinally, handles cycles, attaches any disconnected nodes deterministically, maps Ledger node types explicitly, and emits only edges whose endpoints resolve in the returned tree. This repairs the backend adapter only. The current Web request route/identifier still does not match the Findings endpoint contract and is not claimed as an end-to-end UI flow.

Background hosted services: LedgerMerkleAnchorWorker, LedgerProjectionWorker, AssetRegistryLedgerProjectionWorker, RuntimeTraceRetentionWorker. The findings schema auto-migrates on startup via AddStartupMigrations("findings", "FindingsLedger", …).

Runtime cutover (truthful fail-closed posture). In non-Testing hosts the WebService deliberately registers Unsupported* services for retired or unimplemented surfaces (evidence content, reachability map, backport evidence, risk-explanation store, score-history store, webhook store/delivery, VulnExplorer VEX-override attestor). Those return 501 problem+json instead of fabricating success. Rekor verification is disabled unless findings:ledger:rekor:enabled=true + a configured baseUrl. See README.md“Runtime cutover status” for the full list.

RiskEngine

RiskEngine.WebService (service name riskengine, riskengine Postgres schema) computes risk scores from registered providers and assesses exploit maturity.

MethodPathScopeNotes
GET/risk-scores/providersrisk-engine:readLists registered provider names.
POST/risk-scores/jobsrisk-engine:operateEnqueues + runs a job in-line; returns 202 Accepted.
GET/risk-scores/jobs/{jobId}risk-engine:readReads a stored result (404 if absent).
POST/risk-scores/simulationsrisk-engine:operateEvaluates a batch (no persistence).
POST/risk-scores/simulations/summaryrisk-engine:operateBatch + aggregate (avg/min/max + top-3).
GET/exploit-maturity/{cveId}[/level|/history]risk-engine:readExploit-maturity assessment.
POST/exploit-maturity/batchrisk-engine:operateBatch maturity assessment.

Providers registered in Program.cs: DefaultTransformsProvider, CvssKevProvider, EpssProvider, CvssKevEpssProvider, VexGateProvider, FixExposureProvider (plus FixChainRiskProvider in Core/Providers/FixChain/, which is not registered in the WebService provider registry). The result store is selected by the Storage:Driver config key (default postgres): postgresPostgresRiskScoreResultStore (requires a connection string and runs the riskengine startup migration), inmemoryInMemoryRiskScoreResultStore which throws unless the host is Testing. CVSS/KEV/EPSS sources fail closed (Unsupported*501 risk_sources_unavailable) unless a real EPSS bundle/snapshot/directory is configured under RiskEngine:Sources:Epss:* or the caller supplies explicit inline scoring signals. risk-engine:read and risk-engine:operate are the JWT scopes (StellaOpsScopes.RiskEngineRead / RiskEngineOperate); RiskEnginePolicies.Read/Operate are the policy names.

VulnExplorer (merged)

The standalone StellaOps.VulnExplorer.Api container has been decommissioned (SPRINT_20260408_002). Its endpoints are served by the Ledger WebService (Endpoints/VulnExplorerEndpoints.cs) under vuln:* scopes:

MethodPathScope (policy)
GET/v1/vulns, /v1/vulns/{id}vuln:view (VulnExplorer.View)
POST / PATCH/v1/vex-decisions[...]vuln:operate (VulnExplorer.Operate)
GET/v1/vex-decisions[...]vuln:view
GET/v1/evidence-subgraph/{vulnId}vuln:view
POST / PATCH/v1/fix-verifications[...]vuln:operate
POST/v1/audit-bundlesvuln:audit (VulnExplorer.Audit)

vuln:investigate (VulnExplorer.Investigate) is also registered as a policy. VEX decisions persist in findings.vex_decisions (migration 010); the store is Postgres-backed with an honest empty 200 when a tenant has no decisions.

Findings.VulnCorrelation (analytics ingestion + correlation)

StellaOps.Findings.VulnCorrelation.WebService is a worker — no gateway route, no read-model endpoints, only worker health probes — that owns and auto-migrates the whole analytics.* schema (11 tables, 4 materialized views, the sp_* / compute_daily_rollups / refresh_all_views functions) and runs three hosted consumers:

Background serviceStreamWrites
AnalyticsIngestionServiceorchestrator:events (scanner.event.report.ready, scanner.scan.completed)analytics.artifacts, raw_sboms, components, artifact_components — SBOM read from CAS
AttestationIngestionServiceattestor:events (rekor.entry.logged, rekor.inclusion.verified)analytics.attestations, raw_attestations, vex_overrides, provenance/SLSA fields on artifacts
VulnerabilityCorrelationServiceconcelier:advisory.observation.updated:v1, concelier:advisory.linkset.updated:v1analytics.component_vulns (+ artifact severity counts), joining Concelier’s vuln.* tables

It was relocated out of platform-web by SPRINT_20260703_001 PAC-5, keeping the Platform:AnalyticsIngestion config section names and the CAS stream checkpoint so ingestion resumed without a gap. Platform retains the read surface (GET /api/analytics/*, scope analytics.read) and the daily-rollup / materialized-view-refresh maintenance lane — the ownership split, the schema, the config keys, and the tenancy constraint (analytics.* has no tenant column) are all documented in vuln-correlation.md.


Runtime instrumentation

What this is for

The runtime instrumentation subsystem turns ad-hoc eBPF / syscall / APM probe observations into a per-finding evidence stream. Operators use the resulting runtime score (0–100) to triage which of N otherwise-equivalent vulnerabilities are actually exercised in production. The feature is gated by findings:ledger:runtime:enabled; operators opt in per the enable guide.

Default-value note (verify before relying on it). The toggle is read in two places with different defaults: RuntimePersistenceExtensions.AddFindingsLedgerRuntimePersistence reads it with defaultValue: true (Postgres repositories wired unless explicitly set to false), while RuntimeCapabilitiesEndpoints reads it with defaultValue: false. The operational intent (ADR-013, enable guide) is opt-in, but the persistence layer wires the Postgres repositories by default. Set the key explicitly in any environment where the posture matters.

Pipeline

flowchart LR
    A[Agent / probe] -->|HTTP POST| B[Ingest endpoint]
    B --> C[Privacy filter
RuntimeSymbolRedactor] C --> D[Aggregator
RuntimeTraceAggregator] D --> E[(Postgres
findings.runtime_traces
findings.runtime_trace_aggregates
findings.runtime_scores
findings.runtime_timeline_events)] E --> F[Score calculator
RuntimeScoreCalculator] F --> E E --> G[Query API
/runtime/traces
/runtime/score
/runtime-timeline] G --> H[CLI / Web console / SDK]

ASCII fallback (for environments where Mermaid does not render):

agent → ingest → privacy filter → aggregator → persistence ─┐
                                                             ├→ score calculator → persistence
                                                             │
                                  query / timeline ←─────────┘
                                          ↓
                                  CLI / UI / SDK

Components

ComponentLives atSprint
Domain typessrc/Findings/StellaOps.Findings.Ledger/Domain/Runtime/RuntimeModels.cs011
Persistence interfaces + Null repossrc/Findings/StellaOps.Findings.Ledger/Infrastructure/Runtime/ (Postgres impls under Infrastructure/Postgres/Runtime/)011
SQL migrations (4 runtime tables)src/Findings/StellaOps.Findings.Ledger/migrations/011_runtime_traces.sql, 012_runtime_scores_timeline.sql, 013_runtime_aggregates_entry_point.sql, 014_runtime_traces_container_metadata.sql, 018_runtime_traces_release_id.sql011+
Privacy filter, aggregator, scoresrc/Findings/__Libraries/StellaOps.Findings.Ledger.Runtime/ (Privacy/, Aggregation/, Scoring/)012
HTTP endpointssrc/Findings/StellaOps.Findings.Ledger.WebService/Endpoints/RuntimeTracesEndpoints.cs, RuntimeTimelineEndpoints.cs, RuntimeCapabilitiesEndpoints.cs, RuntimeObservationsEndpoint.cs013, 020, 025
Orchestration servicesrc/Findings/StellaOps.Findings.Ledger.WebService/Services/ (RuntimeInstrumentationService)013
Observability + retentionsrc/Findings/StellaOps.Findings.Ledger.WebService/ (RuntimeInstrumentationMetrics, RuntimeTraceRetentionWorker)014
Documentation, ADRs, CLI, SDKdocs/modules/findings-ledger/, docs/architecture/decisions/, src/Cli/StellaOps.Cli/015

Data model

Four Postgres tables under the findings schema:

The doc previously described “three tables” and named the aggregate table runtime_aggregates; the shipped schema has four runtime tables and the aggregate table is runtime_trace_aggregates.

See schema.mdand the SQL migrations under src/Findings/StellaOps.Findings.Ledger/migrations/ (files 011014, 018) for column-level detail.

HTTP surface (summary)

MethodPathDescriptionPolicy / JWT scope
POST/api/v1/findings/{findingId}/runtime/tracesIngest one runtime trace observationfindings.runtime.write → scope findings:write
GET/api/v1/findings/{findingId}/runtime/tracesList per-function aggregatesfindings.runtime.read → scope findings:read
GET/api/v1/findings/{findingId}/runtime/scoreRead the current runtime scorefindings.runtime.read → scope findings:read
GET/api/v1/findings/{findingId}/runtime-timelineChronological event timeline (note: dash, not slash)findings.runtime.read → scope findings:read
GET/api/v1/capabilities/runtimePlatform-enabled flag + tenant-scoped ingest statsfindings.runtime.read → scope findings:read
POST/api/v1/runtime/observationsUnified release-bound, finding-agnostic ingestAllowAnonymous (mTLS-authenticated upstream; no Authority scope)

Scope clarification. findings.runtime.read / findings.runtime.write are the authorization-policy names, not the scope strings. In Program.cs the read policy requires the JWT scope findings:read (StellaOpsScopes.FindingsRead) and the write policy requires findings:write. A read-only token is rejected with 403 by the write policy.

Ingest always returns 202 Accepted. When the feature is disabled the Null repositories no-op writes (still 202) and return empty reads, which surface as 404. Empty-result reads return 404 NotFound (not 200 OK + empty array) — this is contract-tested. Clients MUST distinguish the two states.

Full details: runtime-instrumentation-api.md. OpenAPI: docs/modules/findings-ledger/openapi/.

Operational view

Privacy posture

Per-process address tokens (@0x[hex]+ suffixes) are stripped from symbol names and file paths before any persistence path executes. The aggregator’s bucket key is the redacted symbol, so two traces of SSL_write@0xAA… and SSL_write@0xBB… collapse into a single aggregate. The 1-byte loss of fidelity is an explicit design trade-off: the addresses are per-process-image artifacts with no cross-process correlation value. Hit counts and container counts are counts, not identifiers — they cannot de-anonymize which specific pod / process / request produced a trace.

Full rationale, alternatives considered, and per-field redaction rules: ADR-015 Findings Runtime Privacy Filterand runtime-instrumentation.md§1.

Architecture decision records

ADRTopic
ADR-014 Findings Runtime PersistenceFour-table layout (traces / trace-aggregates / scores / timeline), JSONB frames, index design
ADR-015 Findings Runtime Privacy FilterRedaction contract, what is / is not stripped
ADR-016 Findings Runtime Score FormulaFour-component weighted score (0.40 hits / 0.30 entry-point / 0.20 coverage / 0.10 recency), weight rationale
ADR-013 Findings Runtime Disabled ModeNull-pattern repository, 202/404 disabled contract
ADR-017 Findings Runtime Score Recompute Async-MigrationIn-line recompute today; migration plan to async when the recompute-P95 alert fires

Determinism contract

Sprint SPRINT_20260501_043 (Group J determinism sweep, builds on Sprints 040 and 041).

The Findings ledger is content-addressed and append-only. Every row identity that participates in the chain hash, the Merkle anchor, or any persisted audit state is derived from canonical content via SHA-256 — there are no Guid.NewGuid() calls on the production path inside src/Findings/StellaOps.Findings.Ledger/. Two replays of the same input produce byte-identical rows.

The single source of truth for the key shapes is LedgerDeterministicIds. Each helper is unit-tested with a stability assertion, a distinctness assertion, and (for one site) a frozen-anchor literal in LedgerDeterministicIdsTests.

Row / eventHelperKey tuple
Airgap timeline impact event idAirgapTimelineEventId(tenantId, chainId, sequence)
Evidence-snapshot-linked event idEvidenceSnapshotEventId(tenantId, chainId, sequence, dsseDigest)
Decision (status-changed) event idDecisionEventId(tenantId, chainId, decisionId)
Decision id (when caller omits)DecisionIdFromContent(tenantId, alertId, actorId, occurredAt, decisionStatus, replayToken)
Attestation pointer idAttestationPointerId(tenantId, findingId, attestationDigest, attestationType)
Attestation pointer event idAttestationPointerEventId(tenantId, chainId, sequence, pointerId)
Merkle anchor idMerkleAnchorId(tenantId, rootHash, sequenceStart, sequenceEnd)
Snapshot idSnapshotId(tenantId, label, sequenceNumber, timestamp)
Runtime trace idRuntimeTraceId(tenantId, findingId, capturedAt, artifactDigest, componentPurl)

Bumping the v1 suffix in any of these keys invalidates existing rows. Pre-prod tolerates a wipe; do not change a key shape on a production ledger without a coordinated re-write.

Time provider injection

Every chain-write site that previously read the wall clock now flows through the constructor-injected TimeProvider. Tests use Microsoft.Extensions.Time.Testing.FakeTimeProvider (MIT, vendored as a package dependency) to fix the recorded time. recorded_at participates in some payload bodies, so freezing the clock is a prerequisite for the byte-identical replay invariant.

A small number of DateTimeOffset.UtcNow reads remain in the ledger project (12 STELLA0112 sites at the close of sprint 043). All of them are either purely observability (for example SnapshotService.ReplayEventsAsync’s ObservedAt metric tag) or operational policy timestamps that do not participate in the chain hash or in any persisted audit state (for example ExpireOldSnapshotsAsync’s sweep cutoff and PostgresSnapshotRepository’s updated_at columns). These will be addressed in a follow-up sprint that injects TimeProvider into the unTimeProvider’d services; they are tracked there.

Decision row contract change (Option A)

DecisionEvent.Id is now string? (nullable) and carries no default. Callers may either supply a content-derived id explicitly or leave it null and let DecisionService.RecordAsync derive one via LedgerDeterministicIds.DecisionIdFromContent(...). The WebService API path takes the latter route; the response and the audit-timeline read path use the populated id from the persisted record.

Signed VEX override anchoring

In non-Testing hosts the WebService wires RealVexOverrideAttestorAdapter, which signs VEX override decisions with a DSSE vex-override@v1 envelope (ES256; key source is PrivateKeyPem, else a stable seed-derived P-256 key from KeySeed) and stores the compact envelope in findings.vex_override_envelopes. When no key source is configured (Enabled=false, or both PEM and seed empty) the adapter degrades honestly to the UNSIGNED interim (PendingVexOverrideAttestorAdapter) rather than fabricating a signature. In Testing hosts a TestVexOverrideAttestorAdapter is used instead.

When a caller requests anchorToRekor=true, Findings calls Attestor’s transparency submission API (POST /api/v1/rekor/entries via the configured findings:ledger:VexOverrideAttestation:AttestorBaseUrl, which defaults to the internal http://attestor.stella-ops.local) instead of talking directly to public Rekor. Attestor’s primary transparency backend is local/offline and air-gap safe. The returned rekorEntryId (RekorEntryId) and rekorLogIndex (RekorLogIndex) DTO fields are compatibility names for the local transparency UUID and monotonic local log index returned by Attestor.

If the transparency submission path is unavailable, Findings fails the create request closed: the /v1/vex-decisions endpoint returns 503 problem+json with title: "local_transparency_unavailable" and a code extension carrying the underlying adapter error (local_transparency_submitter_unavailable when no submitter is configured, local_transparency_submit_failed on a non-2xx Attestor response, or local_transparency_receipt_missing when the receipt has no UUID/log index). A signed-but-unanchored override is not persisted for a request that explicitly required anchoring. The DSSE vex-override@v1 predicate type is https://stellaops.dev/predicates/vex-override@v1 (VexOverrideAttestationOptions.PredicateType); the compact envelope is stored in findings.vex_override_envelopes (migration 021), and the decision row carries the envelope digest and storage reference through signed_override, attestation_ref_id, attestation_ref_digest, and attestation_ref_storage (migration 010).