Signals Module Architecture
Verified against commit
942a18727con 2026-09-26 (UTC). Re-verify:git grep -n "AddHostedService\|AddSignalsPostureReplication\|MapGroup" -- src/Signals/StellaOps.Signalsandls src/Signals/__Libraries/StellaOps.Signals.Persistence/Migrations.
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
- Ingest & serve reachability evidence: Call graphs, reachability facts, runtime facts, unknown symbols, execution evidence, beacons — keyed by callgraph/scan/subject identifiers.
- Own durable evidence storage: The
signalsPostgreSQL schema (auto-migrated on startup) plus a content-addressed artifact store (filesystem, RustFS or S3) for callgraph and runtime-facts blobs. - Score deterministically: Combine normalized evidence dimensions into a single 0-100 score via the EWS / Unified Score calculators; same inputs + policy = same score, always.
- Provide transparency: Decomposable scores with per-dimension contributions, applied guardrails, and explanations.
- Fail closed: Outside explicit local-harness mode, require durable PostgreSQL, an explicit cache endpoint, Authority authentication, and (in production) an object-store artifact driver (
s3orrustfs). See Production Runtime Boundary.
Location
src/Signals/
├── StellaOps.Signals/ # Web service (minimal-API host)
│ ├── Program.cs # Host + all /signals endpoints
│ ├── CompatibilityApiV1Endpoints.cs # /api/v1/signals (static records)
│ ├── Scm/ScmWebhookEndpoints.cs # /webhooks/{github,gitlab,gitea}
│ ├── Score/ # Live /api/v1/score endpoints, contracts, services
│ ├── UnifiedScore/ # Older in-process facade over EWS
│ ├── Hosting/ # runtime validator, sealed-mode monitor, posture replication
│ ├── Doctor/ # Signals Doctor checks
│ ├── Models/ Options/ Parsing/ Routing/ Lattice/ Authentication/
│ ├── Persistence/ # Repository interfaces + in-memory doubles
│ ├── Services/ # Ingestion, scoring, events, retention, decay
│ └── Storage/ # FileSystem/RustFS/S3 callgraph + runtime-facts stores
├── __Libraries/
│ ├── StellaOps.Signals.Persistence/ # PostgreSQL repos + Migrations/*.sql (schema: signals)
│ ├── StellaOps.Signals.EvidenceWeightedScore/ # EWS calculator, inputs, normalizers, weight manifests
│ ├── StellaOps.Signals.Ebpf/ # eBPF parsers/models (simulated loader; no host composes it)
│ └── StellaOps.Signals.Ebpf.Contracts/
└── __Tests/
├── StellaOps.Signals.Tests/
├── StellaOps.Signals.Persistence.Tests/
└── StellaOps.Signals.Ebpf.Tests/
Withdrawn 2026-09-27 (SPRINT_20260927_003 PCD-44). The MVC controllers
HotSymbolsController,RuntimeAgentControllerandRuntimeFactsControllerwere compiled but never served (Program.csmaps no controllers), nothing implementedIHotSymbolRepository, and the/api/v1/agentsbase is Release Orchestrator’s agent surface. They are withdrawn tosrc/__Obsoleted/Signals/StellaOps.Signals/PCD-44/, and their routes answer404.SignalsWithdrawnSurfaceTestspins that the Signals assembly declares no MVC controller.
Withdrawn 2026-09-29 (SPRINT_20260928_001 PCD-159). The
StellaOps.Signals.RuntimeAgentlibrary (CLR runtime-agent abstractions:RuntimeAgentBase,DotNetEventPipeAgent,AgentRegistrationService,RuntimeFactsIngestService) and its test project were referenced by no deploying host.DotNetEventPipeAgentnever started EventPipe collection, and the ingest service only buffered events in memory. Both projects are frozen atsrc/__Obsoleted/Signals/(SignalsRuntimeAgentWithdrawalTests). Runtime facts reach Signals over HTTP (see Inbound below).
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
| Dimension | Symbol | Description | Input DTO |
|---|---|---|---|
| Reachability | RCH | Code path reachability to vulnerable sink | ReachabilityInput |
| Runtime | RTS | Live observation strength (eBPF/dyld/ETW) | RuntimeInput |
| Backport | BKP | Patch evidence from distro/changelog/binary | BackportInput |
| Exploit | XPL | Exploit probability (EPSS + KEV) | ExploitInput |
| Source Trust | SRC | VEX source trustworthiness | SourceTrustInput |
| Mitigations | MIT | Active protective controls | MitigationInput |
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:
- Not-Affected Cap: If vendor says not-affected (BKP=1) and no runtime contradiction (RTS<0.6), cap at 15
- Runtime Floor: If strong live signal (RTS>=0.8), floor at 60
- Speculative Cap: If no reachability and no runtime (RCH=0, RTS=0), cap at 45
Score Buckets
| Bucket | Range | Meaning |
|---|---|---|
| ActNow | 90-100 | Strong evidence of exploitable risk; immediate action |
| ScheduleNext | 70-89 | Likely real; schedule for next sprint |
| Investigate | 40-69 | Moderate evidence; investigate when touching component |
| Watchlist | 0-39 | Low/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.
NormalizerAggregatorand 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)
- Caller assembles an
EvidenceWeightedScoreInputfrom the per-dimension input DTOs. - Normalizers (
ReachabilityNormalizer,RuntimeSignalNormalizer, …) convert raw evidence to 0-1 values;NormalizerAggregatorcomposes them. - EvidenceWeightedScoreCalculator selects a formula mode and applies it against the active
EvidenceWeightPolicy. - Guardrails (caps/floors, encoded in the calculator +
AppliedGuardrails) adjust the score. - 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. The host provisions no scope or credential itself. 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.
In Compose the identity is the stellaops-signals-doctor bootstrap client, which Authority’s standard.yaml declares with client_credentials, tenant default, and the scopes platform:doctor:register catalog:replicate. Authority registers the client at start from STELLAOPS_SIGNALS_DOCTOR_CLIENT_SECRET. The drain and the Doctor identity are switched by SIGNALS_ENVIRONMENT_STATE_REPLICA_ENABLED and SIGNALS_DOCTOR_REGISTRATION_ENABLED, which both default to off. The feed is Platform’s own catalog feed at http://platform.stella-ops.local/, and the tenant comes from SIGNALS_ENVIRONMENT_STATE_REPLICA_AUTH_TENANT (default default). The release bundle turns both switches on, and its installers generate the client secret. A fresh bundle install therefore configures the drain without a manual step. tools/scripts/validate/check-bundle-env-completeness.sh fails the bundle when that wiring is incomplete (check E).
Health / discovery (anonymous)
| Method | Path | Notes |
|---|---|---|
| GET | /healthz | Liveness. |
| GET | /readyz | Readiness; 503 sealed-mode-blocked when sealed-mode non-compliant, else startup-state gated. |
| GET | /openapi/v1.json | OpenAPI 3.1 document, exposed anonymously for the gateway aggregator. |
| GET | /api/v1/buildinfo | Image provenance via MapBuildInfoEndpoint (verify aggregator). |
Core /signals API
| Method | Path | Scope | Purpose |
|---|---|---|---|
| GET | /signals/ping | signals:read | Liveness probe (204). |
| GET | /signals/status | signals:read | Service version + sealed-mode status. |
| POST | /signals/callgraphs | signals:write | Ingest a call graph (CallgraphIngestRequest); returns 202 + callgraphId. Audited. |
| GET | /signals/callgraphs/{callgraphId} | signals:read | Fetch a stored callgraph document. |
| GET | /signals/callgraphs/{callgraphId}/manifest | signals:read | Stream the callgraph manifest blob. |
| POST | /signals/runtime-facts | signals:write | Ingest runtime facts (RuntimeFactsIngestRequest); returns 202 + subjectKey. Audited. |
| POST | /signals/runtime-facts/ndjson | signals:write | Stream runtime facts as NDJSON (gzip-aware); metadata via query/headers. Audited. |
| POST | /signals/runtime-facts/synthetic | signals:write | Local-harness only — deterministic synthetic probe fixtures. Not mapped in production. Audited. |
| GET | /signals/facts/{subjectKey} | signals:read | Fetch the reachability fact for a subject. |
| POST | /signals/reachability/union | signals:write | Ingest a reachability union graph (application/zip; X-Analysis-Id header). Audited. |
| GET | /signals/reachability/union/{analysisId}/meta | signals:read | Stream union meta.json. |
| GET | /signals/reachability/union/{analysisId}/files/{fileName} | signals:read | Stream a union graph file (json / ndjson). |
| POST | /signals/reachability/recompute | signals:admin | Recompute a reachability fact (ReachabilityRecomputeRequest). Audited. |
| POST | /signals/unknowns | signals:write | Ingest unknown symbols (UnknownsIngestRequest); returns 202 + subjectKey. Audited. |
| GET | /signals/unknowns | signals:read | Query unknowns (band, limit≤1000, offset); paged. |
| GET | /signals/unknowns/{subjectKey} | signals:read | Unknowns for a subject. |
| GET | /signals/unknowns/{id}/explain | signals:read | Score/band/normalization-trace explanation for one unknown. |
| POST | /signals/execution-evidence | signals:write | Build execution evidence (ExecutionEvidenceRequest); 202 / rate_limited / 422. Audited. |
| POST | /signals/beacons | signals:write | Ingest verification beacon events (BeaconIngestRequest). Audited. |
| GET | /signals/beacons/rate/{artifactId}/{environmentId} | signals:read | Beacon verification rate for an artifact/environment pair. |
Callgraph manifest retrieval (PCD-278). Verified against source fdd7ad182e. GET /signals/callgraphs/{callgraphId}/manifest reads by the persisted artifact hash through ICallgraphArtifactStore.GetManifestAsync. The configured filesystem, RustFS or S3 driver returns the written manifest bytes as application/json; the response owns and disposes the returned stream. The handler interprets no storage path. An unknown graph or missing manifest metadata retains an empty 404; a missing stored manifest retains JSON 404 {"error":"manifest not found"}. Authorization and installation posture are checked before reading the store, with the existing empty 401/403 and sanitized JSON 503 refusals. The OpenAPI document declares these outcomes and the successful CallgraphManifest schema. Reverify with pwsh tools/scripts/test-targeted-xunit.ps1 -Project src/Signals/__Tests/StellaOps.Signals.Tests/StellaOps.Signals.Tests.csproj -Class '*SignalsCallgraphManifestRouteTests' -BuildProjectReferences -Restore.
Unknown callgraph on runtime-facts ingest. A batch that carries observed facts triggers a reachability recompute against the callgraph it names. When Signals does not hold that callgraph, POST /signals/runtime-facts and POST /signals/runtime-facts/ndjson refuse the batch before anything is stored and answer 404 with {"error": "...", "code": "callgraph_not_found", "callgraphId": "<id>"}. The batch can never succeed until the callgraph is uploaded, so publishers treat it as a rejection, not a retry. A batch with only synthetic facts does not trigger a recompute and is not checked.
Runtime-facts event tenant (PCD-865). The tenant of the runtime.updated event that an observed batch emits is the caller’s authenticated tenant: the stellaops:tenant claim of the bearer token, resolved by the tenant middleware. Request metadata never selects it, and there is no default fallback.
POST /signals/runtime-factsandPOST /signals/runtime-facts/syntheticrefuse a request whosemetadatanames a tenant other than the caller’s (keystenant_id,tenantandsubject.tenant; compared case-insensitively). The refusal is the empty403of the refusal contract below, returned before anything is stored or published. A caller without an authenticated tenant may not name a tenant in metadata at all.POST /signals/runtime-facts/ndjsoncarries no metadata; its event carries the caller’s tenant. Before PCD-865 every NDJSON upload emittedruntime.updatedfordefault.- A request without an authenticated tenant (a token without the claim, or the development header fallback) stores its facts and runs the reachability recompute, but emits no
runtime.updatedevent.
Not changed by PCD-865: the stored reachability fact is keyed by subject, not tenant, and the signals.fact.updated event still takes its tenant from the stored fact’s tenant or subject.tenant metadata, else Signals:Events:DefaultTenant. Reverify with pwsh tools/scripts/test-targeted-xunit.ps1 -Project src/Signals/__Tests/StellaOps.Signals.Tests/StellaOps.Signals.Tests.csproj -Class '*SignalsRuntimeFactsTenantBindingTests' -BuildProjectReferences.
Runtime-facts refusal contract (PCD-274). Verified against source a4e2be6fff. The JSON, NDJSON and local-harness synthetic POST operations declare the existing authorization and installation-posture refusals alongside their ingest outcomes:
| Status | Content type and body | Boundary |
|---|---|---|
| 401 | Empty body; no content type. | An unauthenticated request is refused. |
| 403 | Empty body; no content type. | An authenticated principal lacks signals:write, or (JSON and synthetic operations, PCD-865) the request metadata names a tenant other than the caller’s. |
| 503 | application/json, {"error":"sealed-mode evidence invalid","reason":"..."}. | Required installation posture/evidence is unavailable or invalid; reason is sanitized. |
| 400 | Handler validation uses application/json, {"error":"..."}. Framework binding may return an empty body before the handler. | Invalid ingest input. |
Authorization precedes the posture read for requests that reach these handlers; both refusals precede ingestion and storage. The synthetic operation is mapped only in a local harness, returns 202 on acceptance, and answers an unknown callgraph with its existing application/json 404 {"error":"callgraph not found."} body. The two observed-ingest 404 bodies retain the code and callgraphId fields above. Reverify the composed endpoint and generated OpenAPI contract with pwsh tools/scripts/test-targeted-xunit.ps1 -Project src/Signals/__Tests/StellaOps.Signals.Tests/StellaOps.Signals.Tests.csproj -Class '*SignalsRuntimeFactsResponseContractTests' -BuildProjectReferences -Restore.
Runtime observation ingest (agent)
Scanner’s reachability lane publishes its extracted snapshot through /signals/callgraphs before publishing unresolved edges through /signals/unknowns (PCD-345). The graph artifact uses the existing JSON contract and includes scan id, image digest and Scanner graph digest as metadata. The stored Signals id links the unknowns report to the accepted graph. Registered language keys include java, nodejs, javascript, dotnet, python, go, rust, ruby and php. No sibling database read is involved. Native recompute requires the stored graph id, scan subject, entrypoints and targets under signals:admin; the producer only needs signals:write aoc:verify. Unknowns pressure is computed from that subject’s persisted unknowns when recompute runs.
The local estate’s owning Signals profile selects the same s3 protocol driver as the release bundle, using its installed SeaweedFS endpoint and configured credential pointers. The store uses the signals-data bucket contract and converges a missing bucket on write. Reachability fact events use the existing Valkey connection through the redis publisher, with tenant default. A healthy process alone does not exercise these lazy dependencies: the PCD-345 runtime proof posts a graph, reads it, recomputes the scan, checks the persisted pressure and verifies the fact event. Its no-unknowns subject gives zero pressure on the same graph, while a writer token and anonymous reader are refused at their respective authorization boundaries.
Ownership ruled (Q-7, 2026-08-25) and EXECUTED (2026-09-01,
SPRINT_20260824_010P8-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-stampedeventId— the endpoint embeds it in the row’scontextsentry and the upsert’sDO UPDATEarm is containment-guarded against it — so a publisher retry replays the stable fact id instead of double-incrementinghit_count. No new state: the marker lives in thecontextsJSONB the ingest already appends.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /api/v1/runtime/observations | signals:write bearer token; tenant from the token (PCD-865) | SOLE runtime-observation ingest from the deployment-observation agent (Q-7). Writes for the token’s tenant (slug resolved to the canonical UUID through the tenants replica); a body tenantId that differs is refused 403 with nothing written. Dedups on the required eventId idempotency key, then idempotently upserts signals.runtime_facts tagged with release_id. Validates eventId present, agentId a GUID, imageDigest sha256:…. Returns 503 (Retry-After) while the tenants replica is disabled or cannot answer, and when durable PostgreSQL is not configured. Not exposed at the gateway. Sprint 20260512_020 RO-RUN-003 + P8-16. |
Authentication and tenant (PCD-865, owner ruling 2026-10-18). Unlike the
/signals/*endpoints, this route usesRequireAuthorization(signals:write)rather thanProgram.TryAuthorize, so the development header fallback does not apply. The deployment-observation agent mints an Authority client-credentials token (signals:write aoc:verify, for the tenant it is registered to) and posts to its configured Signals base address (Agent:RuntimeEvidence:SignalsBaseAddress, compose defaulthttp://signals.stella-ops.local). The token’sstellaops:tenantis a slug, whilesignals.runtime_facts.tenant_idand the envelope carry the install-specific tenant UUID, so Signals resolves the slug through its OWN replica of the Authority tenants catalog (Catalog:Replication:Tenants, the shared replica seam; it needs the host service identity withcatalog:replicate, the same one the environment-state replica uses). Refusals:401without a token;403withoutsignals:write, for a token without a tenant or with a tenant the replica does not hold as active, and for a bodytenantIdthat names another tenant;503withRetry-After: 30while the replica is disabled or cannot answer. Every refusal happens before anything is written.
An existing runtime agent ID also belongs to one tenant (PCD-865-C7). The agent upsert checks that ownership atomically under PostgreSQL’s conflict-row lock. A different tenant receives 403 with error: agent_not_authorized, before agent metadata or runtime facts change. An own-tenant update and event retry remain accepted. The request cannot transfer an existing agent ID to another tenant. Historical ownership reconciliation is an operator action based on the canonical ReleaseOrchestrator registration and its enrolled certificate, separate from observation ingestion. Preserve historical observations during that reconciliation.
Not exposed at the gateway (PCD-865, owner ruling 2026-10-18). The route carries
RouterPublicationExclusionMetadata: Signals does not publish it over the Router HELLO, so the gateway cannot resolve it, and the router dispatcher refuses it. Agents keep posting to Signals directly (http://signals.stella-ops.local). The gateway’s aggregated OpenAPI document (/openapi.json,/api/openapi/aggregate) still lists the path, because it embeds Signals’ own OpenAPI document; the gateway answers404(urn:stellaops:router:endpoint-not-found) for it (verified live 2026-10-18). Reverify withpwsh tools/scripts/test-targeted-xunit.ps1 -Project src/Signals/__Tests/StellaOps.Signals.Tests/StellaOps.Signals.Tests.csproj -Class '*SignalsRuntimeObservationTenantBindingTests','*SignalsRuntimeObservationGatewayExposureTests' -BuildProjectReferences.
For client provisioning, replica configuration, rollout order and forcing controls, see deployment-observation agent authentication.
The local option-A rollout was accepted on 2026-10-20: direct anonymous observations and observations naming a mismatched body tenant were refused; the enrolled agent and own-tenant runtime facts ingested, and runtime.updated carried the caller’s tenant. Source commits: agent-core 29b14d6c16709afd7774afa4bffb81fc3bbd29d0, Signals fe48a471274a4ed6474ee452c940818e1160ff81. See the acceptance receipt. PCD-865-C7 closed the separate agent-ID ownership issue on 2026-10-20, using clean source a99d4df9fb19cf50ec9f6096995a159afed5abeb. The deployed collision returned 403 without changing the agent or its observations. After canonical-owner verification, an operator changed only the historical agent row’s tenant; the real enrolled agent then ingested and replayed successfully. See the C7 live receipt. The other tenant-boundary follow-ups remain independently scoped.
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. They dispatch nothing: an event that asks for a scan or an SBOM ingest answers 501.
Compatibility API (/api/v1/signals)
| Method | Path | Scope | Purpose |
|---|---|---|---|
| GET | /api/v1/signals | signals:read or orch:read | List signal records (type/status/provider filters, cursor paging). |
| GET | /api/v1/signals/stats | signals:read or orch:read | Aggregate counts by type/status/provider, success rate, avg latency. |
Truthfulness flag:
CompatibilityApiV1Endpointscurrently serves a hard-coded array of five static demo records (sig-001…sig-005), not data sourced from thesignalsschema. It exists so the gateway/orchestrator compatibility surface andorch:readconsumers 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:
| Scope | Constant | Used by |
|---|---|---|
signals:read | SignalsPolicies.Read | All GET / read endpoints. |
signals:write | SignalsPolicies.Write | Ingest endpoints (callgraphs, runtime-facts, unknowns, beacons, execution-evidence, reachability union). |
signals:admin | SignalsPolicies.Admin | POST /signals/reachability/recompute. |
score.read | ScorePolicies.ScoreRead (signals.score.read) | All /api/v1/score/* routes. |
score.evaluate | ScorePolicies.ScoreEvaluate (signals.score.evaluate) | Additional requirement for POST /api/v1/score/evaluate. |
Each signals:* scope stands alone: a client holding signals:read only can call every read endpoint and is refused 403 on an ingest or admin endpoint. The /signals/* handlers check the one scope they need (Program.TryAuthorize), and Signals seeds no resource-server default scopes, so the named SignalsPolicies policies also require only their own scope (SignalsResourceScopeLeastPrivilegeTests). Before SPRINT_20260928_001 PCD-160 Signals seeded signals:read, signals:write and signals:admin as resource-server defaults, which ANDed all three into each named policy. A deployment that sets Signals:Authority:RequiredScopes adds those scopes to the named policies again; the Doctor policy (ops.health) and the two score policies never include resource-server defaults, so a score client needs score.read, plus score.evaluate for evaluation, and no signals:* scope.
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:ConnectionStringand theirSignals__Postgres__ConnectionString/Postgres__Signals__ConnectionStringenvironment forms — were accepted until the M4 window so the pre-window image kept booting. They are no longer read. They survive inSignalsSchemaTopology.RetiredConnectionSourcesas 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
| File | Adds |
|---|---|
001_v1_signals_baseline.sql | Collapsed 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.sql | Tenant-scoped score_history persistence and lookup indexes for the unified score API. |
003_v1_signals_retire_unused_tables.sql | Guarded forward retirement (RFC-12) of the eight baseline tables with no production writer: agent_commands, agent_heartbeats, artifacts, cve_func_hits, reachability_components, reachability_findings, runtime_samples, symbol_component_map. A populated table refuses convergence with SQLSTATE 55000 and keeps its rows. The uncalled prune_heartbeat_history routine and the cve_reachability_summary rollup that read them are dropped; refresh_analytics_views() now refreshes scan_stats_daily only. |
004_v1_signals_retention_classes.sql | Comment-only retention classes (RFC-12) for the 13 classified tables; see Retention classes. |
005_v1_signals_retire_graph_metrics.sql | Guarded forward retirement (RFC-12, owner ruling of 2026-09-23) of graph_metrics, which the unknowns centrality factor read and nothing wrote, together with high_centrality_nodes, the one view that read it. A populated table refuses convergence with SQLSTATE 55000 and keeps its rows. |
006_v1_signals_retire_unknowns_rescan_schedule.sql | Forward retirement (PCD-158, owner ruling of 2026-09-29) of the unused unknowns rescan columns next_scheduled_rescan, rescan_attempts and last_rescan_result and the index idx_unknowns_next_rescan. The unknown rows are kept. |
007_v1_signals_unknowns_retention_comment.sql | Comment-only (PCD-189): restates the unknowns retention class without the rescan schedule that 006 retired. 004 is applied and stays unchanged (ADR-004), so the converged comment is 007’s. |
Retention classes (RFC-12)
Every class is derived from the table’s writers and readers, stated by 004_v1_signals_retention_classes.sql (restated for unknowns by 007_v1_signals_unknowns_retention_comment.sql) and proved in docs/architecture/retention/p13-source-inventory.json.
| Class | Tables | Mechanism |
|---|---|---|
derived-rebuildable | scans, cg_nodes, cg_edges, entrypoints | Projected from the stored callgraph document by CallGraphSyncService; removed only with their scan header (ON DELETE CASCADE). |
derived-rebuildable | func_nodes, call_edges | Written by CallgraphIngestionService from the normalized document it stores; upserted by graph hash. |
source-of-truth | callgraphs, unknowns, runtime_facts, runtime_agents, score_history | No time-based deletion. unknowns is replaced per subject on a new report; the others are upserted or insert-only. |
cache-bounded | reachability_facts | RuntimeFactsRetentionService deletes documents whose computed_at is older than Signals:Retention:RuntimeFactsTtlHours (default 720). The SQL delete rechecks the expiry, so a document recomputed after selection survives. |
cache-bounded | deploy_refs | DeploymentRefsRetentionService deletes rows whose last_seen_at is older than Signals:Retention:DeploymentRefsRetentionDays (default 30, the window every reader counts; a shorter value is refused), in batches of DeploymentRefsDeleteBatchSize (1000) up to DeploymentRefsMaxBatchesPerCycle (100) per cycle. Registered only on durable hosts. |
| retired | graph_metrics | No producer anywhere in the estate; retired by 005_v1_signals_retire_graph_metrics.sql. The unknowns scorer reports its centrality factor as unavailable (see Unknowns centrality factor). |
Both sweeps run on Signals:Retention:CleanupIntervalMinutes (default 60) and stop with Signals:Retention:EnableAutoCleanup=false. The Doctor check doctor.signals.retention.overdue-rows counts rows more than one day past either window and reports disabled retention as a finding.
Tables (by responsibility)
| Domain | Tables |
|---|---|
| Scan projection header | scans |
| Static call graph (relational) | cg_nodes, cg_edges, entrypoints, func_nodes, call_edges |
| Canonical callgraph documents | callgraphs |
| Reachability | reachability_facts |
| Runtime evidence | runtime_agents, runtime_facts |
| Deployment references | deploy_refs |
| Unknown symbols | unknowns |
| Unified score | score_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, PostgresCallGraphQueryRepository, PostgresCallGraphProjectionRepository, PostgresRuntimeObservationRepository).
Unknowns producer
Scanner fills signals.unknowns (SPRINT_20260929_005 PCD-187; before it nothing outside Signals posted the route, so the table stayed empty outside tests). The scanner worker’s publish-signals-unknowns stage runs after prepare-reachability-inputs whenever Scanner:Worker:Reachability:SignalsBaseUrl is set, and posts the extracted call graph’s unresolved edges: a target that is not a node of the graph (missing-target-node), an edge the extractor explained as unknown (unresolved-callee), and reflection or dynamic calls (dynamic-call-target). The subject is the scan id with the image digest, so subjectKey is the scan id; callgraphId is the snapshot’s graph digest; entries are distinct, ordered ordinally and capped at 1000 per scan. The worker authenticates as stellaops-scanner-worker with a per-tenant signals:write aoc:verify token. A scan without a source tree has no call graph and posts nothing. Each post replaces the subject’s rows, the nightly decay pass scores them, and GET /signals/unknowns and the explain route serve them. The reachability unknowns-pressure term reads the same count, but only inside a recompute for the same subject, which needs a Signals call graph for it; no service posts call graphs to Signals today, so that term is still zero on a running stack. SignalsUnknownsContractSignalsSideTests boots the Production host on PostgreSQL and posts the request of the shared fixture src/__Tests/corpus/signals-unknowns-v1, which the Scanner worker’s stage test pins as exactly what the stage sends.
Unknowns centrality factor (unavailable)
The unknowns triage score (UnknownsScoringService) has five factors: popularity (P), exploit potential (E), uncertainty (U), centrality © and staleness (S). Signals has no source for C. Nothing in the estate computes call-graph centrality for Signals, and the table the scorer used to read, graph_metrics, had no writer and was retired by 005_v1_signals_retire_graph_metrics.sql. The scorer therefore records C as unavailable: normalizationTrace.unavailableFactors carries the key C with its reason, and centralityScore, degreeCentrality, betweennessCentrality, rawCentrality and normalizedCentrality are 0. The weight wC stays in the weight vector, so the other weights and the band thresholds keep their meaning. C contributes nothing to the score. Scores and bands are unchanged, because every durable host already computed C as 0. Graph’s own centrality engine writes the graph schema, which Signals does not read.
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:
filesystem(default) →FileSystemCallgraphArtifactStore/FileSystemRuntimeFactsArtifactStore(development-only; container filesystem is ephemeral).rustfs→RustFsCallgraphArtifactStore/RustFsRuntimeFactsArtifactStore(RustFS HTTP object API,/buckets/{bucket}/objects/{key}with an API-key header).s3→S3CallgraphArtifactStore/S3RuntimeFactsArtifactStore(S3 protocol, path-style, SigV4; the release bundle runs it against its SeaweedFS endpoint).
Production requires s3 or rustfs — see Runtime-facts production storage. Both object-store drivers write the same object keys and CAS URIs.
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:
redis→RedisEventsPublisherrouter→RouterEventsPublisher(HTTP to the Stella Router)inmemory→InMemoryEventsPublisher(local-harness only)- disabled (
Signals:Events:Enabled=false) →NullEventsPublisher
The default topic is signals.fact.updated.v1 (overridable via Signals:AirGap:EventTopic).
Background workers & scheduler
NightlyDecayWorker(hosted service) runs the unknowns decay pass (UnknownsDecayService, configured viaUnknownsDecayOptions).RuntimeFactsRetentionServiceand, on durable hosts,DeploymentRefsRetentionServicerun the retention sweeps (see Retention classes).- Withdrawn 2026-09-27 (SPRINT_20260927_003 PCD-44):
UnknownsRescanWorker, its orchestrators, job-client seam, metrics andSignals:UnknownsRescanoptions, andStellaOps.Signals.Scheduler(SchedulerQueueJobClient, which enqueued into JobEngine’sScheduler.Queueimplementation, a cross-service build edge). None was ever registered. - Retired 2026-09-29 (SPRINT_20260928_001 PCD-158): the unknowns rescan schedule. Scoring no longer stamps a rescan time;
nextScheduledRescan,rescanAttemptsandlastRescanResultleave the model and the API, the three rescan-cadence options leaveUnknownsScoringOptions,GetDueForRescanAsyncleavesIUnknownsRepository, and006_v1_signals_retire_unknowns_rescan_schedule.sqldrops the columns and their index. The owner ruled the capability not needed: nothing read the fields, no JobEngine run request can target an image digest, and no producer in the estate posted unknowns at the time (SignalsUnknownsRescanScheduleRemovalTests). Scanner became the producer on 2026-10-02 (PCD-187, Unknowns producer).
Production Runtime Boundary
Signals is fail-closed outside explicit local harness mode:
STELLAOPS_POSTGRES_SIGNALS_CONNECTION(keyPostgres:Signals:ConnectionString) is required. Non-durable repositories are available only when the host runs asTestingLocalHarness, or whenSignals:LocalHarness:Enabled=trueis set in Development/Testing.Signals:Authority:Enabled=trueis required, andSignals:Authority:AllowAnonymousFallback=falseis required. Header/anonymous fallback is only for explicit local and test harnesses. Production startup throws (viaStellaOps.Signals.Hosting.SignalsRuntimeConfigurationValidator, which implements the sharedIRuntimeConfigurationValidatorcontract introduced in Sprint 20260513_018 URCV-002) if either constraint is violated; the validator short-circuits only forDevelopment,Testing,TestingLocalHarness, or the explicitSignals:LocalHarness:Enabled=trueopt-in. Operators must declareSignals__Authority__Enabled=trueandSignals__Authority__AllowAnonymousFallback=falseexplicitly in production compose profiles.Signals:Cache:ConnectionStringmust be explicitly configured. Production startup must not silently use a local Redis/Valkey default such aslocalhost:6379.Signals:Events:Driver=inmemoryis local-harness-only. Production can useredis,router, or explicitly disable publishing withSignals:Events:Enabled=false, which binds the null publisher intentionally./signals/runtime-facts/syntheticis local-harness-only and is not mapped on production hosts. Route isolation is not the evidence boundary: every event and persisted fact carries the structuralRuntimeFactOrigin(ObservedorSynthetic), and AOC provenance records carry the same value asevidenceOrigin. Synthetic facts remain available to local-harness inspection, but are excluded fromruntime.updated, reachability runtime-hit/scoring inputs, execution-evidence predicates, and the observedruntimeFactsCountinsignals.fact.updated. Synthetic ingest responses report observed and synthetic fact/hit counts separately. Metadata such asprobe=syntheticis descriptive only and is never the classification authority.- Runtime-facts batch ingestion is evidence-bearing. It requires a configured
ICryptoHashprovider and a runtime-facts artifact store before any subject is processed. Signals must fail closed instead of returning timestamp-derived hashes orcas://references when the raw batch was not actually persisted. The filesystem driver bindsFileSystemRuntimeFactsArtifactStore; the RustFS driver bindsRustFsRuntimeFactsArtifactStoreand the S3 driver bindsS3RuntimeFactsArtifactStore. Both object-store drivers persist runtime-facts artifacts underruntime-facts/{first2}/{blake3}/runtime-facts.ndjson[.gz], and their save, read, existence, and delete operations verify the BLAKE3-256 digest instead of trusting object metadata.
Runtime-facts production storage (Sprint 20260502_013 G9-RFS)
Production hosts MUST use an object-store driver (s3 or rustfs) 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.
The validator’s refusal names the bound keys: Signals:Storage:Driver=s3 with Signals:Storage:S3:Endpoint, :AccessKey and :SecretKey, or Signals:Storage:Driver=rustfs with Signals:Storage:RustFs:BaseUrl.
The S3 driver (SPRINT_20260927_008 PCD-97) is implemented in S3RuntimeFactsArtifactStore and S3CallgraphArtifactStore over AWSSDK.S3. It uses Signals:Storage:BucketName, :RootPrefix and :RuntimeFactsRootPrefix as below, plus:
| Key | Purpose |
|---|---|
Signals:Storage:Driver=s3 | Selects the S3-protocol binding. |
Signals:Storage:S3:Endpoint | Absolute HTTP(S) endpoint of the S3-compatible service (the bundle: http://s3.stella-ops.local:8333). No cloud default exists. |
Signals:Storage:S3:AccessKey + :SecretKey | The S3 identity; both required. The bundle passes the STELLAOPS_S3_* identity the object store renders. |
Signals:Storage:S3:Region | SigV4 signing region (default us-east-1; S3-compatible stores accept a fixed value). |
Signals:Storage:S3:Timeout | Per-request timeout (default 60s). |
The bucket is created on the first write that finds it missing, so a fresh installation needs no provisioning step. The S3 driver sends no RustFS immutability or retention headers; retention deletes by digest through IRuntimeFactsArtifactStore as for RustFS.
The RustFS driver is implemented in RustFsRuntimeFactsArtifactStore. Required configuration keys:
| Key | Purpose |
|---|---|
Signals:Storage:Driver=rustfs | Selects the RustFS-backed binding. |
Signals:Storage:BucketName | Bucket containing the runtime-facts/... prefix (default signals-data). |
Signals:Storage:RootPrefix | Object-key prefix under the bucket for callgraph artifacts (default callgraphs). |
Signals:Storage:RuntimeFactsRootPrefix | Object-key prefix under the bucket for runtime-facts artifacts (default runtime-facts). |
Signals:Storage:RustFs:BaseUrl | Absolute HTTP(S) endpoint for the RustFS API (e.g. http://rustfs:9000/api/v1/). |
Signals:Storage:RustFs:ApiKey + :ApiKeyHeader | Authentication credentials applied to every request (default header X-API-Key). |
Signals:Storage:RustFs:Timeout | Per-request timeout (default 60s; bounded so 5xx propagates rather than hanging). |
Signals:Retention:RuntimeFactsTtlHours | Drives 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:
- Digest-verified round-trip.
SaveAsyncre-reads the object after PUT and re-hashes the bytes; mismatched digests throwInvalidDataExceptionrather than returning a fake CAS URI. - Bounded failure propagation. RustFS 4xx/5xx responses surface as
InvalidOperationExceptioncarrying status code + reason; there is no silent fallback to filesystem. - Retention emits structured telemetry.
RuntimeFactsRetentionServicedeletes the underlying artifact by digest viaIRuntimeFactsArtifactStoreand on transient RustFS failures (e.g. 503) logs a warning carryingStoreType=rustfs,Operation=delete, andErrorKind=<exception>instead of swallowing the error.
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 the archived BinaryIndex architecture.
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:Scoringconfig section inetc/signals.yaml. The latter configures the reachability confidence scorer (ReachableConfidence,UnreachableConfidence,RuntimeBonus,MaxConfidence,MinConfidence) used byReachabilityScoringService, not the EWS dimension weights.
Service configuration (signals.yaml)
Top-level sections under Signals: (see SignalsOptions and devops/etc/signals.yaml.sample). The host reads optional ../etc/signals.yaml and signals.yaml relative to its content root; there is no Postgres section, because the connection comes only from the canonical variable:
| Section | Purpose |
|---|---|
Authority | OIDC resource-server config (Enabled, Issuer, Audiences, RequiredScopes, AllowAnonymousFallback, BypassNetworks, …). With no Audiences set, the host accepts api://signals. It always also accepts the estate audience stellaops that Authority stamps on service tokens (SignalsAuthorityOptionsConfigurator). Signals reads only Signals:Authority; the Authority:ResourceServer:* keys other hosts read have no effect on it. The shipped compose files and the release bundle write only Signals__Authority__Enabled, Signals__Authority__AllowAnonymousFallback and Signals__Authority__Issuer for signals, so signals opens no bypass network and fetches its OpenID metadata from the issuer. SignalsComposeAuthorityKeysTests fails when one of those sources writes an authentication key that signals does not read, or a value the running host does not hold. |
Storage | Artifact-store driver (filesystem/rustfs), RootPath, BucketName, RuntimeFactsRootPrefix, RustFs:*. |
Scoring | Reachability confidence parameters (see above). |
Cache | Redis/Valkey reachability-fact cache (ConnectionString, DefaultTtlSeconds). |
Events | Event publishing (Enabled, Driver = redis/router/inmemory, Router:*). |
AirGap | Air-gap event topic override. |
OpenApi, Retention, Scm | OpenAPI doc, runtime-facts retention TTL, SCM webhook options. |
Integration Points
Truthfulness note:
StellaOps.Signals.csprojholds no project references to Policy, Concelier, or Excititor. It does reference the lean Scanner public-verdict contract and the sharedStellaOps.Reachability.Corefoundation 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)
| Producer | Data | Mechanism |
|---|---|---|
| Scanner / reachability tooling | Call graphs, reachability union graphs | POST /signals/callgraphs, POST /signals/reachability/union |
Runtime-facts publishers (StellaOps.RuntimeInstrumentation.RuntimeFacts RuntimeFactsHttpPublisher) | Observed symbol executions | POST /signals/runtime-facts / …/ndjson |
| Deployment-observation agent | Runtime observations (per release) | POST /api/v1/runtime/observations (direct, not via the gateway; signals:write client-credentials token, tenant from the token) |
Scanner worker publish-signals-unknowns stage (PCD-187) | A scan’s unresolved call-graph edges (missing-target-node, unresolved-callee, dynamic-call-target), keyed by scan id | POST /signals/unknowns under stellaops-scanner-worker with signals:write |
| CI/CD evidence | Execution evidence, verification beacons | POST /signals/execution-evidence, POST /signals/beacons |
| SCM/CI providers | Push/PR/release/pipeline events | POST /webhooks/{github,gitlab,gitea} |
| EWS library callers (in-process) | Per-dimension *Input DTOs | IEvidenceWeightedScoreCalculator.Calculate(...) |
Direct project references (StellaOps.Signals.csproj): StellaOps.Auth.Abstractions and StellaOps.Auth.ServerIntegration (Authority), StellaOps.Messaging, StellaOps.Infrastructure.Postgres, StellaOps.Audit.Emission, StellaOps.Cryptography, StellaOps.Cryptography.Dsse, StellaOps.Canonical.Json, StellaOps.Reachability.Core, StellaOps.Doctor.Plugin.Abstractions, StellaOps.AirGap.Policy.Replication, StellaOps.Signals.Contracts, and the local StellaOps.Signals.Persistence and StellaOps.Signals.EvidenceWeightedScore projects; StellaOps.Scanner.Contracts is a package reference.
Outbound (Consumers)
| Consumer | Usage |
|---|---|
| Stella Router / event subscribers | signals.fact.updated.v1 reachability-fact-change events (redis or router driver) |
| Gateway / orchestrator compatibility | /api/v1/signals + /api/v1/signals/stats (currently static demo data) |
| Backend Router / score clients | Tenant-scoped /api/v1/score/* evaluation, lookup, history, explain, weights, replay, and verify. |
| Audit pipeline | Audit events on ingest/recompute via StellaOps.Audit.Emission |
Determinism
Signals maintains strict determinism in its scoring and evidence digests:
- Same inputs + same policy = same score: No randomness, no time-dependent factors in the EWS formula.
AddDeterminismDefaults()is wired inProgram.cs, and time-dependent values are explicitly annotated (// Determinism:Allowed). - Policy versioning: Each weight manifest is versioned; the
EvidenceWeightedScoreResultcarries a canonical digest (WithComputedDigest()). - Reproducible replay:
UnifiedScore/Replay/IReplayVerifierre-derives scores for verification. - Canonical fact digests:
ReachabilityFactDigestCalculatorproduces 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 three projects under src/Signals/__Tests/ (StellaOps.Signals.Tests, StellaOps.Signals.Persistence.Tests, StellaOps.Signals.Ebpf.Tests). The host project groups its tests by feature subdirectory:
| Focus | Location (examples) |
|---|---|
| EWS calculator, normalizers, determinism, property tests | EvidenceWeightedScore/ (EvidenceWeightedScoreCalculatorTests, …PropertyTests, …DeterminismTests, Normalizers/) |
| Unified score + replay | UnifiedScore/ |
| Callgraph / reachability ingestion & scoring | CallgraphIngestionServiceTests, ReachabilityScoringServiceTests, ReachabilityUnionIngestionServiceTests, ReachabilityLatticeTests |
| Runtime facts (incl. batch, provenance, stack frames) | RuntimeFactsIngestionServiceTests, RuntimeFactsBatchIngestionTests, RuntimeFactsProvenanceNormalizerTests, RustFsRuntimeFactsArtifactStoreTests |
| Unknowns scoring, decay & producer | UnknownsScoringServiceTests, UnknownsDecayServiceTests, UnknownsScoringIntegrationTests, SignalsUnknownsContractSignalsSideTests |
| Host / config / durability | SignalsDurableHostTests, SignalsRuntimeConfigurationValidationTests, ProgramCompatibilityTests |
| SCM webhooks | Scm/ |
Related Documentation
- Companion:
unified-score.md,api-reference.md,README.md - eBPF determinism contract:
contracts/ebpf-micro-witness-determinism-profile.md - Runtime configuration validation:
../../architecture/runtime-configuration-validation.md - Forward-only migration policy: ADR-004
- BinaryIndex CAS pattern (shared object-store shape): archived BinaryIndex architecture
Earlier drafts linked a
24-Dec-2025 Evidence-Weighted Score Modeladvisory,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 receives webhooks from SCM (Source Code Management) and CI (Continuous Integration) providers. It validates the signature, maps the payload to a normalized event and decides whether the event asks for a scan or an SBOM ingest. It dispatches neither. No scan owner API exists to dispatch to: Scanner quarantines source-triggered scanning, and its own webhooks answer 501 source_scan_not_available. So an event that asks for a scan answers 501 problem+json with code source_scan_not_available, an event that asks only for an SBOM ingest answers 501 with code sbom_ingest_not_available, and any other valid event answers 200. The sanctioned ingress is CLI-in-CI (stella sbom generate / stella sbom attach). Before SPRINT_20260928_001 PCD-108 a scan request answered 202 with triggersDispatched: true while the service only logged “Would trigger scan”.
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 # Decides which triggers an event asks for; dispatches none
└── ScmWebhookEndpoints.cs # Minimal API webhook endpoints
Supported Providers
| Provider | Webhook Endpoint | Signature Header | Validation |
|---|---|---|---|
| GitHub | /webhooks/github | X-Hub-Signature-256 | HMAC-SHA256 |
| GitLab | /webhooks/gitlab | X-Gitlab-Token | Token match |
| Gitea | /webhooks/gitea | X-Gitea-Signature | HMAC-SHA256 |
Event Types
| Event Type | Description | Asks for (answered 501) |
|---|---|---|
Push | Code push to branch | Scan (main/release branches) |
PullRequestOpened | PR opened | — |
PullRequestMerged | PR merged | Scan |
ReleasePublished | Release created | Scan |
ImagePushed | Container image pushed | Scan |
PipelineSucceeded | CI pipeline completed | Scan |
SbomUploaded | SBOM artifact uploaded | SBOM 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
ScmEventTypeenum is broader than the trigger table above — it also includesPullRequest,PullRequestClosed,TagCreated,RefCreated,RefDeleted,PipelineStarted,PipelineCompleted,PipelineFailed, andArtifactPublished. Only the subset listed in the table asks for a trigger; every other event answers200.
Trigger decision
ScmTriggerService.Evaluate returns the triggers an event asks for. A scan is asked for by Push to a main/release branch (main, master, release/*, release-*), PullRequestMerged, ReleasePublished, ImagePushed and PipelineSucceeded. An SBOM ingest is asked for by SbomUploaded and by an event whose artifact is an SBOM; no provider mapper produces either today. ScmWebhookService answers 501 for any requested trigger (the scan code wins when both are asked for) and 200 when none is. The 501 body carries code, eventId, eventType, provider, repository and requestedTriggers (ScmWebhookTriggerHostTests).
Security
- Signature verification: All webhooks validate the provider signature before processing (GitHub/Gitea HMAC-SHA256, GitLab token match); missing secret or invalid signature is rejected with a warning log.
- Sealed-mode gate: All three webhook handlers await the local installation posture before reading the body or invoking the webhook service. Unavailable posture or invalid required evidence returns 503. Allowed admission retains provider signature checks and forwarding of the existing integration/tenant context.
- Delivery logging: Each delivery is logged with provider, event type, and delivery ID (structured
ILoggerscope), and the result is returned to the caller. A refused trigger is logged at warning level with itscode.
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 fromSignals:Scmconfig (above), and there is no rate limiter in the SCM webhook path. These claims have been removed pending implementation.
