Signals Module Architecture

Overview

The Signals service (StellaOps.Signals) is the reachability and runtime-evidence plane. It ingests and serves the raw evidence signals that downstream scoring and policy consume: static call graphs, reachability facts, runtime facts (observed symbol execution from the runtime agent / deployment-observation agent), unknown symbols, execution evidence, and verification beacons. It owns the signals PostgreSQL schema, exposes an HTTP API under /signals (plus SCM/CI webhooks and a compatibility /api/v1/signals surface), and publishes signals.fact.updated.v1 events when reachability facts change.

The service also owns the Evidence-Weighted Score (EWS) and Unified Score runtime. Score/Endpoints/ScoreEndpoints.cs exposes the tenant-scoped /api/v1/score API, backed by ScoreEvaluationService and durable signals.score_history; the older UnifiedScore/ facade remains an in-process library rather than the endpoint handler. The EWS algorithm is documented under Evidence-Weighted Score (scoring library) below.

Audience: engineers producing or consuming reachability/runtime evidence, integrators of the EWS scoring library, and operators configuring durable storage and the SCM/CI webhook surface. For the scoring model in isolation see unified-score.md; for the wire contract see api-reference.md.

Module Purpose

Location

src/Signals/
├── StellaOps.Signals/                         # Web service (minimal-API host)
│   ├── Program.cs                             # Host + all /signals endpoints
│   ├── CompatibilityApiV1Endpoints.cs         # /api/v1/signals (static demo records)
│   ├── Scm/ScmWebhookEndpoints.cs             # /webhooks/{github,gitlab,gitea}
│   ├── Hosting/                               # SignalsRuntimeConfigurationValidator,
│   │                                          #   SignalsSealedModeMonitor, SignalsStartupState
│   ├── Models/                                # Callgraph*, ReachabilityFact*, RuntimeFacts*,
│   │                                          #   Unknown*, Beacon*, ExecutionEvidence* DTOs
│   ├── Persistence/                           # Repo interfaces + in-memory implementations
│   ├── Services/                              # Ingestion, scoring, events, retention, decay
│   ├── Storage/                               # FileSystem/RustFs callgraph + runtime-facts stores
│   ├── EvidenceWeightedScore/                 # EWS scoring library used by Score runtime
│   │   ├── EvidenceWeightedScoreCalculator.cs # also defines EvidenceWeightedScoreResult,
│   │   │                                      #   AppliedGuardrails, DimensionContribution
│   │   ├── EvidenceWeightedScoreInput.cs
│   │   ├── EvidenceWeightPolicy.cs            # weights, guardrails, BucketThresholds
│   │   ├── EvidenceWeightPolicyOptions.cs / IEvidenceWeightPolicyProvider.cs
│   │   ├── WeightManifest.cs / FileBasedWeightManifestLoader.cs / IWeightManifestLoader.cs
│   │   ├── ReachabilityInput.cs / RuntimeInput.cs / BackportInput.cs /
│   │   │   ExploitInput.cs / SourceTrustInput.cs / MitigationInput.cs / AnchorMetadata.cs
│   │   └── Normalizers/
│   │       ├── BackportEvidenceNormalizer.cs   ExploitLikelihoodNormalizer.cs
│   │       ├── MitigationNormalizer.cs         ReachabilityNormalizer.cs
│   │       ├── RuntimeSignalNormalizer.cs      SourceTrustNormalizer.cs
│   │       ├── IEvidenceNormalizer.cs          NormalizerAggregator.cs / INormalizerAggregator.cs
│   │       └── EvidenceNormalizersServiceCollectionExtensions.cs
│   ├── Score/                                 # Live /api/v1/score endpoints, contracts, services
│   └── UnifiedScore/                          # Older in-process facade over EWS
│       ├── UnifiedScoreService.cs / IUnifiedScoreService.cs / UnifiedScoreModels.cs
│       ├── UnknownsBandMapper.cs              ServiceCollectionExtensions.cs
│       └── Replay/                            # Deterministic replay verifier
├── StellaOps.Signals.RuntimeAgent/           # In-process runtime evidence agent (CLR EventPipe, eBPF)
├── StellaOps.Signals.Scheduler/              # Scheduler queue job client for rescans
├── __Libraries/
│   ├── StellaOps.Signals.Persistence/        # PostgreSQL repos + Migrations/*.sql (schema: signals)
│   └── StellaOps.Signals.Ebpf/               # eBPF micro-witness support
└── __Tests/
    └── StellaOps.Signals.Tests/              # Unit, property, integration, snapshot tests

