Router Architecture

Canonical specification for the Stella Ops Router — the single HTTP ingress and binary-transport dispatch layer for the Stella Ops release control plane. Audience: platform and service engineers who register microservices behind the gateway, plus operators configuring transports, routes, and TLS. The Router hosts StellaOps.Gateway.WebService; first-party services reach it over binary transports, never HTTP.

Companion specifications:

Location clarification (updated 2026-03-04). The Router (src/Router/) hosts StellaOps.Gateway.WebService with configurable route tables via GatewayRouteCatalog, reverse proxy support, SPA fallback hosting, WebSocket routing, Valkey messaging transport integration, and StellaOpsRouteResolver for front-door dispatching. This is the canonical deployment for HTTP ingress. The standalone src/Gateway/ was deleted in Sprint 200.

System Architecture

Scope

Transport Architecture

Mounted transport plugins (updated 2026-06-11). Router and Messaging transport bundles are composed at runtime from read-only mounted directories: devops/plugins/router/<profile>/<plugin-id>/ to /app/plugins/router/<profile>/<plugin-id>/ and devops/plugins/messaging/<profile>/<plugin-id>/ to /app/plugins/messaging/<profile>/<plugin-id>/. Trust roots mount at /app/trust-roots/plugins/{router,messaging}. The publish helper supports STELLAOPS_PLUGIN_PACKAGING_MODE=mounted, which skips baking Router/Messaging plugin payloads into service images; legacy image-staged mode remains only as a transition path until all mounted bundles and probes are green. Mounted mode is fail-closed for router-gateway: the publish helper audits the publish context before Docker build and rejects any remaining StellaOps.Router.Transport.* or StellaOps.Messaging.Transport.* implementation payloads. The companion ProjectReference audit reports direct gateway references to those implementation projects as mounted-router-messaging-coupling until the gateway host uses only abstractions plus signed mounted bundles for transport/backend registration.

Current source guard evidence (2026-06-11): docs/qa/feature-checks/runs/pluginized-compose/router-mounted-backend-admission-20260611/gateway-project-reference-audit.json scans the Router runtime host and passes with zero mounted-router-messaging-coupling references. The 2026-06-05 contract slice removed gateway compile-time references to optional TCP, TLS, and Messaging Postgres implementations and made gateway dispatch depend on host-owned interfaces from StellaOps.Router.Common: IGatewayConnectionTransportServer and IGatewayMessagingTransportServer. The 2026-06-06 Valkey diagnostics slice moved token-revocation propagation telemetry behind the transport-agnostic StellaOps.Messaging.Diagnostics contract and lets the Valkey backend plugin register the concrete store/writer. The 2026-06-06 mounted-bundle cutover removed the final StellaOps.Router.Transport.Messaging runtime ProjectReference, stopped defaulting Router/Messaging plugin discovery to image-resident assembly names, and teaches gateway startup to discover signed Router transport child bundles under /app/plugins/router/<profile>/<plugin-id>/. The actual Messaging backend registration path now receives the mounted Router:Messaging:PluginDirectory path via Gateway:Transports:Messaging:PluginDirectory, so a mounted Valkey bundle can be used without a gateway image dependency.

The 2026-06-11 backend admission slice extended the gateway Messaging backend path to pass Gateway:MessagingBackendPlugins:TrustRootPath, Profile, and EnforceSignatureVerification into MessagingPluginLoader and mirrors those values to Gateway:Transports:Messaging. Non-development startup forces signature verification on even if an operator sets the enforcement flag false. package-runtime-plugins.ps1 -Module router -Profile base produces the signed Router Messaging bundle with the dev signer, and -Module messaging -Profile base produces the signed Valkey backend bundle and matching cosign.pub.

