Signals API

The Signals service ingests and normalizes reachability/runtime signals (call graphs, runtime facts, reachability unions, unknowns) and computes deterministic reachability scores and Unknowns triage bands. It is a core input to the Stella Ops release control plane: the signals it produces feed policy-gated promotion decisions.

This reference is for integrators and operators wiring tooling against the service. It is derived from the live service implementation in src/Signals/StellaOps.Signals (Program.cs, CompatibilityApiV1Endpoints.cs, the Api/ controllers, and Scm/ScmWebhookEndpoints.cs).

Companion references:

Service base path and discovery

Most service endpoints live under the /signals route group; a compatibility surface lives under /api/v1/signals; agent and hot-symbol controllers live under /api/v1/agents and /api/v1/signals/hot-symbols; SCM/CI webhooks live under /webhooks.

Authentication & scopes

The /signals group endpoints authorize per-request inside each handler via Program.TryAuthorize (see Program.cs), not via attribute policies. Authorization accepts either:

  1. An Authority-issued bearer token whose scope claim contains the required scope (validated by StellaOps.Auth.ServerIntegration), or
  2. When Signals:Authority:AllowAnonymousFallback is enabled (development fallback), an X-Scopes request header carrying the required scope.

Required scopes are defined in SignalsPolicies (src/Signals/StellaOps.Signals/Routing/SignalsPolicies.cs) and registered in the canonical catalog StellaOpsScopes (src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs):

Policy constantScope stringStellaOpsScopes member
SignalsPolicies.Readsignals:readStellaOpsScopes.SignalsRead
SignalsPolicies.Writesignals:writeStellaOpsScopes.SignalsWrite
SignalsPolicies.Adminsignals:adminStellaOpsScopes.SignalsAdmin

Authorization outcomes:

When Authority is disabled (Signals:Authority:Enabled=false), the service registers an Anonymous authentication scheme and logs a warning; handlers still run their per-request scope check against the X-Scopes header.

Sealed (air-gapped) mode

When sealed-mode enforcement is enabled, every /signals and /webhooks handler first calls TryEnsureSealedMode. If the installation’s sealed-mode evidence is non-compliant, the request returns 503 Service Unavailable with body { "error": "sealed-mode evidence invalid", "reason": "<reason>" }. /readyz likewise returns 503 with { "status": "sealed-mode-blocked", "reason": "<reason>" }.

Health & status

MethodPathAuthNotes
GET/healthzAnonymousLiveness probe.
GET/readyzAnonymousReadiness; 200 {"status":"ready"} or 503. Returns sealed-mode-blocked 503 when applicable.
GET/signals/pingsignals:readReturns 204 No Content.
GET/signals/statussignals:readReturns { service, version, sealedMode: { enforced, compliant, reason } }.
GET/buildinfoAnonymousImage provenance (mapped via MapBuildInfoEndpoint).

Call graphs

POST /signals/callgraphs

Ingests a call graph artifact. Scope: signals:write. Returns 202 Accepted with a Location of /signals/callgraphs/{callgraphId}.

Request body (CallgraphIngestRequest):

FieldTypeRequiredNotes
languagestringyesParser language: one of java, nodejs, python, go.
componentstringyes
versionstringyes
artifactContentTypestringyes
artifactFileNamestringyes
artifactContentBase64stringyesBase64-encoded call graph artifact.
metadataobject (string→string?)no
schemaVersionstringno
analyzerobject (string→string?)no

Response body (CallgraphIngestResponse): callgraphId, artifactPath, artifactHash, casUri, graphHash, manifestCasUri, schemaVersion, nodeCount, edgeCount, rootCount.

Error responses:

GET /signals/callgraphs/{callgraphId}

Returns the stored call graph document, or 404 if not found. Scope: signals:read.

GET /signals/callgraphs/{callgraphId}/manifest

Returns the call graph manifest file (application/json), or 404 when the callgraph or its manifest path is missing. Scope: signals:read.

Runtime facts

POST /signals/runtime-facts

Ingests a batch of runtime fact events. Scope: signals:write. Returns 202 Accepted with Location /signals/runtime-facts/{subjectKey}.

