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; SCM/CI webhooks live under /webhooks. The former agent and hot-symbol controllers are withdrawn (see Withdrawn surfaces).

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

The existing admission gates await installation posture from Signals’ own local catalog replica. An observed seal activates the evidence policy; an observed unseal cannot disable local enforcement. Witnessed absence uses local configuration. If the replica is unreadable or required 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>" }. Authenticated /signals/status remains readable during refusal. The existing authorization and webhook-signature ordering is preserved. Evidence health checks file presence and age; it does not establish cryptographic timestamp verification.

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, scope, postureStatus, version } }. The seal scope is installation; postureStatus distinguishes Declared, NotDeclared and Unreadable.
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.

Each post replaces the subject’s stored entries. The producer is the Scanner worker’s publish-signals-unknowns stage (PCD-187): one post per scan with a call graph that has unresolved edges, subject {scanId, imageDigest}, callgraphId set to the graph digest and reason one of missing-target-node, unresolved-callee or dynamic-call-target.

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, createdAt, updatedAt }. normalizationTrace.unavailableFactors maps a factor letter to the reason it had no input. The centrality factor C is always listed, because Signals stores no centrality metrics, and it contributes nothing to score.

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 — the SOLE ingest target since Q-7 (2026-08-25, executed SPRINT_20260824_010 P8-16; the former Findings.Ledger dual-write copy is deleted). Authenticated (PCD-865, owner ruling 2026-10-18). The caller presents an Authority client-credentials bearer token with signals:write (RequireAuthorization(signals:write)); Authority mints a signals:* token only when aoc:verify is requested with it, so agents request signals:write aoc:verify for their tenant. The observation is written for the token’s tenant: Signals resolves the stellaops:tenant slug to the canonical tenant UUID through its own replica of the Authority tenants catalog (Catalog:Replication:Tenants). Sealed-mode is still enforced. The route is not exposed at the gateway: it is withheld from Router publication, and agents post to Signals directly inside the network.

StatusWhen
401No valid bearer token.
403The token lacks signals:write; the token carries no tenant or a tenant the replica does not hold as active; or the body tenantId names a different tenant. Nothing is written. Body { "error": "..." } for the tenant refusals.
503The tenants replica is not enabled or cannot answer (Retry-After: 30), sealed-mode evidence is invalid, or Signals has no PostgreSQL. Nothing is written.

This route writes only when Signals is configured with a PostgreSQL connection string. Without durable persistence it 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 }.

Withdrawn surfaces

Withdrawn 2026-09-27 (SPRINT_20260927_003 PCD-44). The following were never served by the Signals host (it maps no MVC controllers) and are withdrawn. A request to any of these paths on Signals answers 404.

  • RuntimeAgentController and RuntimeFactsController, route base /api/v1/agents (register, heartbeat, list, get, delete, commands, posture, facts). /api/v1/agents is Release Orchestrator’s agent surface. Runtime observations are ingested at POST /api/v1/runtime/observations and POST /signals/runtime-facts.
  • HotSymbolsController, GET /api/v1/signals/hot-symbols, /top, /stats and /correlated. No hot-symbol repository was ever implemented.
  • The unknowns rescan worker and its Signals:UnknownsRescan options. Unknowns are scored and decayed. The rescan schedule itself (nextScheduledRescan, rescanAttempts, lastRescanResult) was retired on 2026-09-29 (SPRINT_20260928_001 PCD-158); no unknowns response carries it.

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.

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:

No webhook answers 202: before SPRINT_20260928_001 PCD-108 a scan request answered 202 with triggersDispatched: true although nothing was dispatched.

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 in its own stellaops_signals database, auto-migrated on startup (embedded SQL under src/Signals/__Libraries/StellaOps.Signals.Persistence/Migrations/: 001_v1_signals_baseline.sql through 007_v1_signals_unknowns_retention_comment.sql). Tables: signals.scans, signals.cg_nodes, signals.cg_edges, signals.entrypoints, signals.callgraphs, signals.func_nodes, signals.call_edges, signals.reachability_facts, signals.runtime_agents, signals.runtime_facts, signals.deploy_refs, signals.unknowns, signals.score_history.

The connection string is resolved (in order) from the STELLAOPS_POSTGRES_SIGNALS_CONNECTION environment variable and the Postgres:Signals:ConnectionString configuration key. There is no shared-connection fallback. 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: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.