This is still not full runtime acceptance. Runtime completion still requires a full compose startup/probe run that exercises static console routing, /connect/*, /api/*, route resync, and one Messaging/Valkey service call. The 2026-06-11 proof bundle refreshed static mount/admissibility evidence and a mounted-mode test image audit, but the live stellaops/router-gateway:dev compose tag and HTTP/runtime probes remain separate acceptance gates. The focused guard GatewayRuntimePluginReferenceGuardTests now freezes the zero-reference state so TCP, TLS, Router Messaging, Postgres, Valkey, or any new transport/backend implementation cannot re-enter the gateway project graph without failing the adjacent test.

Each transport connection carries:

┌─────────────────┐                           ┌─────────────────┐
│   Microservice  │                           │     Gateway     │
│                 │         HELLO             │                 │
│   Identity      │ ─────────────────────────►│   Routing       │
│   - POST /items │         HEARTBEAT         │   State         │
│   - GET /items  │ ◄────────────────────────►│                 │
│   Metadata      │     RESYNC / ENDPOINTS    │   Connections[] │
│   replay        │ ◄────────────────────────►│                 │
│                 │                           │   Connections[] │
│                 │  REQUEST / RESPONSE       │                 │
│                 │ ◄────────────────────────►│                 │
│                 │                           │                 │
│                 │  STREAM_DATA / CANCEL     │                 │
│                 │ ◄────────────────────────►│                 │
└─────────────────┘                           └─────────────────┘

Front Door (Configurable Route Table)

The Router Gateway serves as the single HTTP entry point for the entire Stella Ops platform. In addition to binary transport routing for microservices, it handles:

Route Table Model

Routes are configured in Gateway:Routes as a StellaOpsRoute[] array, evaluated first-match-wins:

public sealed class StellaOpsRoute
{
    public StellaOpsRouteType Type { get; set; }
    public string Path { get; set; } = string.Empty;
    public bool IsRegex { get; set; }
    public string? TranslatesTo { get; set; }
    public Dictionary<string, string> Headers { get; set; } = new();

    // Optional route-level default timeout (e.g. "15s", "2m"); applied when
    // endpoint metadata does not provide a timeout.
    public string? DefaultTimeout { get; set; }

    // When true, the gateway preserves Authorization/DPoP headers instead of
    // stripping them. NOTE: defaults to true. Passthrough still requires the
    // route prefix to be in Gateway:Auth:ApprovedAuthPassthroughPrefixes.
    public bool PreserveAuthHeaders { get; set; } = true;
}

The StellaOpsRouteType enum members are declared in source order Microservice, ReverseProxy, StaticFiles, StaticFile, WebSocket, NotFoundPage, ServerErrorPage.

Route types:

TypeBehavior
ReverseProxyStrip path prefix, forward to TranslatesTo HTTP URL
StaticFilesServe files from TranslatesTo directory, SPA fallback if x-spa-fallback: true header set
StaticFileServe a single file at exact path match
WebSocketBidirectional WebSocket proxy to TranslatesTo ws:// URL
MicroservicePass through to binary transport pipeline
NotFoundPageHTML file served on 404 (after all other middleware)
ServerErrorPageHTML file served on 5xx (after all other middleware)

Reverse proxy is reserved for external/bootstrap surfaces such as OIDC browser flows, Rekor, and frontdoor static assets. First-party Stella API surfaces are expected to use Microservice routing so the gateway remains the single routing authority instead of silently bypassing router registration state.

Regex microservice routes that own a root prefix must use a segment boundary when the same prefix can appear in static asset filenames. The local frontdoor uses ^/policy(?=/|$)(.*) rather than ^/policy(.*) so Angular chunks such as /policy-decisioning.routes-*.js stay on the SPA/static path instead of being misrouted to the Policy service.

The served Console uses Scheduler’s native /api/v1/scheduler/*, not the old /scheduler host-prefix rewrite. PacksRegistry and Notify likewise use their published native roots. RAR-7’s consumer-alias retirement preserves those owners and the new Console in both forward and recovery configurations; it is not a future JobEngine/Notify consolidated-owner cutover. The current binary classifies /api and /v1 misses as API404. A retired non-API /scheduler/* GET may instead receive the exact SPA index as HTML200: that is no API route, not successful API dispatch. Live retirement verification must distinguish the two. The doctor host-prefix entries retired with doctor-web; Platform serves its doctor aggregate at /api/v1/platform/doctor/*.

Percent-encoded path segments are re-escaped before translation. Kestrel percent-DECODES %3F and %23 into HttpRequest.Path while deliberately leaving %2F encoded, so a purl-shaped segment arrives half-decoded (pkg%3Arpm%2Fredhat%2Fbpftool%3Farch%3Daarch64 → pkg:rpm%2Fredhat%2Fbpftool?arch=aarch64). RouteDispatchMiddleware re-escapes those two characters before splicing request-derived text back into TranslatesTo (EscapePathDelimiters): re-parsing the spliced value would otherwise read the bare ? as the start of the query and TRUNCATE the target path there, so the published template stops matching and the request 404s with urn:stellaops:router:endpoint-not-found. The encoded slash was never the problem — a template parameter is [^/]+, which %2F satisfies. The re-escaped form is also what goes on the wire, because the microservice side splits its own path on the first ? (AspNetRouterRequestDispatcher.ParsePathAndQuery) and recovers the value with Uri.UnescapeDataString.

The raw request target travels beside the path (RAR-12, 2026-09-02). Kestrel percent-decodes the request target once into HttpRequest.Path (keeping %2F), so a canonical %252F inside a product key (a literal %2F in an OCI package name) is already %2F in everything the gateway derives from Path, RequestFrame.Path included. Owners that must decode a segment exactly once therefore read IHttpRequestFeature.RawTarget and fail closed without it (the Vulnerabilities Hub’s DecodeExactLastRouteSegment), which turned every gateway-routed product-key read into HTTP 400 The raw route target is unavailable. while the byte-identical direct Kestrel read passed. Contract: RequestFrame.RawTarget (optional envelope field rawTarget) carries the wire target, path plus query string with the percent-encoding intact, with the route prefix translated the same way as the path. RouteDispatchMiddleware records it (RouterHttpContextKeys.RawRequestTarget) whenever it records a translated path: for a regex route it re-matches the WIRE path against the route’s own pattern and splices the wire captures into the target path template, for a prefix route it swaps the prefix on the raw path, so the raw bytes never pass through the Uri parser; a wire path that does not select the route records nothing. TransportDispatchMiddleware forwards the recorded value, forwards IHttpRequestFeature.RawTarget verbatim when no translation applied (published-endpoint routing and identity translations), and sends none for a translated path without a translated raw target. AspNetRouterRequestDispatcher populates IHttpRequestFeature.RawTarget from the frame’s value, and from RequestFrame.Path when none arrived, so an older gateway keeps working and an older service ignores the field. Gateway route matching is unchanged. The fix reaches a running estate only through images: the gateway must be rebuilt to send the raw bytes, and each service must be rebuilt with the new StellaOps.Microservice.AspNetCore to populate the feature.

Verified against source b44e7c9ca3a7d2ca4fb265a2889d7e982a7fc5c2 (2026-09-02), unfiltered suites Router.Common 263/263, Router.Gateway 78/78, Gateway.WebService 864/864, Router.AspNet 115/115, zero skips. Re-verify:

dotnet test src/Router/__Tests/StellaOps.Router.Common.Tests/StellaOps.Router.Common.Tests.csproj
dotnet test src/Router/__Tests/StellaOps.Router.Gateway.Tests/StellaOps.Router.Gateway.Tests.csproj
dotnet test src/Router/__Tests/StellaOps.Gateway.WebService.Tests/StellaOps.Gateway.WebService.Tests.csproj
dotnet test src/Router/__Tests/StellaOps.Router.AspNet.Tests/StellaOps.Router.AspNet.Tests.csproj

The pins are FrameConverterTests.ToRequestFrame_RoundTrip_PreservesRawTarget, RouteDispatchMiddlewareRawTargetTests, the three TransportDispatchMiddlewareTests.Invoke_*RawTarget* cases and AspNetRouterRequestDispatcherTests.DispatchAsync_PopulatesRawTarget_FromTheFrame_PreservingCanonicalEscapes; each was red against the pre-fix source.

Explicit v2 read-model routes must stay ahead of the generic ^/api/v2/{service} matcher. Local compose pins /api/v2/context*, /api/v2/releases*, /api/v2/topology*, /api/v2/evidence*, and /api/v2/integrations* to platform; /api/v2/security* is owned by findings-security. The related /api/risk/aggregated-status path is also routed to findings-security ahead of the Policy risk catch-all.

Deployment-topology setup endpoints are owned and published by ReleaseOrchestrator, not Concelier. Regions, targets, infrastructure bindings, pending deletions and environment/agent setup use published routing. The remaining three static identity pins for environment rename and environment/agent/integration deletion requests are registry-retired at source: the live publication contains all four exact operations, with no matching foreign owner. Their handlers still return explicit 503 topology_rename_unavailable or 503 topology_deletion_unavailable; retiring a Router pin does not implement these operations. Never restore the former broad ^/api/v1/environments(.*) or ^/api/v1/agents(.*) Concelier rules, which shadow unrelated owners.

Platform-owned admin subtrees that do not correspond to a standalone service must likewise stay explicit ahead of the generic ^/api/v1/{service} matcher. In particular, /api/v1/admin/crypto/secret-providers* routes to Platform’s sealed provider-discovery API with authorization headers preserved. (/api/v1/admin/migrations* used to be a second example; that route was deleted from both gateway configs by SPRINT_20260722_026 CM-2 on 2026-08-02 along with the API behind it, and GatewayAdminMigrationsRoutePresenceTests is now an absence guard that fails if it returns.) Without these explicit routes the generic fallback treats admin as a service name and returns a false service-unavailable response.

Operational API families whose first path segment is not their owning service key also require explicit routes ahead of the generic matcher. /api/v1/issues* is owned by Concelier, while /api/v1/excititor/oci-trust-policies* is owned by Excititor and is rewritten to the handler’s canonical /excititor/oci-trust-policies* path. These rules are segment-bound so similarly prefixed paths cannot be captured accidentally.

Scanner’s /api/v1/reports* family also requires an explicit auth-preserving ReverseProxy route to scanner ahead of the generic matcher, matching Scanner’s existing HTTP-forwarded scan data plane. The rule preserves bearer authorization and prevents the generic /api/v1/{service} matcher from treating reports as a standalone service key.

Concelier raw-advisory readback has two explicit frontdoor routes ahead of the SPA/static fallback: /concelier/advisories/raw* is the public module-qualified contract, and /advisories/raw* is retained as a compatibility target for Concelier’s POST Location header. Both strip to Concelier’s canonical /advisories/raw* backend path and preserve auth headers. Broader /api/v1/advisories* traffic remains owned by AdvisoryAI.

Pipeline Order

System paths bypass the configured route table and the microservice pipeline. GatewayRoutes.IsSystemPath covers /health, /health/live, /health/ready, /health/startup, /metrics, /openapi.json, /openapi.yaml, /.well-known/openapi, /api/openapi/aggregate, the product-metadata endpoints (/.well-known/security.txt, /.well-known/stella-product-security.json, /api/v1/product/support-lifecycle), the router admin/operator surfaces (/api/v1/gateway/administration/router/resync, /api/v1/router/routing/weights, /api/v1/router/instances, /api/v1/operator/verify/*), and /buildinfo.json.

Actual middleware order from Program.cs (HTTPS redirect first, then):

CorrelationIdMiddleware
SecurityHeadersMiddleware
UseStellaOpsCors
UseAuthentication
SenderConstraintMiddleware
IdentityHeaderPolicyMiddleware   ← strips reserved identity headers, signs envelope
UseAuthorization
HealthCheckMiddleware            ← /health, /metrics
TryUseStellaRouter
UseWebSockets
ProductMetadataMiddleware        ← fail-closed product-security/lifecycle metadata
RouteDispatchMiddleware          ← static files, reverse proxy, websocket
MapGatewayOpenApiAggregator / MapGatewayBuildInfoAggregator / MapVerifyEndpoints /
  MapRoutingWeightEndpoints / MapRouterInstancesEndpoints / MapGatewayPluginProbeEndpoints /
  MapRouterAdministrationEndpoints
UseWhen(non-system):             ← microservice pipeline
  RequestLoggingMiddleware → GlobalErrorHandlerMiddleware → PayloadLimitsMiddleware →
  EndpointResolutionMiddleware → AuthorizationMiddleware → UseRateLimiting →
  RoutingDecisionMiddleware → RequestRoutingMiddleware
ErrorPageFallbackMiddleware      ← custom 404/500 pages

Published-Endpoint Routing (the mint layer)

Every connected microservice sends its full endpoint list in its HELLO payload, and GatewayHostedService indexes it into IGlobalRoutingState. Those published endpoints can serve as a routing source in their own right, so a service needs no static route-table entry for its own paths.

The layer is gated by Gateway:Routing:PublishedEndpointRouting(GatewayRoutingOptions.PublishedEndpointRouting, default false). While it is off, the static route table alone decides every request — the shipped behaviour.

When it is on, RouteDispatchMiddleware consults IPublishedEndpointRouter for any request that reached the SPA catch-all (a StaticFiles route carrying x-spa-fallback: true) and, on a hit, hands the request to the microservice pipeline instead of serving index.html. Precedence:

  1. Explicit static route — matched by StellaOpsRouteResolver first-match, so operator intent always wins. Flipping a route to publication means deleting its static entry.
  2. A file on disk under the static route’s directory (console assets).
  3. A published endpoint for the request’s method and path.
  4. The SPA catch-all, then the error pages.

Browser navigation (IsBrowserNavigation: GET, extension-less, Accept: text/html, not an /api|/v1 path) is never diverted, so console deep links keep resolving to the shell. Conversely, with the flag on an unmatched /api|/v1 path falls through to the pipeline and gets its JSON 404 rather than a 200 index.html.

The configured Console directory is optional to API dispatch. If the SPA catch-all’s directory is absent, published API requests still traverse endpoint resolution, authorization, rate limiting and transport; missing Console assets return 404. Removing and restoring the directory does not require restarting the gateway. Explicit route precedence and the published-routing feature switch remain unchanged.

The bare root can never mint (GatewayOwnedPaths.IsRoot, refused at both the divert decision and inside PublishedEndpointRouter before the routing state is consulted). Services legitimately map root banners on their own surfaces — signer’s GET / readiness banner, policy-engine’s GET / → /healthz redirect — and endpoint discovery publishes them; without this refusal a non-browser GET / at the gateway was diverted away from the console to whichever publisher won specificity/health ranking (live incident, 2026-08-11). The estate’s front door belongs to the gateway’s SPA fallback structurally, no matter what any service HELLO-declares.

Minted requests traverse the same EndpointResolutionMiddleware → AuthorizationMiddleware → rate limit → dispatch chain as Microservice-type static routes, so HELLO-published RequiringClaims (plus Authority overrides) are enforced at the gateway. This is a behaviour change for any path previously served by a ReverseProxy entry, which bypasses gateway endpoint-claims authorization entirely.

When several published templates match one path, PathTemplateSpecificity ranks them: most literal segments first, then fewest parameters, then longer template, then health rank, heartbeat and connection id. Raw template length alone is not a specificity signal — a placeholder is usually longer than the literal it competes with, which let /api/v1/lineage/{artifactDigest} out-rank /api/v1/lineage/export. Two services publishing the identical template (for example the shared /catalog-changes/{catalog} wire, whose owner depends on the value) cannot be separated by publication at all; those keep an explicit static pin.

Static route remainder contract (target end state)

The owner ruling behind SPRINT_20260809_001: the static route table holds ONLY what publication cannot express — everything else is runtime truth from HELLO. The contractual remainder classes, each with its static-reason:

ClassEntriesWhy publication cannot express it
Gateway-localStaticFiles SPA catch-all, NotFoundPage, ServerErrorPageserved by the gateway itself, no microservice behind them
External targetrekor:3322not a connected microservice; genuine outbound HTTP hop
OIDC / browser front-door8 authority entries (/connect, /console, /authority, …)raw-JWT/browser flows that must terminate on authority’s own HTTP surface. /.well-known is the exception and is no longer permanent (2026-08-28): authority declares GET /.well-known/openid-configuration as a real endpoint and the bridge now publishes it, so that entry survives only until a gateway cold-start proves the minted path serves it — services fetch OIDC metadata from the router at startup, before authority’s HELLO may have arrived
Identical-template collisions3 /catalog-changes/* pinsone shared CatalogWire template published by multiple hosts with value-dependent ownership — specificity cannot rank them
Advertised-contract paths/api/registry/tokenexternal Docker/ORAS clients follow the realm the registry advertises in WWW-Authenticate; not repointable code
Unbounded streaming^/api/vulnerabilities/v1/corpus/export$, ^/api/vulnerabilities/v1/binaries/build-id-index$these exact owner artifacts remain HTTP streams; AspNetRouterRequestDispatcher captures the entire body in one frame, and GatewayTransportClient.SendStreamingAsync collects chunks before exposing its response stream (VulnerabilitiesHubRouteConfigTests)
Signer custody transport/api/v1/signerthe current DPoP/auth-passthrough contract explicitly requires ReverseProxy (OfflineKitSignerCustodyRouteConfigTests); it is not a not-yet-live deletion candidate
Surviving B pinsany path-rewrite whose consumers have not yet repointeda HELLO-published endpoint cannot express a rewrite; each entry dies when its consumers land

Working target: <= ~23 entries, each carrying a verified static reason. BIN-8 adds the exact build-id index export to the transport-unsuitable exceptions, preserving the bearer and bare owner origin; neighboring binary paths still resolve through publication. The former ~19 estimate omitted the required corpus-stream and Signer transport pins; the later21 count incorrectly left the explicitly pinned /connect/revocation OIDC front-door among pending rewrites. This is a measured working bound, not permission to delete a required route to meet a number; bootstrap/browser rows can shrink only after their own live behavior is proven. The release bundle ships exactly this remainder; the rest of the table is runtime truth.

The target was recorded as ~15 until 2026-08-29, when the classes above were counted against the real table for the first time. The four rows the enumeration had missed are /issuer-directory, /platform, /platform/envsettings.json and ^/api/v1/tenants(.*) — all browser/SPA front-door ReverseProxy rows. Their permanence is asserted, not established: publication does express three of them (issuerdirectory declares 14 paths under /issuer-directory, platform declares 12 under /platform including envsettings.json), so the reason they stay is that deleting a ReverseProxy row moves its traffic onto the binary Microservice transport, not that HELLO cannot carry the path. That is a live behaviour change for browser flows and needs a window, not a config edit. The same caveat applies to the eight OIDC rows: authority publishes /connect/*, /console/*, /authority/* and /jwks too — their static-reason is the browser-flow termination requirement, which is independent of whether the path is published.

Source and live runtime (2026-08-31): appsettings.json and the generated devops/compose/router-gateway-local.json and devops/release/bundle/config/router-gateway.json are in lockstep at 49 routes (21 Microservice, 25 ReverseProxy, 3 gateway-local). The five new removals are the three Notifier aliases, Pack Registry alias and /scheduler host prefix. Native Console32907 reads are deployed/verified; strict-TLS PRE proves all five native/alias reads200 and owner-attributed401/403 denials. Each native GET has one published owner and no operation publishes the retired prefixes. Gateway-only replay and POST now pass: all 20 native/denial/retired-path checks and all six browser observations, with healthy runtime, zero restarts, unchanged non-target identities and a passing recovery preflight (not executed). The unchanged accepted Gateway binary is clean 4bcb645bd03643c7941de4c935873e061b103940. (2026-09-02: the accepted Gateway binary was re-derived to clean 409106c5d4 after the RAR-12 live swap; the canonical replay profile carries the per-service pin. See docs/runbooks/router/local-runtime-replay.md.) The immediate route-recovery profile retains exact54 configuration and the new Console; restoring the old Console after alias retirement is not a valid recovery. Earlier topology and collision retirement evidence remains in the topology receipt and sprint. Consumer proof: Console native reads. Live retirement: 49-route receipt. Pending repository CI is reported separately; this is not SCN acceptance. Re-verify source and the live boundary separately with tools/scripts/validate/check-local-runtime-sources.cjs and the local runtime replay procedure. The three host-capture fallbacks (^/api/v1/([^/]+)(.*), ^/api/v2/([^/]+)(.*), bare /api) are gone with them, so a freed path now reaches the mint layer instead of being captured and dispatched with its first path segment as a service hint.

Source classification (2026-08-31): the49 rows decompose as 22 protected remainder rows (including the OIDC revocation rewrite), and 27 still to go: 14 path-rewrites awaiting verified consumer retirement (RAR-6), 2 rows a registered service does not publish, 9 rows whose target service is not registered with the gateway at all (attestor 5, evidencelocker 4), 1 same-template collision, and 1 absent OfflineKit deployment. RAR-7 removed the other five unpublished rows, six resolved collisions and the one remaining covered row. 22 + 27 = 49. No protected row was removed to satisfy a numerical target. Corpus export was previously misclassified as a rewrite even though its bare-origin target preserves the incoming path; Signer was misclassified as an unverified deletion candidate. Per-row disposition and the census ratchet: docs/implplan/SPRINT_20260809_001_Router_route_autopublish_realignment.md; the per-row classification is docs/implplan/_evidence/2026-08-28-rar5-coverage-classification.md. Do not treat the class table above as a description of today’s config; it is the contract the migration converges to.

Re-verify: python3 -c "import json;print(len(json.load(open('devops/compose/router-gateway-local.json'))['Gateway']['Routes']))"

Regulatory Product Metadata Ingress

Router owns the externally visible product-security metadata endpoints before static console fallback and before generic /api/* dispatch:

The endpoints are default-off and fail closed with 503 and an empty body when publication is disabled, sealed metadata config is absent, operational mailbox, key, placement, lifecycle, rotation, or escalation preflight is incomplete, the release manifest is missing, or manifest verification has not been recorded for the running product image. Published responses are generated from the sealed release metadata and carry strong ETags over the exact response bytes.

Router also owns regulatory feed minimisation at the ingress boundary. Route table entries opt in with the sealed route headers X-StellaOps-Regulatory-Feed: router-regulatory-feed-route-v1 and X-StellaOps-Regulatory-Purpose: <purpose>. The supported purpose values match Notify’s channel contract: general, dora-incident, dora-roi-distribution, dora-info-sharing, nis2-csirt, and enisa-cra. When a request hits an opted-in route, Router rewrites or replaces the downstream purpose query parameter before dispatching to Notify. Unknown or legacy route purposes fail closed to Notify’s backward-compatible general filter instead of leaking a regulatory feed to the wrong purpose.

Router does not duplicate Notify channel persistence. It only applies the sealed ingress purpose, forwards the normalized purpose to Notify, and emits a startup audit event using the shared EU catalog event name evidence.eu.public_metadata.published with a hash of the regulatory feed route table.

Docker Architecture

Browser → Router Gateway (port 80) → [microservices via binary transport]
                                    → [HTTP backends via reverse proxy]
                                    → [Angular SPA from /app/wwwroot]

Development compose may bind-mount the Angular dist at /app/wwwroot. Release images bake the verified dist directly into router-gateway at that same path; there is no separate console image or console-builder service in the release bundle.

When the gateway runs in-container, listener binding must honor explicit ASPNETCORE_URLS / ASPNETCORE_HTTP_PORTS / ASPNETCORE_HTTPS_PORTS values from compose. Wildcard hosts (+, *) are normalized to 0.0.0.0 before Kestrel listeners are created so the declared HTTP frontdoor contract actually comes up.


Service Identity

Instance Identity

Each microservice instance is identified by:

FieldTypeDescription
InstanceIdstringUnique instance identifier
ServiceNamestringLogical service name (e.g., “billing”)
VersionstringSemantic version (major.minor.patch)
RegionstringDeployment region (e.g., “us-east-1”)
WeightintRouting weight for intra-tier weighted canary selection (default 100)

Version Matching

Region Configuration

Gateway node identity comes from GatewayNodeOptions (bound from the Gateway:Node configuration section), which the host maps onto RouterNodeConfig (the routing-plugin’s node config; section name GatewayNode, legacy Router:Node):

public sealed class GatewayNodeOptions          // bound from "Gateway:Node"
{
    public string Region { get; set; } = "local";
    public string NodeId { get; set; } = string.Empty;
    public string Environment { get; set; } = "dev";
    public List<string> NeighborRegions { get; set; } = new();
}

RouterNodeConfig.Region is [Required]; the gateway fails to start without a region. NodeId is auto-generated as gw-<region>-<8hex> when left blank. NeighborRegions drives the Tier 1 region fallback in routing. Region is never derived from HTTP headers or URL hostnames.


Endpoint Model

Endpoint Identity

Endpoint identity is (HTTP Method, Path):

FieldExample
MethodGET, POST, PUT, PATCH, DELETE
Path/invoices, /items/{id}, /users/{userId}/orders

Endpoint Descriptor

Each endpoint includes:

public sealed record EndpointDescriptor
{
    public required string ServiceName { get; init; }
    public required string Version { get; init; }
    public required string Method { get; init; }
    public required string Path { get; init; }
    public TimeSpan DefaultTimeout { get; init; } = TimeSpan.FromSeconds(30);
    public IReadOnlyList<ClaimRequirement> RequiringClaims { get; init; } = [];
    public bool AllowAnonymous { get; init; }
    public bool RequiresAuthentication { get; init; }
    public IReadOnlyList<string> AuthorizationPolicies { get; init; } = [];
    public IReadOnlyList<string> Roles { get; init; } = [];
    public EndpointAuthorizationSource AuthorizationSource { get; init; } = EndpointAuthorizationSource.None;
    public bool SupportsStreaming { get; init; }
    public Type? HandlerType { get; init; }
    public EndpointSchemaInfo? SchemaInfo { get; init; }
}

EndpointDescriptor is a record (not a class) and carries richer authorization metadata than just RequiringClaims: discovered named policies, role names, an explicit anonymous/authenticated flag, and the AuthorizationSource (None, AspNetMetadata, YamlOverride, Hybrid, AuthorityOverride) that records where the authorization metadata was resolved from.

Path Matching


Routing Algorithm

Instance Selection

Given (ServiceName, Version, Method, Path):

  1. Filter candidates (DefaultRoutingPlugin):

    • Match ServiceName exactly
    • Match Version exactly (strict semver when StrictVersionMatching, which is the default; the requested version falls back to RoutingOptions.DefaultVersion when the caller omits it)
    • Draining instances are always excluded from new routing (they may only finish in-flight work)
    • Prefer Healthy; fall back to Degraded only when AllowDegradedInstances is enabled and no healthy instance remains
  2. Region preference (only when PreferLocalRegion and a gateway region are set):

    • Tier 0: instances where Region == RouterNodeConfig.Region
    • Tier 1: configured NeighborRegions
    • Tier 2: all other regions
  3. Within the best non-empty region tier:

    • Prefer lower AveragePingMs (within PingToleranceMs)
    • If tied, prefer more recent LastHeartbeatUtc
    • If multiple remain tied and they advertise differing non-default effective Weight values, use weighted random selection (canary)
    • Otherwise apply the configured tie-breaker: RoundRobin (per-service counter) or Random (default)

Instance Health

public enum InstanceHealthStatus
{
    Unknown,
    Healthy,
    Degraded,
    Draining,
    Unhealthy
}

Health metadata per connection:

FieldTypeDescription
StatusenumCurrent health status
LastHeartbeatUtcDateTimeLast heartbeat timestamp
AveragePingMsdoubleAverage round-trip latency

Transport Layer

Transport Types

TransportUse CaseStreamingNotes
InMemoryTestingYesIn-process channels
TCPDev-onlyYesLength-prefixed frames; gated by STELLAOPS_ROUTER_ALLOW_INSECURE_TRANSPORTS=true (see Insecure transport gate)
TLSSecure / ProductionYesCertificate-based encryption; strict validation (see TLS validation contract)
UDPDev-onlyNoSingle datagram per frame; gated by STELLAOPS_ROUTER_ALLOW_INSECURE_TRANSPORTS=true
RabbitMQQueuingYesExchange/queue routing; envelope-verified
Messaging (Valkey/Postgres)ProductionYesAt-least-once delivery; envelope-verified (DLQ on failure)

StellaOps.Router.Gateway is a legacy gateway library and is fail-closed by default: AddRouterGateway(...) does not register StellaOps.Router.Transport.InMemory or any other transport implicitly. Local process-only harnesses must opt in with AddRouterGatewayLocalInMemoryHarness(...), which is gated to Development, Testing, or Local host environments. Production ingress uses StellaOps.Gateway.WebService with configuration-driven transport activation.

Frame-layer Envelope HMAC

Every Request / Response / RequestStreamData / ResponseStreamData frame crossing a router transport carries an application-layer envelope MAC in addition to whatever transport-level integrity exists. Audit finding C9 in microservice-audit-pass2-2026-04-29.md recorded that messaging-transport consumers previously dispatched RPC payloads without verifying identity at the application layer, and finding C7 noted that TCP/UDP transports carry frames in cleartext. Sprint SPRINT_20260501_012 addresses both by adding a transport-agnostic frame-layer envelope.

Wire format. The seal is a sidecar struct travelling alongside the frame body, not a JSON wrapper around the payload. Stamped by StellaOps.Router.Common.Frames.HmacFrameEnvelopeSigner, verified by HmacFrameEnvelopeVerifier:

public sealed record FrameEnvelopeSeal {
    string KeyId;            // Key id; supports rotation via multiple loaded keys.
    string Algorithm;        // "HS256" — same marker used by GatewayIdentityEnvelope.
    DateTimeOffset IssuedAtUtc;
    string Mac;              // Base64Url-encoded HMAC-SHA256 output.
}

The seal hangs off Frame.EnvelopeSeal for in-process flow and is carried across transports as follows:

MAC input. Canonical bytes produced by FrameCanonicalBytes.Compute: explicit length-prefixed binary form with magic header FECB, version, frame type, correlation id, key id, and Unix-ms timestamp, payload bytes. Big-endian and length-delimited so the encoding is deterministic across processes / OS / language. JSON serialisation is not used — JSON property ordering is implementation-defined and would break sealing.

Key material. Configured via FrameEnvelopeOptions.Keys (a key-id -> UTF-8 secret map) bound from Router:Frame:Envelope. The Messaging and RabbitMQ transport plugins activate HmacFrameEnvelopeSigner plus HmacFrameEnvelopeVerifier when that section is present; a present but invalid section fails host registration rather than silently reverting to unsigned frames. The active key id on the signer side is FrameEnvelopeOptions.ActiveKeyId; the verifier trusts the key id carried inside the seal as long as it appears in the loaded Keys map. Rotation: load the new key on every host first, then flip ActiveKeyId on the gateway. The HMAC routes through StellaOps.Cryptography.IHmacAlgorithm so regional crypto plugins (FIPS, GOST, SM, eIDAS) can substitute the implementation; direct HMACSHA256 calls in product code are forbidden by the conformance test in src/Cryptography/__Tests/StellaOps.Cryptography.Tests/HmacBclCallSiteConformanceTests.cs.

Skew window. Default ±5 minutes (FrameEnvelopeOptions.ReplayWindow). Symmetric — past and future timestamps are checked. Air-gap deployments with significant clock drift may widen the window via configuration; document the chosen value in your operations runbook.

Failure modes. Verification rejects with a FrameEnvelopeRejectedException carrying a FrameEnvelopeRejectionReason: Missing, UnknownKeyId, UnsupportedAlgorithm, Malformed, TimestampOutOfWindow, MacMismatch, UnsupportedCanonicalVersion. The exception attaches structured metadata under keys frame.envelope.reason, frame.envelope.keyId, frame.envelope.issuedAt. The router_frame_envelope_verify_failed_total{reason} counter is incremented on each failure; the failure is logged at warning level with structured fields. On the messaging transport the forged frame is dead-lettered with reason envelope-invalid:<reason>. On RabbitMq the frame is dropped (autoAck=true) and the counter / log capture the audit.

Migration note. Existing dev compose deployments with unsigned frames in transit must drain queues before upgrading; otherwise legacy frames will hit the DLQ. Every supported Compose surface binds ActiveKeyId=primary and Keys:primary from the same value as STELLAOPS_IDENTITY_ENVELOPE_SIGNING_KEY: the canonical gateway and Router microservices, Findings Security canary, Tester, SmRemote, and the supported legacy monolith. The runtime-posture validator rejects any of these surfaces when its Router identity-envelope mapping lacks that matching frame-envelope pair. Custom hosts that omit FrameEnvelopeOptions retain the documented bootstrap posture: the messaging server / client log a startup warning and operate in verifier-disabled mode (no rejection); this is the documented bootstrap posture but must not persist into production. Setting only part of the section is not a compatibility escape hatch: registration fails closed when the active key, key material, or replay window is invalid.

Insecure Transport Gate (TCP/UDP)

Sprint SPRINT_20260501_012 demoted the TCP and UDP transports to dev-only. Their plugins (TcpTransportPlugin, UdpTransportPlugin) and registration helpers (AddTcpTransport*, AddUdpTransport*) call TcpTransportPlugin.IsInsecureTransportsAllowed() / UdpTransportPlugin.IsInsecureTransportsAllowed() at registration time and throw InvalidOperationException unless the environment variable STELLAOPS_ROUTER_ALLOW_INSECURE_TRANSPORTS is set to a truthy value (true, 1, yes, case-insensitive).

When the override is in effect, the guard logs a loud warning indicating that TCP/UDP transports are unencrypted and replayable on the network layer; the override only relaxes the confidentiality and non-replay defenses. Frames over TCP/UDP still gain application-layer integrity through the frame-layer envelope HMAC described above.

Production composes (devops/compose/docker-compose.stella-services.yml) must not load these plugins. Migrate to StellaOps.Router.Transport.Tls or StellaOps.Router.Transport.Messaging for production traffic.

Signed Transport Plugin Loader (Sprint 20260501-005)

Sprint SPRINT_20260501_005 closed Pass-2 audit finding B5: the gateway transport plugin loader at src/Router/StellaOps.Gateway.WebService/Program.cs previously loaded any DLL matching StellaOps.Router.Transport.*.dll from plugins/router/transports/ with no signature verification. The gateway is the single public entry point of Stella Ops and therefore the highest- blast-radius plugin surface in the platform.

The loader (RouterTransportPluginLoader) now requires:

  1. A signed manifest at plugins/router/transports/transports.signed.json that pins, per file name, the SHA-256 of the on-disk DLL and the leaf signing thumbprint. The manifest schema is stellaops.router.transports/v1 and is documented in RouterTransportManifest.
  2. A valid X.509 chain from the manifest’s leaf signing certificate to the Stella-internal CA bundle distributed via the offline kit. The chain-build path reuses IPluginSignatureValidator / X509PinnedSignatureValidator from PSV-02 / PSV-03 — there is one validator implementation across the entire platform.
  3. A directory whose *.dll files are an exact match for the manifest’s transports[] list. Any extra DLL fails the entire load closed (no pinned DLLs are loaded either) — this defends against drop-in attacks while a signature is being rotated.
  4. Per-DLL SHA-256 match against the pinned digest. A mismatch fails the entire load closed with a ListedDllDigestMismatch rejection.
sequenceDiagram
    participant Op as Operator
    participant FS as plugins/router/transports/
    participant Loader as RouterTransportPluginLoader
    participant Trust as IPluginTrustRoot
    participant Val as X509PinnedSignatureValidator

    Op->>FS: drop *.dll + transports.signed.json
    Loader->>FS: open transports.signed.json
    Loader->>Loader: parse + validate schema shape
    Loader->>Val: ValidateAsync(canonicalBytes, signedJson, TrustRoot)
    Val->>Trust: GetAnchors()
    Val-->>Loader: PluginSignatureValidationResult
    Loader->>Loader: enforce AllowedSignerThumbprints policy
    Loader->>FS: enumerate *.dll
    Loader->>Loader: extra DLL? → fail-closed
    loop for each pinned entry
        Loader->>FS: read DLL bytes
        Loader->>Loader: SHA-256 vs manifest pin
    end
    Loader->>Loader: Assembly.LoadFrom each accepted DLL
    Loader-->>Op: RouterTransportTrustReport

Gateway.WebService/Program.cs reads the policy from configuration:

Gateway:
  TransportPlugins:
    Directory: "plugins/router/transports/"
    TrustRootDirectory: "trust-roots/plugins/"
    AllowedSignerThumbprints: ["…"]
    RequireSignedManifest: true   # default; only false when ASPNETCORE_ENVIRONMENT=Development

RequireSignedManifest=false is honoured only when IHostEnvironment.IsDevelopment(); in any other environment the override is rejected with a fatal log line and the value is forced back to true.

Threat-model footnote — AppDomain-resident transports. The transports loaded by the build system into the host AppDomain (TCP, TLS, Messaging, in-memory) are not verified at runtime by this pipeline. They ride the gateway image’s own signature: Authenticode on Windows, signed RPM/DEB on Linux, signed container image on container deploys. The signed-manifest pipeline only governs disk-resident drop-in transport plugins.

Gateway startup treats a missing or empty plugins/router/transports/ directory as “no operator-managed drop-ins configured” and continues with AppDomain-resident transports only. If the directory contains transports.signed.json or any DLL matching the configured Router transport glob, the signed-manifest pipeline is mandatory and still fails closed on a missing, malformed, unsigned, or mismatched manifest. Shared web-service publish targets therefore do not copy Stella’s built-in Router transport DLLs into plugins/router/transports/ by default; signed drop-in packaging requires an explicit MSBuild opt-in.

Trust-root sharing with the platform plugin pipeline. The IPluginTrustRoot registered in the gateway’s DI container is the same PEM-directory bundle distributed to the platform plugin host (PSV-05). Trust- root rotation is therefore one offline-kit operation that updates both pipelines.

Stella-internal signers only. The pre-prod plan assumes AllowedSignerThumbprints only ever contains Stella-internal CA-issued leaf thumbprints. Third-party transport plugins are out of scope for v1; the schema can be extended (per-tenant allowlists, expiry dates) when that business case lands. See the sprint’s Decisions & Risks for the open product question.

Release signing path. devops/docker/sign-router-transports keeps its local-key-generating development mode, but release engineering uses its external-key mode: --signer-pfx, --pfx-password-file, and --trust-root-pem are all mandatory and --keys-dir is forbidden. The tool writes the release trust root, signs the canonical manifest, then immediately runs RouterTransportTrustPipeline over the produced directory; a chain, signature, allowlist-shape, extra-DLL, or digest failure aborts preparation. Private release material remains outside the source repository.

Operator workflow. See docs/runbooks/router/add-router-transport.md for the build-and-deploy steps. Operators can pre-validate a directory drop without restarting the gateway via stella router transports verify (documented in docs/modules/cli/operations/router-transports.md).

Transport Plugin Interface

public interface ITransportServer
{
    Task StartAsync(CancellationToken ct);
    Task StopAsync(CancellationToken ct);
    event Func<ConnectionState, HelloPayload, Task> OnHelloReceived;
    event Func<ConnectionState, HeartbeatPayload, Task> OnHeartbeatReceived;
    event Func<string, Task> OnConnectionClosed;
}

public interface ITransportClient
{
    Task ConnectAsync(CancellationToken ct);
    Task DisconnectAsync(CancellationToken ct);
    Task SendFrameAsync(Frame frame, CancellationToken ct);
}

TLS Validation Contract

The TLS transport (StellaOps.Router.Transport.Tls) enforces a strict validation contract on every TLS handshake — server-side (mTLS clients) and client-side (microservices validating the gateway). Audit finding C8 in microservice-audit-pass2-2026-04-29.md recorded the previous permissive behaviour and led to this rewrite (see also SPRINT_20260501_013).

Validation rules, in evaluation order:

  1. Required-but-missing. When the role requires a peer certificate and none was presented, reject (certificate_required_but_missing).
  2. RemoteCertificateNotAvailable. Reject (remote_certificate_not_available).
  3. RemoteCertificateNameMismatch is always fatal. Even with AllowSelfSigned=true and a matching pin, a hostname mismatch is rejected unconditionally (name_mismatch). This is the central C8 fix; the previous validator accepted name mismatch when it was the only error.
  4. RemoteCertificateChainErrors:
    • When AllowSelfSigned=false: reject (chain_error_and_self_signed_disallowed).
    • When AllowSelfSigned=true:
      • Accept only if the chain status is exactly UntrustedRoot or PartialChain (no other status flags), otherwise reject (chain_status_not_permitted).
      • The certificate must match at least one entry from the configured pin set (thumbprint OR subject OR SAN; pin matching uses StringComparer.Ordinal on a normalised SHA-256 hex thumbprint). Otherwise reject (pin_mismatch).
      • If the pin set is empty at runtime (should already have failed Options.Validate() at startup), reject (pin_set_empty).
  5. No errors: accept.

Pinning options live on TlsTransportOptions.CertificatePinning:

FieldTypeNotes
ThumbprintsSha256string[]Recommended tightest posture (per-leaf). Hex (case-insensitive on input, normalised); separators :, -, are stripped.
AllowedSubjectsstring[]Exact RFC-2253 form (e.g. CN=service-a, O=stellaops). Allows leaf rotation under a private CA without re-pinning.
AllowedSansstring[]Exact DNS-name SAN values; no wildcard expansion.

Startup validation. Constructing TlsTransportServer or TlsTransportClient with AllowSelfSigned=true and an empty pin set throws InvalidOperationException referencing audit C8. This is intentionally breaking: any compose file that relied on the old permissive validator must add a pin set or set AllowSelfSigned=false. See the worked example for compose syntax.

Telemetry. Each rejection increments router_tls_validate_rejected_total{reason, role} (meter StellaOps.Router.Transport.Tls) and emits a structured-log warning with the reason field. role is either client_certificate or server_certificate.

Frame Types

public enum FrameType
{
    Hello,
    Heartbeat,
    Request,
    Response,
    RequestStreamData,
    ResponseStreamData,
    Cancel,
    ResyncRequest,
    EndpointsUpdate,
    Goodbye
}

The enum uses the default int backing type with implicit zero-based ordinals (no explicit : byte or numeric assignments). Goodbye is sent by a microservice immediately before shutdown to request prompt deregistration without waiting for lease expiry.


Gateway Pipeline

HTTP Middleware Stack

Request ─►│ ForwardedHeaders        │
          │ RequestLogging          │
          │ ErrorHandling           │
          │ Authentication          │
          │ EndpointResolution      │  ◄── (Method, Path) → EndpointDescriptor
          │ Authorization           │  ◄── RequiringClaims check
          │ RoutingDecision         │  ◄── Select connection/instance
          │ TransportDispatch       │  ◄── Send to microservice
          ▼

Identity Header Policy and Tenant Selection

Downstream Service Trust Mode (GatewayAuthorizationTrustMode)

The rules above are gateway-side. How a microservice decides to trust the identity it receives is a separate knob: StellaRouterBridgeOptions.AuthorizationTrustMode (StellaOps.Microservice.AspNetCore/StellaRouterBridgeOptions.cs:95-96, mirrored on StellaRouterOptions.cs:79-80). It has three modes (StellaRouterBridgeOptions.cs:208-226):

ModeBehaviour in AspNetRouterRequestDispatcher.PopulateIdentity (:237-258)
ServiceEnforcedEnvelope is never consulted; gateway headers are best-effort context and the service authorizes independently.
Hybrid(default)Prefers the signed identity envelope; if the envelope is absent or invalid, falls back to trusting the raw X-StellaOps-Actor / -TenantId / -Scopes / -Roles headers (PopulateIdentityFromHeaders, :384-432).
GatewayEnforcedRequires a valid gateway-signed envelope; a missing/invalid envelope is rejected (fails closed). This is the only fail-closed mode.

Security posture — read this before leaving the default. Under the default Hybrid mode, a malformed or absent envelope does not produce a 401; it silently degrades to header trust. The reverse-proxy envelope middleware reinforces this: on a bad envelope it never throws and continues unauthenticated (IdentityEnvelopeMiddlewareExtensions.cs:158-162), leaving downstream authorization as the gate. This is acceptable only because those frames arrive over the internal binary transport, whose integrity is a separate mechanism — the frame-layer envelope HMAC (see Frame-layer Envelope HMAC). Note the compounding failure mode: that HMAC runs in verifier-disabled bootstrap mode until FrameEnvelopeOptions is bound on a host (see the Migration note in that section). When the identity envelope is absent and the frame verifier is unbound, any participant on the bus can assert an arbitrary actor, tenant, scope, or role set purely via headers — including the stellaops:tenant value that tenancy is derived from. Bind FrameEnvelopeOptions and prefer GatewayEnforced for services handling tenant-scoped or privileged data.

Connection State

Per-connection state maintained by Gateway:

public sealed class ConnectionState
{
    public required string ConnectionId { get; init; }
    public required InstanceDescriptor Instance { get; init; }
    public InstanceHealthStatus Status { get; set; } = InstanceHealthStatus.Unknown;
    public DateTime ConnectedAtUtc { get; init; } = DateTime.UtcNow;
    public DateTime LastHeartbeatUtc { get; set; } = DateTime.UtcNow;
    public double AveragePingMs { get; set; }
    public Dictionary<(string Method, string Path), EndpointDescriptor> Endpoints { get; } = new();
    public required TransportType TransportType { get; init; }
    public IReadOnlyDictionary<string, SchemaDefinition> Schemas { get; init; } = new Dictionary<string, SchemaDefinition>();
    public ServiceOpenApiInfo? OpenApiInfo { get; init; }
}

LastHeartbeatUtc is a non-nullable DateTime (initialised to the connection time); TransportType is required.

Payload Handling

The Gateway treats bodies as opaque byte sequences:

Payload Limits

Configurable limits protect against resource exhaustion:

LimitScope
MaxRequestBytesPerCallSingle request
MaxRequestBytesPerConnectionAll requests on connection
MaxAggregateInflightBytesAll in-flight across gateway

Exceeded limits result in:


Microservice SDK

Configuration

services.AddStellaMicroservice(options =>
{
    options.ServiceName = "billing";
    options.Version = "1.0.0";
    options.Region = "us-east-1";
    options.InstanceId = Guid.NewGuid().ToString();
    options.ServiceDescription = "Invoice processing service";
});

Endpoint Declaration

Attributes:

[StellaEndpoint("POST", "/invoices")]
public sealed class CreateInvoiceEndpoint : IStellaEndpoint<CreateInvoiceRequest, CreateInvoiceResponse>

Handler Interfaces

Typed handler (JSON serialization):

public interface IStellaEndpoint<TRequest, TResponse>
{
    Task<TResponse> HandleAsync(TRequest request, CancellationToken ct);
}

public interface IStellaEndpoint<TResponse>
{
    Task<TResponse> HandleAsync(CancellationToken ct);
}

Raw handler (streaming):

public interface IRawStellaEndpoint
{
    Task<RawResponse> HandleAsync(RawRequestContext ctx, CancellationToken ct);
}

Endpoint Discovery

Two mechanisms:

  1. Source Generator (preferred): Compile-time discovery via Roslyn
  2. Reflection (fallback): Runtime assembly scanning

Connection Behavior

On connection:

  1. Send HELLO with instance identity.
  2. Start heartbeat timer.
  3. For messaging transport, replay endpoint/schema/OpenAPI metadata only when the router explicitly asks for it.
  4. Listen for REQUEST frames.

HELLO payload:

public sealed class HelloPayload
{
    public required InstanceDescriptor Instance { get; init; }
    public required IReadOnlyList<EndpointDescriptor> Endpoints { get; init; }
    public IReadOnlyDictionary<string, SchemaDefinition> Schemas { get; init; } = new Dictionary<string, SchemaDefinition>();
    public ServiceOpenApiInfo? OpenApiInfo { get; init; }
}

For messaging transport the steady-state contract is intentionally slimmer than the generic shape above:


Authorization

Claims-based Model

Authorization uses RequiringClaims, not roles:

public sealed record ClaimRequirement
{
    public required string Type { get; init; }

    // Single-value AND-style requirement. Ignored when AllowedValues is set.
    public string? Value { get; init; }

    // Any-of requirement: satisfied if the principal presents ANY one of these
    // values for Type (e.g. orch:read OR script:read). Takes precedence over Value.
    public IReadOnlyList<string>? AllowedValues { get; init; }
}

Precedence

  1. Microservice provides defaults in registration metadata
  2. Authority can override centrally
  3. Gateway enforces final effective claims

Enforcement

Gateway AuthorizationMiddleware:

Break-glass introspection transport

The FND-17 gate uses Gateway__Auth__BreakGlassIntrospection__Endpoint for uncached Authority introspection of findings:recovery requests. The canonical deployment endpoint is https://authority.stella-ops.local/introspect; internal HTTP discovery does not imply that credential-bearing introspection accepts HTTP. Preserve certificate and hostname validation, the operator-supplied client secret, and fail-closed behavior.

The caller identity is Gateway__Auth__BreakGlassIntrospection__ClientId, deployed as stellaops-gateway-introspection (devops/compose/docker-compose.stella-services.yml). Authority answers an introspection request only for a caller named among the token’s audiences, so this id must appear in the minting client’s allowedAudiences; exactly one client declares it, stellaops-break-glass-operator (devops/etc/authority/plugins/standard.yaml). Before 2026-09-08 (SPRINT_20260722_016 AUTH-17) the id was stellaops, the audience every token in the estate carries, which made the credential an authorized party for every token the gateway can authenticate; that client is now deleted rather than retained as a fallback. A token that does not carry the introspection audience is refused 503 break_glass_introspection_not_authorized_party, which is a configuration fault and never a revocation.

Verified against middleware source 4bcb645bd03643c7941de4c935873e061b103940 and the 2026-08-30 read-only Gateway-network probe: HTTP returned 400 (HTTPS required), while HTTPS returned 200/active=true for an ordinary token and 200/active=false for an invalid token. Re-verify from the Gateway network with strict TLS validation and both controls before a recreation. This dependency check does not prove live recovery-scope revocation or authorize changing Authority/Findings state. Record deployment-configuration revisions separately from the unchanged image source SHA.

Gateway-owned HTTP endpoints

Beyond proxying to microservices, the gateway maps a small set of its own endpoints. These are registered as system paths, so they bypass RouteDispatchMiddleware and the microservice pipeline.

Method & PathAuth (scope)Purpose
GET /health, /health/live, /health/ready, /health/startupanonymousLiveness/readiness; /health/ready stays 503 until required first-party microservices have live registrations
GET /metricsanonymousPrometheus metrics
GET /openapi.json, /openapi.yaml, /.well-known/openapi, /api/openapi/aggregateanonymousAggregated OpenAPI document over connected services
GET /buildinfo.jsonanonymousBuild-info aggregator (30s cache, 5s per-service timeout)
GET /.well-known/security.txt, /.well-known/stella-product-security.json, /api/v1/product/support-lifecycleanonymous (fail-closed 503)Regulatory product-security metadata
GET /api/v1/router/instancesrouter:routing:readLive instance discovery (version/region/weight/health); optional ?service= filter
GET /api/v1/router/routing/weightsrouter:routing:readRead process-local routing weight overrides + effective weights
POST /api/v1/router/routing/weightsrouter:routing:writeUpsert one process-local routing weight override
GET /api/v1/operator/verify/buildinfoanonymousOperator-verify proxy for buildinfo aggregator
GET /api/v1/operator/verify/sbom-by-digest/{sha256}operator.verify.readImage digest → indexed SBOM record ids
GET /api/v1/operator/verify/ledger-chain/{ledgerId}operator.verify.readFindings-ledger chain integrity verification
GET /api/v1/operator/verify/token-revocationoperator.verify.readAuthority revocation bundle status + gateway propagation telemetry
GET /api/v1/operator/verify/cra-provenance/{tenantId}operator.verify.readPolicy CRA technical-documentation provenance
POST /api/v1/operator/verify/replay-compareoperator.verify.adminTester deterministic replay comparison
POST /api/v1/gateway/administration/router/resyncrouter:routing:writeForce a ResyncRequest to one exact connection or, when the selector is omitted, all connected microservices

The router-routing and operator-verify scopes are declared in the canonical catalog StellaOps.Auth.Abstractions.StellaOpsScopes as router:routing:read, router:routing:write, operator.verify.read, and operator.verify.admin. MapRouterAdministrationEndpoints protects the complete /api/v1/gateway/administration group with policy router.routing.write; the policy requires the canonical router:routing:write scope. The resync request uses an omitted or null connectionId for intentional all-fleet replay and a non-blank exact value for targeted replay. Explicitly blank selectors return 400, while stale non-blank selectors return 404; neither is broadened to an all-fleet request.


Cancellation

CANCEL Frame

public sealed record CancelPayload
{
    public string? Reason { get; init; }
}

// Standard reason constants (StellaOps.Router.Common.Models.CancelReasons):
// "ClientDisconnected", "Timeout", "PayloadLimitExceeded", "Shutdown", "ConnectionClosed"

Gateway sends CANCEL when:

At the HTTP boundary, an aborted request or Kestrel’s known client-reset / client-disconnected IOException is recorded at debug level with status 499 when the response has not started. The gateway does not manufacture an RFC-7807 500 body for a client that is already gone. Other I/O failures remain unexpected server errors and retain the sealed 500 ProblemDetails path.

Microservice handles CANCEL:

For the Messaging transport, authenticated capable clients consume CANCEL on a per-connection control queue instead of waiting behind the per-service request batch. The gateway signs a target binding for each capable live connection; mixed or legacy-only fleets retain one compatibility copy on the request queue, while all-capable fleets omit that redundant copy. Clients without a frame envelope verifier do not advertise or consume the dedicated lane. Exact- correlation tombstones make cancel-before-request and duplicate delivery bounded and idempotent. See Messaging Transport over Valkey for queue topology, rollout compatibility, and residual stream cleanup risk.


Streaming

Buffered vs Streaming

ModeRequest BodyResponse BodyUse Case
BufferedFull in memoryFull in memorySmall payloads
StreamingChunked framesChunked framesLarge payloads

Frame Flow (Streaming)

Gateway                              Microservice
   │                                      │
   │  REQUEST (headers only)              │
   │ ────────────────────────────────────►│
   │                                      │
   │  REQUEST_STREAM_DATA (chunk 1)       │
   │ ────────────────────────────────────►│
   │                                      │
   │  REQUEST_STREAM_DATA (chunk n)       │
   │ ────────────────────────────────────►│
   │                                      │
   │  REQUEST_STREAM_DATA (final=true)    │
   │ ────────────────────────────────────►│
   │                                      │
   │                   RESPONSE           │
   │◄────────────────────────────────────│
   │                                      │
   │         RESPONSE_STREAM_DATA         │
   │◄────────────────────────────────────│

Heartbeat & Health

Heartbeat Frame

Sent at regular intervals over the same connection as requests:

public sealed class HeartbeatPayload
{
    public InstanceDescriptor? Instance { get; init; }
    public string InstanceId { get; init; }
    public required InstanceHealthStatus Status { get; init; }
    public int InFlightRequestCount { get; init; }
    public double ErrorRate { get; init; }
    public DateTime TimestampUtc { get; init; }
}

Health Tracking

Gateway tracks:


Configuration

Gateway configuration (GatewayOptions, section Gateway)

The StellaOps.Gateway.WebService host binds everything under the Gateway section into GatewayOptions. The shape below reflects the actual option classes (GatewayNodeOptions, GatewayTransportOptions, GatewayRoutingOptions, GatewayAuthOptions, GatewayOpenApiOptions, GatewayHealthOptions, and the Routes list). Durations are strings parsed by GatewayValueParser (e.g. "30s", "5m"); sizes accept "100MB".

Gateway:
  Node:
    Region: "us-east-1"
    NodeId: "gw-east-01"        # auto-generated gw-<region>-<8hex> when blank
    Environment: "production"
    NeighborRegions: ["us-west-2"]

  Routing:
    DefaultTimeout: "30s"
    GlobalTimeoutCap: "120s"     # cap applied after endpoint + route timeout resolution
    MaxRequestBodySize: "100MB"
    StreamingEnabled: true
    PreferLocalRegion: true
    AllowDegradedInstances: true
    StrictVersionMatching: true

  Transports:
    Tcp:   { Enabled: false, BindAddress: "0.0.0.0", Port: 9100 }
    Tls:   { Enabled: false, BindAddress: "0.0.0.0", Port: 9443 }
    Messaging:
      Enabled: true
      transport: "valkey"        # or "postgres"
      RequestQueueTemplate: "router:requests:{service}"
      ResponseQueueName: "router:responses"
      ConsumerGroup: "router-gateway"
      HeartbeatInterval: "10s"

  Auth:
    DpopEnabled: true
    AllowAnonymous: true
    EnableTenantOverride: false
    EmitIdentityEnvelope: true
    ApprovedAuthPassthroughPrefixes: ["/connect", "/console", "/authority", "/doctor", "/api"]

  OpenApi:
    Enabled: true
    Title: "StellaOps Gateway API"
    CacheTtlSeconds: 300

  Health:
    StaleThreshold: "30s"
    DegradedThreshold: "15s"
    CheckInterval: "5s"
    RequiredMicroservices: []

  Routes:
    - { Type: Microservice, Path: "^/billing(?=/|$)(.*)", IsRegex: true, TranslatesTo: "http://billing.stella-ops.local$1" }

Note: there is no top-level PayloadLimits / Services block. Payload limits are derived from Gateway:Routing:MaxRequestBodySize, and per-endpoint claims and timeouts are advertised by each microservice via HELLO/EndpointsUpdate, not declared in gateway config. OpenApi:CacheTtlSeconds defaults to 300.

Hot Reload


Error Mapping

ConditionHTTP Status
Version not found404 Not Found
No healthy instance503 Service Unavailable
Request timeout504 Gateway Timeout
Payload too large413 Payload Too Large
Unauthorized401 Unauthorized
Missing claims403 Forbidden
Validation error422 Unprocessable Entity
Rate limit exceeded429 Too Many Requests
Internal error500 Internal Server Error

See Also