Request body (RuntimeFactsIngestRequest):

FieldTypeRequiredNotes
subjectReachabilitySubjectyes{ imageDigest?, component?, version?, scanId? }.
callgraphIdstringyes
eventsRuntimeFactEvent[]no
metadataobject (string→string?)no

RuntimeFactEvent fields: symbolId (required), codeId?, symbolDigest?, purl?, buildId?, loaderBase?, processId?, processName?, socketAddress?, containerId?, evidenceUri?, hitCount (default 1), observedAt?, metadata?.

Response body (RuntimeFactsIngestResponse): factId, subjectKey, callgraphId, runtimeFactCount, totalHitCount, storedAt.

Error: 400 Bad Request on RuntimeFactsValidationException.

POST /signals/runtime-facts/ndjson

Streams runtime fact events as NDJSON (optionally gzip-encoded via Content-Encoding: gzip). Scope: signals:write. Stream metadata (including callgraphId, required) is supplied as query/header parameters (RuntimeFactsStreamMetadata, bound via [AsParameters]). Returns 202 Accepted; 400 when callgraphId is missing or the stream is empty.

POST /signals/runtime-facts/synthetic

Local harness only — registered only when IsSignalsLocalHarness(...) is true. Generates synthetic runtime probe events for an existing call graph. Scope: signals:write. Body (SyntheticRuntimeProbeRequest): callgraphId (required), subject?, eventCount, metadata?. Returns 404 when the referenced callgraph is not found.

GET /signals/facts/{subjectKey}

Returns the reachability fact document for a subject key (ReachabilityFactDocument), or 404. Scope: signals:read.

Reachability

POST /signals/reachability/union

Ingests a reachability union archive. Scope: signals:write.

GET /signals/reachability/union/{analysisId}/meta

Returns the union meta.json (application/json), or 404. Scope: signals:read.

GET /signals/reachability/union/{analysisId}/files/{fileName}

Returns a named union artifact file. Content type is application/json for .json files, otherwise application/x-ndjson. 404 when the file is absent. Scope: signals:read.

POST /signals/reachability/recompute

Recomputes reachability scoring for a call graph. Scope: signals:admin(the only admin-gated endpoint).

Request body (ReachabilityRecomputeRequest): callgraphId, subject (ReachabilitySubject), entryPoints[], targets[], runtimeHits?[], blockedEdges?[] ({ from, to }), metadata?.

Returns 200 OK with { id, callgraphId, subject, entryPoints, states, computedAt }. Errors: 400 on ReachabilityScoringValidationException; 404 on ReachabilityCallgraphNotFoundException.

Unknowns

POST /signals/unknowns

Ingests unknown-symbol entries. Scope: signals:write. Returns 202 Accepted with Location /signals/unknowns/{subjectKey}.

Request body (UnknownsIngestRequest): subject (ReachabilitySubject, required), callgraphId (required), unknowns[] (required). Each UnknownSymbolEntry: symbolId?, codeId?, purl?, edgeFrom?, edgeTo?, reason?.

Response (UnknownsIngestResponse): subjectKey, unknownsCount. Error: 400 on UnknownsValidationException.

GET /signals/unknowns/{subjectKey}

Returns the unknown entries for a subject key, or 404 when none exist. Scope: signals:read.

GET /signals/unknowns

Lists unknowns with optional band filtering and pagination. Scope: signals:read.

Query parameters:

ParameterTypeDefaultNotes
bandstring(none)One of hot, warm, cold (UnknownsBand, case-insensitive). Invalid values are ignored (no filter).
limitint100Clamped to [1, 1000].
offsetint0Clamped to >= 0.

Response: { items, count, limit, offset, band } where band is the lowercase normalized filter (or null).

Triage bands (UnknownsBand): Hot (score ≥ 0.70), Warm (0.40 ≤ score < 0.70), Cold (score < 0.40).

GET /signals/unknowns/{id}/explain

Returns the scoring explanation for a single unknown by id, or 404. Scope: signals:read. Response: { id, subjectKey, band, score, normalizationTrace, flags, nextScheduledRescan, rescanAttempts, createdAt, updatedAt }.

Execution evidence & beacons

