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(scope:signals:write; tenant from the token)- Sprint 20260512_020 RO-RUN-003 unified runtime-evidence ingest, consumed by the deployment-observation agent, which posts to Signals directly inside the network — the SOLE ingest target since Q-7 (executed P8-16; the former Findings.Ledger dual-write copy is deleted). Since PCD-865 (owner ruling 2026-10-18) the agent presents an Authority client-credentials token (
signals:write aoc:verify, minted for its tenant) and the route requires it (401without a token,403withoutsignals:write). The observation is written for the token’s tenant, resolved to the canonical UUID through Signals’ own tenants replica (503withRetry-Afterwhile the replica is disabled or cannot answer). A bodytenantIdthat names another tenant is refused with403and nothing is written. The route is withheld from Router publication, so it is not reachable at the gateway. - 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(eventIdnon-empty GUID — the agent-stamped idempotency key, dedupped on(tenant, eventId)so a publish retry cannot double-incrementhit_count; optionaltenantIdGUID that must name the token’s tenant;agentIdGUID,imageDigestsha256:...,containerNamerequired; plusreleaseId,hostId,containerId,containerName,imageRef,captureMode,capturedAt,containerLabels,processList,portBindings,metadata,userStack,kernelStack,stackCaptureMode). Missing/invalideventId/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, which posts to Signals directly inside the network — the SOLE ingest target since Q-7 (executed P8-16; the former Findings.Ledger dual-write copy is deleted). Since PCD-865 (owner ruling 2026-10-18) the agent presents an Authority client-credentials token (
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, 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: 501 Not Implemented problem+json with code source_scan_not_available (or sbom_ingest_not_available) when the event asks for a scan or an SBOM ingest, which Signals cannot dispatch; 200 with a message when the event asks for neither or is not mapped; 400/401 on validation failures. No webhook answers 202.
Withdrawn surfaces
Withdrawn 2026-09-27 (SPRINT_20260927_003 PCD-44). The attribute-routed controllers that were compiled into the Signals assembly but never served (
Program.csmaps no MVC controllers) are withdrawn, and their routes answer404:
RuntimeAgentController/RuntimeFactsController, route baseapi/v1/agents(register, heartbeat, get, list, delete, commands, posture, facts). That base is Release Orchestrator’s agent surface. Runtime observations are ingested atPOST /api/v1/runtime/observationsandPOST /signals/runtime-facts.HotSymbolsController, route baseapi/v1/signals:hot-symbols,hot-symbols/top,hot-symbols/stats,hot-symbols/correlated.
