Signals API — Reachability Contract
Status: Working contract (aligns with src/Signals/StellaOps.Signals/Program.cs).
This document is the as-wired contract for the Stella Ops Signals service: it enumerates exactly the HTTP routes the Signals host registers in the request pipeline, the scopes each requires, and the routes that are defined in the assembly but not served. It is the authoritative companion to the Signals API reference, which presents the same surface in a more narrative form — when the two disagree, prefer this contract and the source it cites.
All routes are registered in Program.cs (minimal-API endpoints), CompatibilityApiV1Endpoints.cs, and ScmWebhookEndpoints.cs. URLs are lowercased (AddRouting(LowercaseUrls = true)).
Auth, scopes, sealed mode
- Scopes:
signals:read,signals:write,signals:admin(endpoint-specific; see below). These map toSignalsPolicies.Read/.Write/.Admininsrc/Signals/StellaOps.Signals/Routing/SignalsPolicies.cs, and toStellaOpsScopes.SignalsRead/SignalsWrite/SignalsAdminin the canonical scope catalog. The compatibility endpoints under/api/v1/signalsadditionally acceptorch:read(StellaOpsScopes.OrchRead) as an alternative tosignals:read. - Per-request authorization model: the
/signals/**and/api/v1/signalsendpoints do not use ASP.NET authorization filters/policies on the route. Instead each handler callsProgram.TryAuthorize(single scope) orProgram.TryAuthorizeAny(any-of scopes). An authenticated principal that lacks the required scope receives403; an unauthenticated request receives401(unless the dev fallback applies — see below). - Dev fallback: when Authority auth is disabled (
Signals:Authority:Enabled=false) the service uses theAnonymousauthentication handler and the handlers honour theSignals:Authority:AllowAnonymousFallbackflag. When fallback is allowed, requests must includeX-Scopes: <space-separated scopes>(example:X-Scopes: signals:write).AllowAnonymousFallback=trueis rejected at startup outside the local-harness environment bySignalsRuntimeConfigurationValidator. - Sealed mode (air-gap): controlled by
Signals:AirGap:SealedMode(SignalsSealedModeMonitor). When enforcement is enabled and evidence is not compliant, most endpoints return503with{ "error": "sealed-mode evidence invalid", "reason": "<detail>" }. Enforcement is a no-op whenEnforcementEnabledis false.
Endpoints
Discovery, health & status
GET /openapi/v1.json(anonymous) — OpenAPI 3.1 discovery document used by the gateway aggregator fan-out.GET /healthz(anonymous) — health check.GET /readyz(anonymous) —503with{ "status": "sealed-mode-blocked", "reason": ... }when sealed-mode enforcement blocks readiness;503(no body) when not yet ready; otherwise200with{ "status": "ready" }.GET /buildinfo(anonymous) — image-provenance/build-info endpoint mapped viaBuildInfoEndpointExtensions.MapBuildInfoEndpoint(fallback module namesignals), for the operator verify aggregator.GET /signals/ping(scope:signals:read, response:204 No Content).GET /signals/status(scope:signals:read) — returns{ service, version, sealedMode: { enforced, compliant, reason } }.
Callgraph ingestion & retrieval
POST /signals/callgraphs(scope:signals:write)- Body:
CallgraphIngestRequest. Required fields:language,component,version,artifactContentType,artifactFileName,artifactContentBase64. Optional:metadata,schemaVersion,analyzer. - Response:
202 AcceptedwithCallgraphIngestResponse(callgraphId,artifactPath,artifactHash,casUri,graphHash,manifestCasUri,schemaVersion,nodeCount,edgeCount,rootCount) andLocation: /signals/callgraphs/{callgraphId}. - Graph hash is computed deterministically from normalized nodes/edges/roots; see
graphHashin the response. - Validation failures return
400(CallgraphIngestionValidationException,CallgraphParserNotFoundException,FormatException) or422(CallgraphParserValidationException).
- Body:
GET /signals/callgraphs/{callgraphId}(scope:signals:read) —404when not found.GET /signals/callgraphs/{callgraphId}/manifest(scope:signals:read) — returns the manifest file (application/json);404when the callgraph or manifest is missing.
Sample request: docs/api/signals/samples/callgraph-sample.json
Runtime facts ingestion
POST /signals/runtime-facts(scope:signals:write)- Body:
RuntimeFactsIngestRequestwithsubject,callgraphId,events[](RuntimeFactEvent), and optionalmetadata. - Response:
202 AcceptedwithRuntimeFactsIngestResponse(factId,subjectKey,callgraphId,runtimeFactCount,totalHitCount,storedAt) andLocation: /signals/runtime-facts/{subjectKey}.
- Body:
POST /signals/runtime-facts/ndjson(scope:signals:write)- Query parameters (
RuntimeFactsStreamMetadata):callgraphId(required), plus optionalscanId,imageDigest,component,version,purl. - Body: NDJSON of
RuntimeFactEventobjects;Content-Encoding: gzipsupported. An empty stream returns400.
- Query parameters (
POST /signals/runtime-facts/synthetic(scope:signals:write)- Generates a small deterministic sample set of runtime events for a callgraph to unblock testing.
- Local-harness only: this route is registered only when
SignalsRuntimeConfigurationValidator.IsSignalsLocalHarnessis true (Development / TestingLocalHarness). It returns404in normal deployments.
Runtime observations ingestion (internal agent)
POST /api/v1/runtime/observations(anonymous — see note)- Sprint 20260512_020 RO-RUN-003 unified runtime-evidence ingest, consumed by the deployment-observation agent over internal backend networking (trust at the network edge, not OAuth). The route is mapped
AllowAnonymous()and does not require asignals:*scope. - Requires durable PostgreSQL persistence. When Signals runs without a configured Postgres connection string, the route is still mapped but returns
503{ "error": "runtime observation ingest requires durable PostgreSQL persistence." }. - Body:
RuntimeObservationIngestRequest(tenantIdGUID,agentIdGUID,imageDigestsha256:...,containerNamerequired; plusreleaseId,hostId,containerId,containerName,imageRef,captureMode,capturedAt,containerLabels,processList,portBindings,metadata,userStack,kernelStack,stackCaptureMode). Missing/invalidtenantId/agentId/imageDigest/containerNamereturn400. - Response:
202 Acceptedwith{ factId, releaseId, imageDigest }andLocation: /api/v1/runtime/observations/{factId}.
- Sprint 20260512_020 RO-RUN-003 unified runtime-evidence ingest, consumed by the deployment-observation agent over internal backend networking (trust at the network edge, not OAuth). The route is mapped
Unknowns ingestion, retrieval & query
POST /signals/unknowns(scope:signals:write)- Body:
UnknownsIngestRequest(subject,callgraphId,unknowns[]ofUnknownSymbolEntry). - Response:
202 AcceptedwithUnknownsIngestResponse(subjectKey,unknownsCount) andLocation: /signals/unknowns/{subjectKey}.
- Body:
GET /signals/unknowns/{subjectKey}(scope:signals:read) — returns the unknown entries for a subject;404when none.GET /signals/unknowns(scope:signals:read)- Query parameters: optional
band(parsed against theUnknownsBandenum),limit(default 100, clamped 1–1000),offset(default 0). - Response:
200with{ items, count, limit, offset, band }.
- Query parameters: optional
GET /signals/unknowns/{id}/explain(scope:signals:read)- Response:
200with{ id, subjectKey, band, score, normalizationTrace, flags, nextScheduledRescan, rescanAttempts, createdAt, updatedAt };404when the id is unknown.
- Response:
Reachability scoring & facts
POST /signals/reachability/recompute(scope:signals:admin)- Body:
ReachabilityRecomputeRequest(callgraphId,subject,entryPoints[],targets[], optionalruntimeHits[], optionalblockedEdges[]ofReachabilityBlockedEdge{ from, to }, optionalmetadata). - Response:
200 OKwith{ id, callgraphId, subject, entryPoints, states, computedAt }. Validation failures return400(ReachabilityScoringValidationException); unknown callgraph returns404(ReachabilityCallgraphNotFoundException).
- Body:
GET /signals/facts/{subjectKey}(scope:signals:read)- Response:
ReachabilityFactDocument(per-targetstates,score,riskScore,unknownsCount,unknownsPressure, optionaluncertainty, optionalruntimeFactssnapshot andruntimeFactsBatchUri/runtimeFactsBatchHash, optionaledgeBundles,hasQuarantinedEdges);404when not found.
- Response:
Sample fact: docs/api/signals/samples/facts-sample.json
Execution evidence
POST /signals/execution-evidence(scope:signals:write)- Body:
ExecutionEvidenceRequest(artifactIdandenvironmentIdrequired). - Response:
202 Acceptedwith the built evidence andLocation: /signals/execution-evidence/{artifactId}/{environmentId}. Returns400when ids are missing,422when there are insufficient trace events or the pipeline is disabled, and200with{ status: "rate_limited", ... }when rate-limited.
- Body:
Beacon attestation
POST /signals/beacons(scope:signals:write)- Body:
BeaconIngestRequest(events[]— at least one required, else400). - Response:
202 Acceptedwith the ingest response andLocation: /signals/beacons.
- Body:
GET /signals/beacons/rate/{artifactId}/{environmentId}(scope:signals:read)- Response:
200with the verification rate;404when no beacon data exists for the artifact/environment pair.
- Response:
Reachability union bundle ingestion (CAS layout)
POST /signals/reachability/union(scope:signals:write)- Body:
application/zipbundle containingnodes.ndjson,edges.ndjson,meta.json. A non-application/zipContent-Typereturns400. - Optional header:
X-Analysis-Id(defaults to a new GUIDNformat if omitted). - Response:
202 AcceptedwithReachabilityUnionIngestResponse(analysisId,casRoot,files[]of{ path, sha256, records }) andLocation: /signals/reachability/union/{analysisId}/meta.
- Body:
GET /signals/reachability/union/{analysisId}/meta(scope:signals:read) — returnsmeta.json(application/json);404when missing.GET /signals/reachability/union/{analysisId}/files/{fileName}(scope:signals:read) — returns the requested file (application/jsonfor*.json, otherwiseapplication/x-ndjson);404when missing.
Compatibility surface (/api/v1/signals)
These return a fixed, in-memory deterministic sample dataset (operator/console compatibility) and accept either signals:read or orch:read.
GET /api/v1/signals(scope:signals:readororch:read)- Query parameters: optional
type,status,provider,limit(default 50, clamped 1–200),cursor(numeric offset). - Response:
200with{ items, total, cursor }.
- Query parameters: optional
GET /api/v1/signals/stats(scope:signals:readororch:read)- Response:
200with aggregate counts (total,byType,byStatus,byProvider,lastHourCount,successRate,avgProcessingMs).
- Response:
SCM / CI webhooks
Anonymous endpoints (AllowAnonymous()); authentication is per-provider signature/token validation rather than Authority scopes. All are subject to sealed-mode enforcement.
POST /webhooks/github— validatesX-Hub-Signature-256HMAC; readsX-GitHub-Event,X-GitHub-Delivery, optionalX-Integration-Id/X-Tenant-Id.POST /webhooks/gitlab— validatesX-Gitlab-Token; readsX-Gitlab-Event,X-Gitlab-Event-UUID.POST /webhooks/gitea— validatesX-Hub-Signature-256(falls back toX-Hub-Signature); readsX-Gitea-Event,X-Gitea-Delivery.
Responses: 202 Accepted with { eventId, eventType, provider, repository, triggersDispatched, scanTriggersCount, sbomTriggersCount } on success; 200 with a message when the event is ignored; 400/401 on validation failures.
Defined but not currently mapped
The following attribute-routed controllers exist in the Signals assembly but are not served by the Signals host as shipped: Program.cs never calls AddControllers() / MapControllers(), so these routes are not registered and return 404. They are documented here as forward/internal surface only — do not treat them as live API:
RuntimeAgentController/RuntimeFactsController(route baseapi/v1/agents):POST /api/v1/agents/register,POST /api/v1/agents/{agentId}/heartbeat,GET /api/v1/agents/{agentId},GET /api/v1/agents,DELETE /api/v1/agents/{agentId},POST /api/v1/agents/{agentId}/commands,PATCH /api/v1/agents/{agentId}/posture,POST /api/v1/agents/{agentId}/facts.HotSymbolsController(route baseapi/v1/signals):GET /api/v1/signals/hot-symbols,GET /api/v1/signals/hot-symbols/top,GET /api/v1/signals/hot-symbols/stats,GET /api/v1/signals/hot-symbols/correlated.