POST /signals/execution-evidence

Builds execution evidence from runtime trace events. Scope: signals:write. Body (ExecutionEvidenceRequest) requires artifactId and environmentId (else 400).

Outcomes:

POST /signals/beacons

Ingests beacon verification events. Scope: signals:write. Body (BeaconIngestRequest) requires a non-empty events[] (else 400). Returns 202 Accepted (Location /signals/beacons).

GET /signals/beacons/rate/{artifactId}/{environmentId}

Returns the beacon verification rate for an artifact/environment pair, or 404 when no beacon data exists. Scope: signals:read.

Runtime observation ingest (agent)

POST /api/v1/runtime/observations

Unified runtime-evidence ingest for the out-of-process deployment-observation agent. Anonymous (AllowAnonymous): the endpoint trusts authentication at the network edge (mTLS / internal networking) rather than the Authority OAuth flow, regardless of the global anonymous-fallback toggle. Still enforces sealed-mode.

This route is only registered when Signals is configured with a PostgreSQL connection string. Without durable persistence the route returns 503 with { "error": "runtime observation ingest requires durable PostgreSQL persistence." }.

Body (RuntimeObservationIngestRequest) validation (all 400 on failure):

Additional fields: releaseId?, hostId?, containerId?, imageRef?, capturedAt (defaults to now when unset), captureMode?, containerLabels?, processList?, portBindings?, metadata?, optional userStack? / kernelStack? stack frames and stackCaptureMode? (addresses-only, partial-symbols, full-symbols).

Returns 202 Accepted (Location /api/v1/runtime/observations/{id}) with { factId, releaseId, imageDigest }.

Runtime agents (controller)

Controller RuntimeAgentController ([Route("api/v1/agents")], Produces application/json).

MethodPathBodySuccessNotes
POST/api/v1/agents/registerRegisterAgentApiRequest201 Created400 when agentId or hostname missing.
POST/api/v1/agents/{agentId}/heartbeatHeartbeatApiRequest200 OK404 when agent unknown.
GET/api/v1/agents/{agentId}200 OK404 when unknown.
GET/api/v1/agents200 OKQuery: platform (RuntimePlatform), healthy_only (bool).
DELETE/api/v1/agents/{agentId}204 No Content404 when unknown.
POST/api/v1/agents/{agentId}/commandsCommandApiRequest202 Accepted404 when unknown.
PATCH/api/v1/agents/{agentId}/posturePostureUpdateRequest204 No Content404 when unknown.
POST/api/v1/agents/{agentId}/factsFactsIngestApiRequest200 OKRuntimeFactsController; 400 when events empty.

RegisterAgentApiRequest: agentId (required), platform (RuntimePlatform, default DotNet), hostname (required), containerId?, kubernetesNamespace?, kubernetesPodName?, applicationName?, processId?, agentVersion?, initialPosture (RuntimePosture, default Sampled), tags?.

FactsIngestApiRequest.events[] (RuntimeEventApiDto): eventId?, symbolId (required), methodName (required), typeName (required), assemblyOrModule (required), timestamp (required), kind (RuntimeEventKind, default MethodSample), containerId?, processId?, threadId?, callDepth?, durationMicroseconds?, context?. Response (FactsIngestApiResponse): acceptedCount, rejectedCount, aggregatedSymbols.

Note: these MVC controllers do not declare [Authorize] attributes and are not routed through the Program.TryAuthorize per-request scope check used by the /signals group.

Hot symbols (controller)

Controller HotSymbolsController ([Route("api/v1/signals")]). Each endpoint requires the image query parameter to be a valid digest (sha256: or sha512: prefix, else 400).

MethodPathNotes
GET/api/v1/signals/hot-symbolsQuery: image (required), build_id?, function?, module?, min_count?, security_only?, window_hours (default 24), limit (default 100, max 1000), offset (default 0), sort?.
GET/api/v1/signals/hot-symbols/topQuery: image (required), top (default 10, max 100), window_hours (default 24).
GET/api/v1/signals/hot-symbols/statsQuery: image (required).
GET/api/v1/signals/hot-symbols/correlatedQuery: image (required).

