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:
signals/reachability-contract.md— reachability fact contract.unknowns-api.md— cross-service Unknowns registry surface.
Service base path and discovery
- Local dev:
https://localhost:10440,http://localhost:10441 - Local alias:
https://signals.stella-ops.local,http://signals.stella-ops.local - Env var:
STELLAOPS_SIGNALS_URL - Lowercase URLs are enforced (
AddRouting(LowercaseUrls = true)). - An OpenAPI 3.1.1 document is served anonymously at
/openapi/v1.json(app.MapOpenApi().AllowAnonymous()), consumed by the gateway aggregator.
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:
- An Authority-issued bearer token whose
scopeclaim contains the required scope (validated byStellaOps.Auth.ServerIntegration), or - When
Signals:Authority:AllowAnonymousFallbackis enabled (development fallback), anX-Scopesrequest 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 constant | Scope string | StellaOpsScopes member |
|---|---|---|
SignalsPolicies.Read | signals:read | StellaOpsScopes.SignalsRead |
SignalsPolicies.Write | signals:write | StellaOpsScopes.SignalsWrite |
SignalsPolicies.Admin | signals:admin | StellaOpsScopes.SignalsAdmin |
Authorization outcomes:
- Authenticated principal missing the required scope →
403 Forbidden. - No principal and fallback disabled (or no
X-Scopesheader) →401 Unauthorized.
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
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /healthz | Anonymous | Liveness probe. |
| GET | /readyz | Anonymous | Readiness; 200 {"status":"ready"} or 503. Returns sealed-mode-blocked 503 when applicable. |
| GET | /signals/ping | signals:read | Returns 204 No Content. |
| GET | /signals/status | signals:read | Returns { service, version, sealedMode: { enforced, compliant, reason } }. |
| GET | /buildinfo | Anonymous | Image 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):
| Field | Type | Required | Notes |
|---|---|---|---|
language | string | yes | Parser language: one of java, nodejs, python, go. |
component | string | yes | |
version | string | yes | |
artifactContentType | string | yes | |
artifactFileName | string | yes | |
artifactContentBase64 | string | yes | Base64-encoded call graph artifact. |
metadata | object (string→string?) | no | |
schemaVersion | string | no | |
analyzer | object (string→string?) | no |
Response body (CallgraphIngestResponse): callgraphId, artifactPath, artifactHash, casUri, graphHash, manifestCasUri, schemaVersion, nodeCount, edgeCount, rootCount.
Error responses:
400 Bad Request—CallgraphIngestionValidationException,CallgraphParserNotFoundException, or malformed base64 (FormatException).422 Unprocessable Entity—CallgraphParserValidationException.
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):
| Field | Type | Required | Notes |
|---|---|---|---|
subject | ReachabilitySubject | yes | { imageDigest?, component?, version?, scanId? }. |
callgraphId | string | yes | |
events | RuntimeFactEvent[] | no | |
metadata | object (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.
Content-TypeMUST beapplication/zip(otherwise400).- Optional
X-Analysis-Idheader; when omitted a new id is generated. - Returns
202 AcceptedwithLocation/signals/reachability/union/{analysisId}/metaand aReachabilityUnionIngestResponsebody.
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:
| Parameter | Type | Default | Notes |
|---|---|---|---|
band | string | (none) | One of hot, warm, cold (UnknownsBand, case-insensitive). Invalid values are ignored (no filter). |
limit | int | 100 | Clamped to [1, 1000]. |
offset | int | 0 | Clamped 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:
202 Accepted(Location/signals/execution-evidence/{artifactId}/{environmentId}) on success.200 OKwith{ status: "rate_limited", artifact_id, environment_id }when rate-limited.422 Unprocessable Entitywhen there are insufficient trace events or the pipeline is disabled.
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):
tenantIdmust be a GUID.agentIdmust be a GUID.imageDigestmust start withsha256:.containerNameis required.
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).
| Method | Path | Body | Success | Notes |
|---|---|---|---|---|
| POST | /api/v1/agents/register | RegisterAgentApiRequest | 201 Created | 400 when agentId or hostname missing. |
| POST | /api/v1/agents/{agentId}/heartbeat | HeartbeatApiRequest | 200 OK | 404 when agent unknown. |
| GET | /api/v1/agents/{agentId} | — | 200 OK | 404 when unknown. |
| GET | /api/v1/agents | — | 200 OK | Query: platform (RuntimePlatform), healthy_only (bool). |
| DELETE | /api/v1/agents/{agentId} | — | 204 No Content | 404 when unknown. |
| POST | /api/v1/agents/{agentId}/commands | CommandApiRequest | 202 Accepted | 404 when unknown. |
| PATCH | /api/v1/agents/{agentId}/posture | PostureUpdateRequest | 204 No Content | 404 when unknown. |
| POST | /api/v1/agents/{agentId}/facts | FactsIngestApiRequest | 200 OK | RuntimeFactsController; 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 theProgram.TryAuthorizeper-request scope check used by the/signalsgroup.
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).
| Method | Path | Notes |
|---|---|---|
| GET | /api/v1/signals/hot-symbols | Query: 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/top | Query: image (required), top (default 10, max 100), window_hours (default 24). |
| GET | /api/v1/signals/hot-symbols/stats | Query: image (required). |
| GET | /api/v1/signals/hot-symbols/correlated | Query: 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-001…sig-005), not live signal records. Treat it as a compatibility/demo shim rather than a production data source.
| Method | Path | Notes |
|---|---|---|
| GET | /api/v1/signals | Query: type?, status?, provider?, limit? (default 50, clamped [1,200]), cursor?. Response: { items, total, cursor }. |
| GET | /api/v1/signals/stats | Response: { 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.
| Method | Path | Signature header(s) | Event / delivery headers |
|---|---|---|---|
| POST | /webhooks/github | X-Hub-Signature-256 | X-GitHub-Event, X-GitHub-Delivery |
| POST | /webhooks/gitlab | X-Gitlab-Token | X-Gitlab-Event, X-Gitlab-Event-UUID |
| POST | /webhooks/gitea | X-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:
202 Acceptedwith{ eventId, eventType, provider, repository, triggersDispatched, scanTriggersCount, sbomTriggersCount }when an event triggers processing.200 OKwith{ message }when the event is recognized but ignored.401 Unauthorizedon signature validation failure.400 Bad Requeston malformed payloads.
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 constant | Value | Endpoints |
|---|---|---|
IngestCallgraph | ingest_callgraph | POST /signals/callgraphs |
IngestRuntimeFact | ingest_runtime_fact | POST /signals/runtime-facts, .../ndjson, .../synthetic |
ComputeReachability | compute_reachability | POST /signals/reachability/union, /signals/reachability/recompute |
IngestUnknowns | ingest_unknowns | POST /signals/unknowns |
SubmitExecutionEvidence | submit_execution_evidence | POST /signals/execution-evidence |
RegisterBeacon | register_beacon | POST /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.