Note: The doc previously listed EvidenceWeightedScoreResult.cs, Guardrails/ScoreGuardrails.cs, and Policy/EvidenceWeightPolicyProvider.cs as separate files. Those do not exist. The result record (EvidenceWeightedScoreResult) and guardrail types live inside EvidenceWeightedScoreCalculator.cs; the policy provider interface is IEvidenceWeightPolicyProvider.cs.

Evidence-Weighted Score (scoring library)

Status: The EWS calculator remains a reusable in-process library, and the Unified Score facade is also registered by AddScoreEvaluationServices() and exposed through the live /api/v1/score/* Signals API. The evidence dimensions below are populated from plain input DTOs (ReachabilityInput, RuntimeInput, BackportInput, ExploitInput, SourceTrustInput, MitigationInput) supplied by the caller — Signals does not hold direct project references to Policy, Concelier, Scanner, or Excititor (see Integration Points).

Evidence Dimensions

DimensionSymbolDescriptionInput DTO
ReachabilityRCHCode path reachability to vulnerable sinkReachabilityInput
RuntimeRTSLive observation strength (eBPF/dyld/ETW)RuntimeInput
BackportBKPPatch evidence from distro/changelog/binaryBackportInput
ExploitXPLExploit probability (EPSS + KEV)ExploitInput
Source TrustSRCVEX source trustworthinessSourceTrustInput
MitigationsMITActive protective controlsMitigationInput

Scoring Formula

The calculator (EvidenceWeightedScoreCalculator) supports several formula modes selected by policy/input: the standard weighted-sum, an advisory formula (raw = 0.25*cvss + 0.30*epss + 0.20*reachability + 0.10*exploit_maturity - 0.15*patch_proof), an attested-reduction mode, and a VEX override (authoritative not_affected/fixed forces score 0). The standard weighted-sum is:

Score = clamp01(W_rch*RCH + W_rts*RTS + W_bkp*BKP + W_xpl*XPL + W_src*SRC - W_mit*MIT) * 100

Note: MIT is subtractive — mitigations reduce risk. The result is an EvidenceWeightedScoreResult carrying the score, bucket, per-dimension DimensionContribution values, applied AppliedGuardrails, badge flags, a canonical digest, and CalculatedAt.

Guardrails

Hard caps and floors based on evidence conditions:

  1. Not-Affected Cap: If vendor says not-affected (BKP=1) and no runtime contradiction (RTS<0.6), cap at 15
  2. Runtime Floor: If strong live signal (RTS>=0.8), floor at 60
  3. Speculative Cap: If no reachability and no runtime (RCH=0, RTS=0), cap at 45

Score Buckets

BucketRangeMeaning
ActNow90-100Strong evidence of exploitable risk; immediate action
ScheduleNext70-89Likely real; schedule for next sprint
Investigate40-69Moderate evidence; investigate when touching component
Watchlist0-39Low/insufficient evidence; monitor

Architecture

Component Diagram

┌─────────────────────────────────────────────────────────────────┐
│                        Signals Module                           │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌─────────────────┐    ┌──────────────────────────────────┐   │
│  │ NormalizerAggr. │───▶│ EvidenceWeightedScoreCalculator  │   │
│  └────────┬────────┘    └──────────────┬───────────────────┘   │
│           │                            │                        │
│           ▼                            ▼                        │
│  ┌─────────────────┐    ┌──────────────────────────────────┐   │
│  │   Normalizers   │    │   Guardrails (AppliedGuardrails,  │   │
│  │ ┌─────┐ ┌─────┐ │    │   in EvidenceWeightedScoreCalc.)  │   │
│  │ │ BKP │ │ XPL │ │    └──────────────────────────────────┘   │
│  │ └─────┘ └─────┘ │                                           │
│  │ ┌─────┐ ┌─────┐ │    ┌──────────────────────────────────┐   │
│  │ │ MIT │ │ RCH │ │    │   IEvidenceWeightPolicyProvider   │   │
│  │ └─────┘ └─────┘ │                                           │
│  │ ┌─────┐ ┌─────┐ │                                           │
│  │ │ RTS │ │ SRC │ │                                           │
│  │ └─────┘ └─────┘ │                                           │
│  └─────────────────┘                                           │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
           │                          │
           ▼                          ▼
┌─────────────────────┐   ┌───────────────────────────────────────┐
│   Caller-supplied   │   │            Consumers                  │
│   input DTOs        │   │                                       │
│                     │   │ • In-process callers / tests          │
│ • ReachabilityInput │   │ • Unified Score facade                │
│ • RuntimeInput      │   │   (UnifiedScore/UnifiedScoreService)  │
│ • BackportInput     │   │                                       │
│ • ExploitInput      │   │ • Signals /api/v1/score API           │
│ • SourceTrustInput  │   │                                       │
│ • MitigationInput   │   │                                       │
└─────────────────────┘   └───────────────────────────────────────┘

The diagram above describes the EWS scoring library only. The Signals service runtime is described under HTTP API Surface and Persistence. NormalizerAggregator and the calculator are registered as part of the score-evaluation service. They still take caller-supplied DTOs; this wiring does not create direct project references to evidence producers.

Data Flow (EWS library)

  1. Caller assembles an EvidenceWeightedScoreInput from the per-dimension input DTOs.
  2. Normalizers (ReachabilityNormalizer, RuntimeSignalNormalizer, …) convert raw evidence to 0-1 values; NormalizerAggregator composes them.
  3. EvidenceWeightedScoreCalculator selects a formula mode and applies it against the active EvidenceWeightPolicy.
  4. Guardrails (caps/floors, encoded in the calculator + AppliedGuardrails) adjust the score.
  5. An EvidenceWeightedScoreResultis returned with score, bucket, per-dimension contributions, applied guardrails, badge flags, and a canonical digest.

HTTP API Surface

Signals is a minimal-API host (Program.cs). Endpoints under /signals are authorized per-request via Program.TryAuthorize against the scopes below (Authority bearer token, or X-Scopes header when Signals:Authority:AllowAnonymousFallback=true in local/test harnesses). Existing gated read and ingest endpoints await SignalsSealedModeMonitor and return 503 when installation posture is unavailable or required evidence is invalid. /signals/status remains readable and reports the result; score routes retain their own authorization and are not added to this gate. Several write endpoints emit audit events via .Audited(AuditModules.Signals, …).

Installation posture and sealed-mode evidence

Each existing admission awaits a read of airgap-seal:installation from the service’s own database replica. Admissions do not call Platform. The evidence requirement is the local Signals:AirGap:SealedMode:EnforcementEnabled option OR an observed seal; an observed unseal cannot disable the local requirement. Witnessed absence uses the local option. An unreadable, malformed, or never-converged replica refuses admission even when the local option is false.

When RequireEvidenceHealth is true, the existing evidence rule requires a configured file that exists and whose last-write time is within MaxEvidenceAge. The monitor does not parse the artifact body or verify a cryptographic signature. A valid file allows inbound and read operations during a seal. RequireEvidenceHealth=false retains its existing effect once posture is readable. Every admission checks posture before its file cache, and a cached success cannot extend the file’s age limit.

/signals/status returns sealedMode.enforced, compliant, reason, scope (installation), postureStatus (Declared, NotDeclared, or Unreadable), and the declaration version. The unavailable result fails closed with enforced=true; it does not assert that a seal was observed. /readyz also awaits the local reading. /healthz and Doctor routes keep their existing behavior.

The host composes the replica after the existing Doctor service identity. It uses a named, disposed pool from the already-resolved Signals connection, and registers Catalog and Eventing migrations once. Enable the background drain with Catalog:Replication:EnvironmentState:{Enabled,FeedBaseAddress,AuthTenant}. It requests only catalog:replicate using the existing identity and explicit token cache. The identity must already have the grant for that tenant; missing identity/cache or anonymous feed mode refuses startup. No scope or credential is provisioned automatically. The selected identity must also be able to authenticate while the installation is sealed. Before the first durable checkpoint, the gate reports unavailable; after convergence, local reads survive producer loss.

Health / discovery (anonymous)

MethodPathNotes
GET/healthzLiveness.
GET/readyzReadiness; 503 sealed-mode-blocked when sealed-mode non-compliant, else startup-state gated.
GET/openapi/v1.jsonOpenAPI 3.1 document, exposed anonymously for the gateway aggregator.
GET/api/v1/buildinfoImage provenance via MapBuildInfoEndpoint (verify aggregator).

Core /signals API

MethodPathScopePurpose
GET/signals/pingsignals:readLiveness probe (204).
GET/signals/statussignals:readService version + sealed-mode status.
POST/signals/callgraphssignals:writeIngest a call graph (CallgraphIngestRequest); returns 202 + callgraphId. Audited.
GET/signals/callgraphs/{callgraphId}signals:readFetch a stored callgraph document.
GET/signals/callgraphs/{callgraphId}/manifestsignals:readStream the callgraph manifest blob.
POST/signals/runtime-factssignals:writeIngest runtime facts (RuntimeFactsIngestRequest); returns 202 + subjectKey. Audited.
POST/signals/runtime-facts/ndjsonsignals:writeStream runtime facts as NDJSON (gzip-aware); metadata via query/headers. Audited.
POST/signals/runtime-facts/syntheticsignals:writeLocal-harness only — deterministic synthetic probe fixtures. Not mapped in production. Audited.
GET/signals/facts/{subjectKey}signals:readFetch the reachability fact for a subject.
POST/signals/reachability/unionsignals:writeIngest a reachability union graph (application/zip; X-Analysis-Id header). Audited.
GET/signals/reachability/union/{analysisId}/metasignals:readStream union meta.json.
GET/signals/reachability/union/{analysisId}/files/{fileName}signals:readStream a union graph file (json / ndjson).
POST/signals/reachability/recomputesignals:adminRecompute a reachability fact (ReachabilityRecomputeRequest). Audited.
POST/signals/unknownssignals:writeIngest unknown symbols (UnknownsIngestRequest); returns 202 + subjectKey. Audited.
GET/signals/unknownssignals:readQuery unknowns (band, limit≤1000, offset); paged.
GET/signals/unknowns/{subjectKey}signals:readUnknowns for a subject.
GET/signals/unknowns/{id}/explainsignals:readScore/band/normalization-trace explanation for one unknown.
POST/signals/execution-evidencesignals:writeBuild execution evidence (ExecutionEvidenceRequest); 202 / rate_limited / 422. Audited.
POST/signals/beaconssignals:writeIngest verification beacon events (BeaconIngestRequest). Audited.
GET/signals/beacons/rate/{artifactId}/{environmentId}signals:readBeacon verification rate for an artifact/environment pair.

Runtime observation ingest (agent)

Ownership ruled (Q-7, 2026-08-25) and EXECUTED (2026-09-01, SPRINT_20260824_010 P8-16): Signals is the ONE owner of runtime observations. The deployment agent’s dual-write to Findings.Ledger is deleted — this endpoint is the sole ingest path. Future cross-service consumers (Findings included) get a Signals event/read contract into their own projection per the DC-15 pattern, never a second producer write. P8-16 also closed the recorded ingest defect: the ingest now dedups on the agent-stamped eventId — the endpoint embeds it in the row’s contexts entry and the upsert’s DO UPDATE arm is containment-guarded against it — so a publisher retry replays the stable fact id instead of double-incrementing hit_count. No new state: the marker lives in the contexts JSONB the ingest already appends.

MethodPathAuthPurpose
POST/api/v1/runtime/observationsAnonymous (network-edge trust)SOLE runtime-observation ingest from the deployment-observation agent (Q-7). Dedups on the required eventId idempotency key, then idempotently upserts signals.runtime_facts tagged with release_id. Validates eventId present, tenantId/agentId are GUIDs and imageDigest is sha256:…. Returns 503 when durable PostgreSQL is not configured. Sprint 20260512_020 RO-RUN-003 + P8-16.

Unlike the /signals/* endpoints, this endpoint does not apply Program.TryAuthorize — the deployment-observation agent authenticates at the network edge (mTLS / backend networking), not via the Authority OAuth flow, and the tenant id is carried in the request body.

SCM/CI webhooks (anonymous; signature-validated)

See SCM/CI Integration (Webhooks). Endpoints /webhooks/{github,gitlab,gitea} are mapped via MapScmWebhookEndpoints and run the sealed-mode gate; authorization is by provider signature, not Authority scopes.

Compatibility API (/api/v1/signals)

MethodPathScopePurpose
GET/api/v1/signalssignals:read or orch:readList signal records (type/status/provider filters, cursor paging).
GET/api/v1/signals/statssignals:read or orch:readAggregate counts by type/status/provider, success rate, avg latency.

Truthfulness flag: CompatibilityApiV1Endpoints currently serves a hard-coded array of five static demo records (sig-001…sig-005), not data sourced from the signals schema. It exists so the gateway/orchestrator compatibility surface and orch:read consumers resolve, but it is not a live query of stored signals. Treat this as a placeholder until backed by real persistence.

Unified score API (/api/v1/score)

MapScoreEndpoints() exposes evaluation, persisted score lookup/history, weight manifests, explanation, replay-envelope retrieval, and deterministic replay verification. The group is tenant-required. All routes require signals.score.read (score.read); POST /evaluate also requires signals.score.evaluate (score.evaluate). See Unified Score and the generated API reference.

Authorization scopes

Defined in StellaOps.Signals.Routing.SignalsPolicies:

ScopeConstantUsed by
signals:readSignalsPolicies.ReadAll GET / read endpoints.
signals:writeSignalsPolicies.WriteIngest endpoints (callgraphs, runtime-facts, unknowns, beacons, execution-evidence, reachability union).
signals:adminSignalsPolicies.AdminPOST /signals/reachability/recompute.
score.readScorePolicies.ScoreRead (signals.score.read)All /api/v1/score/* routes.
score.evaluateScorePolicies.ScoreEvaluate (signals.score.evaluate)Additional requirement for POST /api/v1/score/evaluate.

Persistence (own database stellaops_signals, schema signals)

Signals owns the physical PostgreSQL database stellaops_signalsand, inside it, the signalsschema (SignalsDataSource.DefaultSchemaName). Note the two names differ: the database is stellaops_signals, the schema inside it is signals. The database lives on the shared installation server db.stella-ops.local with its own login role signals; the ownership boundary is that role plus REVOKE CONNECT on every sibling database, not a dedicated server (ADR-039 P1). Names, the owned migration chain and the declared table set all live in one place, SignalsSchemaTopology.

Connection resolution — one canonical source, and nothing else. STELLAOPS_POSTGRES_SIGNALS_CONNECTION (binding to Postgres:Signals:ConnectionString) is the only source SignalsConnectionResolver reads. A value that is present but blank throws rather than being treated as unconfigured — an empty string is what a failed ${VAR} substitution leaves, and continuing would drop the host into the in-memory harness while reporting healthy. There is no generic shared-connection fallback: ConnectionStrings:Default is read by no project under src/Signals/**.

Retired 2026-08-23 (SPRINT_20260722_020 W3-06 M5). Four pre-window compatibility sources — ConnectionStrings:Signals, Signals:Postgres:ConnectionString and their Signals__Postgres__ConnectionString / Postgres__Signals__ConnectionString environment forms — were accepted until the M4 window so the pre-window image kept booting. They are no longer read. They survive in SignalsSchemaTopology.RetiredConnectionSources as names only, so the retirement is pinned by test rather than asserted here; a host that supplies one of them and nothing else now fails closed.

Per repo policy [§2.7] the service auto-migrates on startup: AddSignalsPersistence calls AddStartupMigrations("signals", "Signals.Persistence", typeof(SignalsDataSource).Assembly), and the migration SQL files are embedded resources (Migrations\**\*.sql, excluding Migrations\_archived\**). This assembly is the only migration authority over the schema — platform-web’s central SignalsMigrationModulePlugin was removed by SPRINT_20260703_001 PAC-6, and the MigrationModuleRegistry / stella system migrations-run mechanism that could have registered Signals was itself deleted on 2026-09-14 (SPRINT_20260722_021 PLT-4).

When no connection string is present at all, durable repos are replaced with InMemory* implementations only in local-harness mode (otherwise startup throws). That escape hatch is gated by Signals:LocalHarness:PersistenceFallback:Enabled, which is ANDed with the environment-gated Signals:LocalHarness:Enabled umbrella, so it can only narrow; the sibling Signals:LocalHarness:RelaxRuntimeValidation:Enabled gates the Authority/cache/artifact-storage validator relaxations independently.

Migrations

FileAdds
001_v1_signals_baseline.sqlCollapsed v1 baseline for callgraphs, reachability, runtime evidence/agents, deployment references, metrics, and unknowns. The pre-v1 source migrations remain archived history.
002_v1_score_history.sqlTenant-scoped score_history persistence and lookup indexes for the unified score API.

Tables (by responsibility)

DomainTables
Scan / artifact intakescans, artifacts
Static call graph (relational)cg_nodes, cg_edges, entrypoints, symbol_component_map, func_nodes, call_edges, cve_func_hits
Canonical callgraph documentscallgraphs
Reachabilityreachability_components, reachability_findings, reachability_facts
Runtime evidenceruntime_samples, runtime_agents, runtime_facts, agent_heartbeats, agent_commands
Deployment / graph metricsdeploy_refs, graph_metrics
Unknown symbolsunknowns
Unified scorescore_history

Repository interfaces live under StellaOps.Signals/Persistence/ (with InMemory* doubles) and PostgreSQL implementations under StellaOps.Signals.Persistence/Postgres/Repositories/ (PostgresCallgraphRepository, PostgresReachabilityFactRepository, PostgresUnknownsRepository, PostgresReachabilityStoreRepository, PostgresDeploymentRefsRepository, PostgresGraphMetricsRepository, PostgresCallGraphQueryRepository, PostgresCallGraphProjectionRepository, PostgresRuntimeObservationRepository).

Artifact storage (blobs)

Callgraph and runtime-facts blobs are stored separately from the relational schema via a content-addressed artifact store selected by Signals:Storage:Driver:

Events

When a reachability fact is created/updated, Signals publishes an event built by ReachabilityFactEventBuilder (signals.fact.updated@v1 payload). The publisher is selected by Signals:Events:Driver:

The default topic is signals.fact.updated.v1 (overridable via Signals:AirGap:EventTopic).

Background workers & scheduler

Production Runtime Boundary

Signals is fail-closed outside explicit local harness mode:

Runtime-facts production storage (Sprint 20260502_013 G9-RFS)

Production hosts MUST use the RustFS-compatible object-store driver for runtime-facts artifacts. The local filesystem driver is a development-only escape hatch (the container filesystem is ephemeral, non-shared across replicas, and not durable across redeploys). SignalsRuntimeConfigurationValidator rejects the filesystem driver (options.Storage.IsFileSystemDriver(), i.e. Signals:Storage:Driver=filesystem) outside of Development, Testing, TestingLocalHarness, or the explicit Signals:LocalHarness:Enabled=true opt-in.

Truthfulness note: the validator’s error message string reads "Signals artifact storage Provider=Filesystem is not permitted in Production. Set Signals:ArtifactStorage:Provider=RustFs and configure RustFs:Endpoint.". Those config keys (Signals:ArtifactStorage:Provider, RustFs:Endpoint) are stale in the message text — the options class actually bound is SignalsArtifactStorageOptions, whose keys are Signals:Storage:Driver (filesystem/rustfs) and Signals:Storage:RustFs:BaseUrl. Use the keys in the table below; the validator message is misleading and should be corrected in code.

The RustFS driver is implemented in RustFsRuntimeFactsArtifactStore. Required configuration keys:

KeyPurpose
Signals:Storage:Driver=rustfsSelects the RustFS-backed binding.
Signals:Storage:BucketNameBucket containing the runtime-facts/... prefix (default signals-data).
Signals:Storage:RootPrefixObject-key prefix under the bucket for callgraph artifacts (default callgraphs).
Signals:Storage:RuntimeFactsRootPrefixObject-key prefix under the bucket for runtime-facts artifacts (default runtime-facts).
Signals:Storage:RustFs:BaseUrlAbsolute HTTP(S) endpoint for the RustFS API (e.g. http://rustfs:9000/api/v1/).
Signals:Storage:RustFs:ApiKey + :ApiKeyHeaderAuthentication credentials applied to every request (default header X-API-Key).
Signals:Storage:RustFs:TimeoutPer-request timeout (default 60s; bounded so 5xx propagates rather than hanging).
Signals:Retention:RuntimeFactsTtlHoursDrives the per-object retention header (X-RustFS-Retain-Seconds). Default 720h. The store also tags every upload X-RustFS-Immutable=true and X-RustFS-Retain-Seconds=<TTL>.

Operational guarantees:

Coverage lives in RustFsRuntimeFactsIntegrationTests (production-shape round-trip, fail-closed on misconfigured endpoint, fail-closed on 5xx) and RuntimeFactsRetentionRustFsTests (delete-by-digest + structured telemetry on 503). The shared object-store CAS pattern is mirrored in BinaryIndex Fingerprints; see docs/modules/binary-index/architecture.md.

EWS weight manifests

EWS weight policies are versioned JSON manifests loaded by FileBasedWeightManifestLoader from etc/weights/*.json (keyed by version, e.g. ews.v1). Versioning supports reproducibility — a computed score carries the policy digest. The default weights/guardrails (below) match EvidenceWeights.Default and BucketThresholds.Default / guardrail defaults in EvidenceWeightPolicy:

{
  "version": "ews.v1",
  "weights": { "rch": 0.30, "rts": 0.25, "bkp": 0.15, "xpl": 0.15, "src": 0.10, "mit": 0.10 },
  "guardrails": {
    "not_affected_cap": { "enabled": true, "max_score": 15 },
    "runtime_floor":    { "enabled": true, "min_score": 60 },
    "speculative_cap":  { "enabled": true, "max_score": 45 }
  }
}

Do not confuse these EWS weight manifests with the Signals:Scoring config section in etc/signals.yaml. The latter configures the reachability confidence scorer (ReachableConfidence, UnreachableConfidence, RuntimeBonus, MaxConfidence, MinConfidence) used by ReachabilityScoringService, not the EWS dimension weights.

Service configuration (etc/signals.yaml)

Top-level sections under Signals: (see SignalsOptions and etc/signals.yaml.sample):

SectionPurpose
AuthorityOIDC resource-server config (Enabled, Issuer, Audiences, RequiredScopes, AllowAnonymousFallback, BypassNetworks, …).
PostgresDurable persistence connection string (resolved alongside Postgres:Signals:ConnectionString).
StorageArtifact-store driver (filesystem/rustfs), RootPath, BucketName, RuntimeFactsRootPrefix, RustFs:*.
ScoringReachability confidence parameters (see above).
CacheRedis/Valkey reachability-fact cache (ConnectionString, DefaultTtlSeconds).
EventsEvent publishing (Enabled, Driver = redis/router/inmemory, Router:*).
AirGapAir-gap event topic override.
OpenApi, Retention, ScmOpenAPI doc, runtime-facts retention TTL, SCM webhook options.

Integration Points

Truthfulness note: StellaOps.Signals.csproj holds no project references to Policy, Concelier, or Excititor. It does reference the lean Scanner public-verdict contract and the shared StellaOps.Reachability.Core foundation so verdict vocabulary and default risk weights are consumed rather than redefined. The EWS calculator itself still consumes caller-supplied DTOs, and operational evidence arrives over HTTP.

Inbound (how evidence reaches Signals)

ProducerDataMechanism
Scanner / reachability toolingCall graphs, reachability union graphsPOST /signals/callgraphs, POST /signals/reachability/union
Runtime agent (StellaOps.Signals.RuntimeAgent)Observed symbol executionsPOST /signals/runtime-facts / …/ndjson
Deployment-observation agentRuntime observations (per release)POST /api/v1/runtime/observations (anonymous, network-edge auth)
Unknown-symbol producersUnknown symbolsPOST /signals/unknowns
CI/CD evidenceExecution evidence, verification beaconsPOST /signals/execution-evidence, POST /signals/beacons
SCM/CI providersPush/PR/release/pipeline eventsPOST /webhooks/{github,gitlab,gitea}
EWS library callers (in-process)Per-dimension *Input DTOsIEvidenceWeightedScoreCalculator.Calculate(...)

Direct project references: StellaOps.Auth.* (Authority), StellaOps.Messaging / StellaOps.Router.Transport.Messaging (events/router), StellaOps.Infrastructure.Postgres (migrations), StellaOps.Audit.Emission, StellaOps.Cryptography, StellaOps.Canonical.Json, and the local StellaOps.Signals.Persistence / StellaOps.Signals.RuntimeAgent libraries.

Outbound (Consumers)

ConsumerUsage
Stella Router / event subscriberssignals.fact.updated.v1 reachability-fact-change events (redis or router driver)
Scheduler (StellaOps.Signals.Scheduler)Unknowns rescan jobs via SchedulerQueueJobClient
Gateway / orchestrator compatibility/api/v1/signals + /api/v1/signals/stats (currently static demo data)
Backend Router / score clientsTenant-scoped /api/v1/score/* evaluation, lookup, history, explain, weights, replay, and verify.
Audit pipelineAudit events on ingest/recompute via StellaOps.Audit.Emission

Determinism

Signals maintains strict determinism in its scoring and evidence digests:

  1. Same inputs + same policy = same score: No randomness, no time-dependent factors in the EWS formula. AddDeterminismDefaults() is wired in Program.cs, and time-dependent values are explicitly annotated (// Determinism:Allowed).
  2. Policy versioning: Each weight manifest is versioned; the EvidenceWeightedScoreResult carries a canonical digest (WithComputedDigest()).
  3. Reproducible replay: UnifiedScore/Replay/IReplayVerifier re-derives scores for verification.
  4. Canonical fact digests: ReachabilityFactDigestCalculator produces stable digests over reachability facts; runtime-facts artifacts are content-addressed (BLAKE3-256).

The performance targets that previously appeared here (single calc <10ms, batch <1s, cache hit >80%, etc.) were aspirational and not codified in a benchmark in this module; they have been removed pending a real perf harness.

Testing Strategy

Tests live in the single project __Tests/StellaOps.Signals.Tests/, grouped by feature subdirectory (not by Properties/Integration/Snapshots folders as earlier drafts claimed):

FocusLocation (examples)
EWS calculator, normalizers, determinism, property testsEvidenceWeightedScore/ (EvidenceWeightedScoreCalculatorTests, …PropertyTests, …DeterminismTests, Normalizers/)
Unified score + replayUnifiedScore/
Callgraph / reachability ingestion & scoringCallgraphIngestionServiceTests, ReachabilityScoringServiceTests, ReachabilityUnionIngestionServiceTests, ReachabilityLatticeTests
Runtime facts (incl. batch, provenance, stack frames)RuntimeFactsIngestionServiceTests, RuntimeFactsBatchIngestionTests, RuntimeFactsProvenanceNormalizerTests, RustFsRuntimeFactsArtifactStoreTests
Unknowns scoring & decayUnknownsScoringServiceTests, UnknownsDecayServiceTests, UnknownsScoringIntegrationTests
Host / config / durabilitySignalsDurableHostTests, SignalsRuntimeConfigurationValidationTests, ProgramCompatibilityTests
SCM webhooksScm/

Earlier drafts linked a 24-Dec-2025 Evidence-Weighted Score Model advisory, SPRINT_8200_0012_* plans, and per-module pages (policy/confidence-scoring.md, concelier/backport-detection.md, scanner/epss-enrichment.md, excititor/trust-vector.md). Those links were not verified to exist at the stated paths and the cross-module “direct integration” they implied is not present in the build graph; they have been dropped in favour of in-module companions above.


SCM/CI Integration (Webhooks)

The Signals module also handles webhook ingestion from SCM (Source Code Management) and CI (Continuous Integration) providers. This enables:

Location

src/Signals/StellaOps.Signals/Scm/
├── Models/
│   ├── NormalizedScmEvent.cs      # Provider-agnostic event payload
│   ├── ScmEventType.cs            # Event type enumeration
│   └── ScmProvider.cs             # Provider enumeration
├── Webhooks/
│   ├── IWebhookSignatureValidator.cs
│   ├── GitHubWebhookValidator.cs  # HMAC-SHA256 validation
│   ├── GitLabWebhookValidator.cs  # Token-based validation
│   ├── GiteaWebhookValidator.cs   # HMAC-SHA256 validation
│   ├── IScmEventMapper.cs
│   ├── GitHubEventMapper.cs       # GitHub -> NormalizedScmEvent
│   ├── GitLabEventMapper.cs       # GitLab -> NormalizedScmEvent
│   └── GiteaEventMapper.cs        # Gitea -> NormalizedScmEvent
├── Services/
│   ├── IScmWebhookService.cs
│   ├── ScmWebhookService.cs       # Orchestrates validation + mapping
│   ├── IScmTriggerService.cs
│   └── ScmTriggerService.cs       # Routes events to Scanner/Orchestrator
└── ScmWebhookEndpoints.cs         # Minimal API webhook endpoints

Supported Providers

ProviderWebhook EndpointSignature HeaderValidation
GitHub/webhooks/githubX-Hub-Signature-256HMAC-SHA256
GitLab/webhooks/gitlabX-Gitlab-TokenToken match
Gitea/webhooks/giteaX-Gitea-SignatureHMAC-SHA256

Event Types

Event TypeDescriptionTriggers
PushCode push to branchScan (main/release branches)
PullRequestOpenedPR opened—
PullRequestMergedPR mergedScan
ReleasePublishedRelease createdScan
ImagePushedContainer image pushedScan
PipelineSucceededCI pipeline completedScan
SbomUploadedSBOM artifact uploadedSBOM ingestion

Webhook Payload Normalization

All provider-specific payloads are normalized to NormalizedScmEvent:

public sealed record NormalizedScmEvent
{
    public required string EventId { get; init; }
    public ScmProvider Provider { get; init; }
    public ScmEventType EventType { get; init; }
    public DateTimeOffset Timestamp { get; init; }
    public required ScmRepository Repository { get; init; }
    public ScmActor? Actor { get; init; }
    public string? Ref { get; init; }
    public string? CommitSha { get; init; }
    public ScmPullRequest? PullRequest { get; init; }
    public ScmRelease? Release { get; init; }
    public ScmPipeline? Pipeline { get; init; }
    public ScmArtifact? Artifact { get; init; }
    public IReadOnlyDictionary<string, object?>? RawMetadata { get; init; }
    public string? TenantId { get; init; }
    public string? IntegrationId { get; init; }
}

The full ScmEventType enum is broader than the trigger table above — it also includes PullRequest, PullRequestClosed, TagCreated, RefCreated, RefDeleted, PipelineStarted, PipelineCompleted, PipelineFailed, and ArtifactPublished. Only the subset listed in the table currently triggers downstream work.

Trigger Routing

ScmTriggerService.ShouldTriggerScan triggers a scan for: Push to a main/release branch (main, release/*, release-*), PullRequestMerged, ReleasePublished, ImagePushed, and PipelineSucceeded. SBOM ingestion is triggered for SbomUploaded (and artifact events carrying SBOM content).

Security

Webhook secrets are resolved from Signals:Scm options — IntegrationSecrets[integrationId], then ProviderSecrets[provider], then DefaultSecret. When no secret is configured, the request is rejected unless Signals:Scm:AllowUnsignedWebhooks=true.

Truthfulness note: the previous draft claimed AuthRef-managed secrets and built-in rate limiting for webhooks. Neither is implemented in src/Signals/StellaOps.Signals/Scm/ as of 2026-05-29 — secrets come from Signals:Scm config (above), and there is no rate limiter in the SCM webhook path. These claims have been removed pending implementation.