Sort values (sort): count_asc, count_desc (default), last_seen_asc, last_seen_desc, name_asc. Response DTOs: HotSymbolApiResponse, TopHotSymbolsResponse, HotSymbolStatsResponse, CorrelatedSymbolsResponse (each carrying HotSymbolDto).

Compatibility surface (/api/v1/signals)

Minimal-API endpoints in CompatibilityApiV1Endpoints. Authorization accepts either signals:read or orch:read (Program.TryAuthorizeAny). Sealed-mode is enforced.

The data returned by this surface is a fixed in-memory fixture set (sig-001sig-005), not live signal records. Treat it as a compatibility/demo shim rather than a production data source.

MethodPathNotes
GET/api/v1/signalsQuery: type?, status?, provider?, limit? (default 50, clamped [1,200]), cursor?. Response: { items, total, cursor }.
GET/api/v1/signals/statsResponse: { total, byType, byStatus, byProvider, lastHourCount, successRate, avgProcessingMs }.

SCM / CI webhooks (/webhooks)

Anonymous endpoints in ScmWebhookEndpoints; security is enforced by per-provider HMAC / token signature validation, not Authority scopes. Sealed-mode is enforced.

MethodPathSignature header(s)Event / delivery headers
POST/webhooks/githubX-Hub-Signature-256X-GitHub-Event, X-GitHub-Delivery
POST/webhooks/gitlabX-Gitlab-TokenX-Gitlab-Event, X-Gitlab-Event-UUID
POST/webhooks/giteaX-Hub-Signature-256 (falls back to X-Hub-Signature)X-Gitea-Event, X-Gitea-Delivery

All three also read optional X-Integration-Id and X-Tenant-Id headers.

Responses:

ScmProvider: Unknown, GitHub, GitLab, Gitea. ScmEventType: Unknown, Push, PullRequest, PullRequestOpened, PullRequestMerged, PullRequestClosed, ReleasePublished, TagCreated, RefCreated, RefDeleted, PipelineStarted, PipelineCompleted, PipelineSucceeded, PipelineFailed, ArtifactPublished, ImagePushed, SbomUploaded.

Audit

State-changing endpoints emit audit events (AuditModules.Signals) via the .Audited(...) filter:

Action constantValueEndpoints
IngestCallgraphingest_callgraphPOST /signals/callgraphs
IngestRuntimeFactingest_runtime_factPOST /signals/runtime-facts, .../ndjson, .../synthetic
ComputeReachabilitycompute_reachabilityPOST /signals/reachability/union, /signals/reachability/recompute
IngestUnknownsingest_unknownsPOST /signals/unknowns
SubmitExecutionEvidencesubmit_execution_evidencePOST /signals/execution-evidence
RegisterBeaconregister_beaconPOST /signals/beacons

Persistence & configuration

Signals owns the PostgreSQL signals schema, auto-migrated on startup (embedded SQL under src/Signals/__Libraries/StellaOps.Signals.Persistence/Migrations/: 001_initial_schema.sql through 005_runtime_facts_stack_frames.sql). Core tables: signals.scans, signals.artifacts, signals.cg_nodes, signals.cg_edges, signals.entrypoints, signals.symbol_component_map, signals.reachability_components, signals.reachability_findings, signals.runtime_samples, signals.runtime_facts, signals.deploy_refs, signals.graph_metrics, signals.unknowns.

The connection string is resolved (in order) from Signals connection string, Signals:Postgres:ConnectionString, Postgres:Signals:ConnectionString, or the matching environment variables. Outside an explicit local-harness mode a connection string is required; in Signals:LocalHarness:Enabled=true development/testing mode the service falls back to in-memory repositories.

Configuration root section is Signals (SignalsOptions.SectionName). Notable sub-sections: Signals:Authority, Signals:UnknownsScoring, Signals:UnknownsDecay, Signals:UnknownsRescan, Signals:Retention, Signals:ExecutionEvidence, Signals:Beacon, plus events (Signals:Events, driver redis | router | inmemory) and storage (filesystem or RustFS). The environment-variable prefix is SIGNALS_; YAML overrides load from etc/signals.yaml / etc/signals.local.yaml.