Router Architecture
Verified against commit
942a18727c(2026-09-26, DOC-2 of SPRINT_20260926_001). Re-verify by reading the evidence paths listed in the Evidence section.
Canonical specification for the Stella Ops Router: the single HTTP ingress (StellaOps.Gateway.WebService, deployed as router-gateway) and the binary-transport dispatch layer between that gateway and the platform microservices. Audience: platform and service engineers who register microservices behind the gateway, and operators who configure transports, routes and TLS. First-party services reach the gateway over binary transports, never HTTP.
Companion specifications:
- Tenant selection and header propagation contract: ADR-002 — multi-tenant same-API-key selection
- Service impact ledger: multi-tenant-service-impact-ledger.md
- Flow sequences: multi-tenant-flow-sequences.md
- Rollout policy: multi-tenant-rollout-and-compatibility.md
Hosts and deployment
| Deployable | Source | Where it runs |
|---|---|---|
router-gateway | src/Router/StellaOps.Gateway.WebService | Release bundle devops/release/bundle/docker-compose.yml (service router-gateway, network aliases router.stella-ops.local and stella-ops.local); development estate devops/compose/docker-compose.stella-services.yml |
The gateway owns no database and reads no database connection string. Its only external state is the Valkey instance used by the Messaging transport (Gateway:Transports:Messaging:*, ConnectionStrings__Redis). The route table is JSON configuration: the release bundle mounts devops/release/bundle/config/router-gateway.json read-only as /app/appsettings.json, and the development estate mounts devops/compose/router-gateway-local.json. Both files and the in-image src/Router/StellaOps.Gateway.WebService/appsettings.json carry the same route table.
The release bundle publishes plain HTTP from container port 8080 (host port STELLA_HTTP_PORT, default 8080) and HTTPS from container port 443 (host port STELLA_HTTPS_PORT, default 8443), bound to STELLA_BIND_ADDRESS (default 127.0.0.1). The container health check requires 200 from /health/ready.
Everything else in src/Router/ is a library consumed by the gateway or by the microservices: the Microservice SDK (StellaOps.Microservice*), the ASP.NET bridge (StellaOps.Router.AspNet), the transports (StellaOps.Router.Transport.*), the messaging abstraction and its backends (StellaOps.Messaging*), the local test harness (StellaOps.Router.Gateway.LocalInMemoryHarness) and the transport plugin adapter StellaOps.Router.Plugin.Unified.
System Architecture
Scope
- A single HTTP ingress service (
StellaOps.Gateway.WebService) handles all external HTTP traffic. - Microservices communicate with the gateway over binary transports. The deployed estate uses the Messaging transport over Valkey; TLS is the certificate-based stream transport; TCP and UDP are development-only.
- HTTP is not used for internal microservice-to-gateway traffic (
TransportTypehas no HTTP member). - Request/response bodies are opaque to the router (raw bytes/streams).
- Forwarded HTTP headers remain case-insensitive across Router frame transport and ASP.NET bridge dispatch; lowercase HTTP/2 names such as
content-typemust be preserved for JSON-bound endpoints, and the ASP.NET bridge must mark POST/PUT/PATCH requests as body-capable so minimal-API JSON binding survives frame dispatch - Gateway scope authorization evaluates against the resolved per-request scope set from identity expansion (
GatewayContextKeys.Scopes), so coarse compatibility scopes such asorch:quotacan satisfy their fine-grained frontdoor equivalents without changing downstream policy names
Transport Architecture
Router transport and Messaging backend implementations are runtime plugins, not compile-time references of the gateway. The gateway host depends only on host-owned interfaces from StellaOps.Router.Common (IGatewayConnectionTransportServer, IGatewayMessagingTransportServer); the guard GatewayRuntimePluginReferenceGuardTests fails if a TCP, TLS, Router Messaging, Postgres or Valkey implementation project re-enters the gateway project graph.
The release bundle configures the plugin locations as:
| Key | Bundle value |
|---|---|
Gateway:TransportPlugins:Directory | /app/plugins/router/base |
Gateway:TransportPlugins:TrustRootDirectory | /app/trust-roots/plugins |
Gateway:TransportPlugins:RequireSignedManifest | ${ROUTER_GATEWAY_REQUIRE_SIGNED_MANIFEST:-true} |
Gateway:MessagingBackendPlugins:Directory | /app/plugins/messaging/base |
Gateway:MessagingBackendPlugins:TrustRootPath | /app/trust-roots/plugins/messaging/cosign.pub |
Gateway:MessagingBackendPlugins:Profile | base |
Gateway:MessagingBackendPlugins:EnforceSignatureVerification | true |
When Gateway:TransportPlugins:Directory is unset the gateway uses plugins/router/transports under its application directory. The directory may hold a signed manifest directly or one child directory per signed bundle (TransportPluginDirectoryProbe.GetSignedManifestLoadCandidateDirectories). Outside Development, RequireSignedManifest=false and EnforceSignatureVerification=false are not honoured: startup logs the override and forces both back to true (Program.cs). Signed bundles are produced by devops/build/package-runtime-plugins.ps1 (-Module router, -Module messaging).
Each transport connection carries:
- Initial identity (HELLO) and, when needed, endpoint metadata replay
- Ongoing heartbeats
- Request/response data frames
- Streaming data frames
- Cancellation frames
┌─────────────────┐ ┌─────────────────┐
│ 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:
- Static file serving (Angular SPA dist)
- Reverse proxy to HTTP-only backend services
- WebSocket proxy to upstream WebSocket servers
- SPA fallback (extensionless paths serve
index.html) - Custom error pages (404/500 HTML fallback)
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:
| Type | Behavior |
|---|---|
ReverseProxy | Strip path prefix, forward to TranslatesTo HTTP URL |
StaticFiles | Serve files from TranslatesTo directory, SPA fallback if x-spa-fallback: true header set |
StaticFile | Serve a single file at exact path match |
WebSocket | Bidirectional WebSocket proxy to TranslatesTo ws:// URL |
Microservice | Pass through to binary transport pipeline |
NotFoundPage | HTML file served on 404 (after all other middleware) |
ServerErrorPage | HTML 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.
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.
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.
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/*), the gateway Doctor checks path /doctor/router-gateway/checks, the plugin diagnostics prefix /internal/plugins, and /buildinfo.json (src/Router/StellaOps.Gateway.WebService/Middleware/GatewayRoutes.cs).
Actual middleware order from Program.cs (HttpsRedirectMiddleware first, see HTTP to HTTPS redirect, then):
CorrelationIdMiddleware
SecurityHeadersMiddleware
UseStellaOpsCors
UseAuthentication
SenderConstraintMiddleware
BreakGlassIntrospectionMiddleware ← per-call Authority introspection for break-glass scopes
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 (when Gateway:OpenApi:Enabled) / MapGatewayBuildInfoAggregator /
MapVerifyEndpoints / MapRoutingWeightEndpoints / MapRouterInstancesEndpoints /
MapServiceDoctorEndpoints / 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 identity in HELLO. Messaging HELLO may omit the endpoint list; the gateway then requests the endpoint metadata replay. GatewayHostedService indexes the published metadata 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). The option class defaults to false, which leaves the static route table alone deciding every request. The shipped route configurations (devops/release/bundle/config/router-gateway.json and devops/compose/router-gateway-local.json) set it to true; the in-image appsettings.json does not set it.
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:
- Explicit static route — matched by
StellaOpsRouteResolverfirst-match, so operator intent always wins. Flipping a route to publication means deleting its static entry. - A file on disk under the static route’s directory (console assets).
- A published endpoint for the request’s method and path.
- 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
With published-endpoint routing on, the static route table holds only what publication cannot express; every other path is resolved from HELLO-published endpoints. The shipped table (devops/release/bundle/config/router-gateway.json, identical in devops/compose/router-gateway-local.json and the in-image appsettings.json) contains these classes:
| Class | Entries | Why publication cannot express it |
|---|---|---|
| Gateway-local | StaticFiles SPA catch-all at / (/app/wwwroot), NotFoundPage, ServerErrorPage | served by the gateway itself, no microservice behind them |
| External target | /rekor → http://rekor.stella-ops.local:3322 | not a connected microservice; genuine outbound HTTP hop |
| OIDC / browser front door (Authority) | /connect, ^/connect/revocation(/.*)?$ (rewritten to Authority /revoke), /.well-known, /jwks, /authority, /authority/console, /console, /console/admin, /issuer-directory, ^/api/v1/tenants(.*) | browser and raw-JWT flows that terminate on Authority’s own HTTP surface |
| Platform browser front door | /platform, /platform/envsettings.json | browser bootstrap reads; deleting a ReverseProxy row moves its traffic onto the binary transport, which changes browser-flow behaviour |
| Identical-template collisions | ^/catalog-changes/(tenants|tenant_lifecycle)(.*) (Authority), ^/catalog-changes/actor_identity(.*) and ^/catalog-changes/environment_state(.*) (Platform) | one shared catalog-change template is published by several hosts with value-dependent ownership; specificity ranking cannot separate them |
| Advertised-contract path | ^/api/registry/token(.*) (Microservice route to registry-token) | external Docker/ORAS clients follow the realm the registry advertises in WWW-Authenticate |
| Unbounded or large streaming | ^/api/vulnerabilities/v1/corpus/export$, ^/api/vulnerabilities/v1/binaries/build-id-index$, ^/api/vulnerabilities/v1/corpus/publications/[^/]+/sections/[^/]+$ | HTTP streams; AspNetRouterRequestDispatcher captures a whole body in one frame and GatewayTransportClient.SendStreamingAsync collects chunks before exposing the response stream. A retained publication section is a file of up to MaxSectionBytes: in one Messaging frame it is base64-encoded twice and serialized as one JSON string, and a 135 MB section exceeded the System.Text.Json limit (502). The section route passes Range, If-Range, Content-Range and the digest ETag through; the publication manifest and current pointer stay published |
| Signer custody transport | /api/v1/signer | the DPoP/auth-passthrough contract requires ReverseProxy; /api/v1/signer is also the only Gateway:Auth:DpopRequiredPrefixes entry in the shipped configurations |
A new static entry needs a stated reason from this list. Re-verify the table with python3 -c "import json;print(json.dumps(json.load(open('devops/release/bundle/config/router-gateway.json'))['Gateway']['Routes'],indent=1))" and tools/scripts/validate/check-local-runtime-sources.cjs; the live-estate replay procedure is local-runtime-replay.md.
Regulatory Product Metadata Ingress
Router owns the externally visible product-security metadata endpoints before static console fallback and before generic /api/* dispatch:
/.well-known/security.txt/.well-known/stella-product-security.json/api/v1/product/support-lifecycle
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 (HTTP 8080 / HTTPS 443 in the container) → [microservices via binary transport]
→ [HTTP backends via reverse proxy]
→ [Angular SPA from /app/wwwroot]
The development estate mounts the Angular dist from the console-builder service’s console-dist volume at /app/wwwroot (devops/compose/docker-compose.stella-services.yml). Release images bake the dist into router-gateway at the same path; the release bundle has no separate console image or console-builder service.
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.
HTTP to HTTPS redirect
HttpsRedirectMiddleware runs first in the pipeline. A plain-HTTP request that is not a system path and not a WebSocket upgrade gets a 302 to HTTPS. The Location keeps the request’s path base, path and query string unchanged. Its port and host come from the gateway’s own options, because the container listens on its internal port and cannot see the port an operator published:
| Key (env form) | Meaning | Unset |
|---|---|---|
Gateway:HttpsRedirect:Port (Gateway__HttpsRedirect__Port) | Public HTTPS port written into Location; 443 is omitted from the URL | no port, i.e. 443 |
Gateway:HttpsRedirect:Host (Gateway__HttpsRedirect__Host) | Public host name or IP address | the request’s host, without its port |
A deployment that publishes HTTPS on a port other than 443 must set the port, for example Gateway__HttpsRedirect__Port: ${STELLA_HTTPS_PORT:-8443} in the release bundle (the development estate sets ${ROUTER_GATEWAY_PUBLIC_HTTPS_PORT:-443}), so that http://127.0.0.1:8080/console?x=1 redirects to https://127.0.0.1:8443/console?x=1. Startup fails if the port is outside 1 to 65535 or if the host carries a scheme, port or path. IPv6 hosts are bracketed in the Location.
Service Identity
Instance Identity
Each microservice instance is identified by:
| Field | Type | Description |
|---|---|---|
InstanceId | string | Unique instance identifier |
ServiceName | string | Logical service name (e.g., “billing”) |
Version | string | Semantic version (major.minor.patch) |
Region | string | Deployment region (e.g., “us-east-1”) |
Weight | int | Routing weight for intra-tier weighted canary selection (default 100) |
Version Matching
- Version matching is strict semver equality
- Router only routes to instances with exact version match
- Default version used when client doesn’t specify
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):
| Field | Example |
|---|---|
| Method | GET, 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
- ASP.NET-style route templates
- Parameter segments:
{id},{userId} - Extra path segments are consumed only by explicit catch-all parameters (
{**path}); ordinary terminal parameters must not behave like implicit catch-alls during messaging transport dispatch - Case sensitivity and trailing slash handling follow ASP.NET conventions
Routing Algorithm
Instance Selection
Given (ServiceName, Version, Method, Path):
Filter candidates (
DefaultRoutingPlugin):- Match
ServiceNameexactly - Match
Versionexactly (strict semver whenStrictVersionMatching, which is the default; the requested version falls back toRoutingOptions.DefaultVersionwhen the caller omits it) Draininginstances are always excluded from new routing (they may only finish in-flight work)- Prefer
Healthy; fall back toDegradedonly whenAllowDegradedInstancesis enabled and no healthy instance remains
- Match
Region preference (only when
PreferLocalRegionand a gateway region are set):- Tier 0: instances where
Region == RouterNodeConfig.Region - Tier 1: configured
NeighborRegions - Tier 2: all other regions
- Tier 0: instances where
Within the best non-empty region tier:
- Prefer lower
AveragePingMs(withinPingToleranceMs) - If tied, prefer more recent
LastHeartbeatUtc - If multiple remain tied and they advertise differing non-default effective
Weightvalues, use weighted random selection (canary) - Otherwise apply the configured tie-breaker:
RoundRobin(per-service counter) orRandom(default)
- Prefer lower
Instance Health
public enum InstanceHealthStatus
{
Unknown,
Healthy,
Degraded,
Draining,
Unhealthy
}
Health metadata per connection:
| Field | Type | Description |
|---|---|---|
Status | enum | Current health status |
LastHeartbeatUtc | DateTime | Last heartbeat timestamp |
AveragePingMs | double | Average round-trip latency |
Transport Layer
Transport Types
| Transport | Use Case | Streaming | Notes |
|---|---|---|---|
| InMemory | Testing | Yes | In-process channels |
| TCP | Dev-only | Yes | Length-prefixed frames; gated by STELLAOPS_ROUTER_ALLOW_INSECURE_TRANSPORTS=true (see Insecure transport gate) |
| TLS | Secure / Production | Yes | Certificate-based encryption; strict validation (see TLS validation contract) |
| UDP | Dev-only | No | Single datagram per frame; gated by STELLAOPS_ROUTER_ALLOW_INSECURE_TRANSPORTS=true |
| RabbitMQ | Queuing | Yes | Exchange/queue routing; envelope-verified |
| Messaging (Valkey/Postgres) | Production | Yes | At-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 pruned evidence docs-archive/qa/audits/microservice-audit-pass2-2026-04-29.md at cebd0316b1 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:
- Messaging (Valkey / Postgres / InMemory): a
EnvelopeSealfield onRpcRequestMessage/RpcResponseMessage(JSON-serialised by the message broker). - RabbitMQ: four AMQP headers —
stellaops-frame-seal-keyid,stellaops-frame-seal-alg,stellaops-frame-seal-iat-ms,stellaops-frame-seal-mac. - TCP / UDP / TLS: not yet wired into the binary frame protocol; these transports inherit application-layer integrity once the sidecar is added to the protocol header (follow-up sprint).
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 (Router__Frame__Envelope__* in the release bundle and in the devops/compose/docker-compose.*.yml service files, including the Tester and SM Remote overlays). The runtime-posture validator tools/scripts/validate/validate_runtime_posture.py rejects a surface whose Router identity-envelope mapping lacks the 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 deployments (the release bundle and 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 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, src/Router/__Libraries/StellaOps.Router.Common/Plugins/) requires, for each signed load directory (the configured Gateway:TransportPlugins:Directory itself, or each child bundle directory under it):
- A signed manifest at
plugins/router/transports/transports.signed.jsonthat pins, per file name, the SHA-256 of the on-disk DLL and the leaf signing thumbprint. The manifest schema isstellaops.router.transports/v1and is documented inRouterTransportManifest. - 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/X509PinnedSignatureValidatorfrom PSV-02 / PSV-03 — there is one validator implementation across the entire platform. - A directory whose
*.dllfiles are an exact match for the manifest’stransports[]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. - Per-DLL SHA-256 match against the pinned digest. A mismatch fails the entire load closed with a
ListedDllDigestMismatchrejection.
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.mdfor 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
Transports implement IRouterTransportPlugin (src/Router/__Libraries/StellaOps.Router.Common/Plugins/IRouterTransportPlugin.cs) and register their server and client sides through RouterTransportRegistrationContext. The server contract is deliberately small; the client contract carries request, cancel and streaming operations over a known ConnectionState:
public interface ITransportServer
{
Task StartAsync(CancellationToken cancellationToken);
Task StopAsync(CancellationToken cancellationToken);
}
public interface ITransportClient
{
Task<Frame> SendRequestAsync(ConnectionState connection, Frame requestFrame,
TimeSpan timeout, CancellationToken cancellationToken);
Task SendCancelAsync(ConnectionState connection, Guid correlationId, string? reason = null);
Task SendStreamingAsync(ConnectionState connection, Frame requestHeader, Stream requestBody,
Func<Stream, Task> readResponseBody, PayloadLimits limits, CancellationToken cancellationToken);
}
Connection lifecycle events (HELLO, heartbeat, close) reach the gateway through IGatewayConnectionTransportServer and IGatewayMessagingTransportServer (src/Router/__Libraries/StellaOps.Router.Common/Abstractions/).
RouterConnectionManager retries transport registration at startup and after a transport death until registration succeeds or the host cancels. It uses the configured positive ReconnectBackoffInitial and ReconnectBackoffMax bounds. One recovery actor coalesces repeated notifications, including a death during an in-flight registration attempt. Stop unsubscribes the death handler, cancels and drains recovery, then disconnects the transport. Reconnecting sends HELLO; the gateway’s resync request replays the existing endpoint, schema and OpenAPI metadata, and the normal heartbeat loop continues.
Messaging response, incoming-request and optional cancellation consumers share a receive-cycle cancellation scope. If any consumer exhausts the existing five-failure budget, that cycle cancels and drains its sibling consumers before raising one TransportDied notification. Otherwise a healthy sibling could keep the receive task waiting and suppress recovery. The replacement cycle retains the connection’s uncancelled lifetime token. Explicit disconnect drains consumers without reporting a transport death. Failure thresholds, backoff and wire contracts are unchanged. This repair is source-verified against 5a7791b578 (2026-10-19). Re-verify: pwsh tools/scripts/test-targeted-xunit.ps1 -Project src/Router/__Tests/StellaOps.Router.Common.Tests/StellaOps.Router.Common.Tests.csproj -Class '*MessagingTransportConsumerRecoveryTests' -BuildProjectReferences -MaxThreads 1.
The Valkey queue factory also exposes the optional neutral IMessageQueueConnectionState contract. Its local ConnectionGeneration advances when the command connection is restored or replaced. A separate receive-cycle watcher awaits WaitForConnectionChangeAsync from the generation used for registration. It cancels and drains idle consumers even when QueueWaitTimeoutSeconds=0 leaves them waiting indefinitely for notifications. The local signal also returns immediately if restoration preceded the wait, avoiding a lost wake-up. Consumers retain their generation checks around queue work. This ends their current receive cycle even when the broker recovers before five consecutive failures. The existing recovery actor then sends a fresh HELLO and answers the gateway’s metadata replay request. Initial connection establishment and subscription-only restores do not trigger extra registration; there is no periodic HELLO timer. A failed PING also replaces an apparently connected but unusable multiplexer. No retry, heartbeat, queue-wait or proof deadline is extended. Source-verified against f02da72b5e (2026-10-19). Re-verify with the consumer command above, pwsh tools/scripts/test-targeted-xunit.ps1 -Project src/Router/__Tests/StellaOps.Messaging.Transport.Valkey.Tests/StellaOps.Messaging.Transport.Valkey.Tests.csproj -Class '*ValkeyConnectionFactoryTests' -BuildProjectReferences -MaxThreads 1, and pwsh tools/scripts/test-targeted-xunit.ps1 -Project src/Router/__Tests/StellaOps.Gateway.WebService.Tests/StellaOps.Gateway.WebService.Tests.csproj -Class '*MessagingRecoveryValkeyRehearsalTests' -BuildProjectReferences -MaxThreads 1. The latter stops only its private test broker and keeps five in-process SDK hosts alive; it does not establish acceptance of a standing-estate outage. Hosts must adopt matching Router messaging, messaging-abstraction and Valkey-provider assemblies in their qualified images and signed plugin cohorts before that separate proof.
The SDK is in the source graph of every non-test Web SDK host through the automatic Router.AspNet reference in src/Directory.Build.props, as well as explicit worker references. A running host needs an owning image rebuilt with the accepted SDK source; passing SDK tests does not update deployed images. This recovery slice is source-verified against 688f426b44 (2026-10-05 UTC). Re-verify with pwsh tools/scripts/test-targeted-xunit.ps1 -Project src/Router/__Tests/StellaOps.Microservice.Tests/StellaOps.Microservice.Tests.csproj -Class '*RouterConnectionRecoveryTests*' -BuildProjectReferences -Restore.
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 pruned evidence docs-archive/qa/audits/microservice-audit-pass2-2026-04-29.md at cebd0316b1 recorded the previous permissive behaviour and led to this rewrite (see also SPRINT_20260501_013).
Validation rules, in evaluation order:
- Required-but-missing. When the role requires a peer certificate and none was presented, reject (
certificate_required_but_missing). RemoteCertificateNotAvailable. Reject (remote_certificate_not_available).RemoteCertificateNameMismatchis always fatal. Even withAllowSelfSigned=trueand 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.RemoteCertificateChainErrors:- When
AllowSelfSigned=false: reject (chain_error_and_self_signed_disallowed). - When
AllowSelfSigned=true:- Accept only if the chain status is exactly
UntrustedRootorPartialChain(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.Ordinalon 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).
- Accept only if the chain status is exactly
- When
- No errors: accept.
Pinning options live on TlsTransportOptions.CertificatePinning:
| Field | Type | Notes |
|---|---|---|
ThumbprintsSha256 | string[] | Recommended tightest posture (per-leaf). Hex (case-insensitive on input, normalised); separators :, -, are stripped. |
AllowedSubjects | string[] | Exact RFC-2253 form (e.g. CN=service-a, O=stellaops). Allows leaf rotation under a private CA without re-pinning. |
AllowedSans | string[] | 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
The HTTP middleware order is listed under Pipeline Order. Inside the microservice branch, EndpointResolutionMiddleware maps (Method, Path) to an EndpointDescriptor, AuthorizationMiddleware enforces the effective claims, RoutingDecisionMiddleware selects a connection, and RequestRoutingMiddleware dispatches over the selected transport (src/Router/__Libraries/StellaOps.Router.Gateway/Middleware/).
Identity Header Policy and Tenant Selection
- Gateway strips client-supplied reserved identity headers (
X-StellaOps-*, legacy aliases, raw claim headers, and auth headers) before proxying. - Effective tenant is claim-derived from the validated
stellaops:tenantclaim; the legacytidfallback was removed (SPRINT_20260824_010 P8-14). - Effective scopes are the case-insensitive union of every validated individual
scpclaim and every token in the validated space-delimitedscopeclaims. Empty values and duplicates are removed before the existing coarse-scope expansions run, and the signed identity envelope emits the result in deterministic ordinal order. - Gateway signs an allow-listed set of downstream ABAC claims into the internal identity envelope after token validation. This includes the developer self-service release-control claims for owned service IDs, repository IDs, image namespaces, allowed environment IDs, maximum environment type, and direct-update permission. Microservice bridges restore only those signed claims into
HttpContext.User; client-supplied claim headers are still stripped. - Global-administrator tenant selection (ADR-023): when the validated principal carries
stellaops:admin=trueand a tenant claim, the canonicalX-StellaOps-TenantIdheader selects the forwarded tenant, written into the downstream tenant headers and the signed identity envelope. This applies whateverGateway:Auth:EnableTenantOverridesays; the Console tenant picker depends on it. Legacy tenant headers (X-StellaOps-Tenant,X-Stella-Tenant,X-Tenant-Id) never select a tenant for a global administrator. The selection is logged with route, actor, home tenant and selected tenant. - Every other principal keeps its claim tenant: with the override off (the default) a conflicting tenant header is stripped and the claim tenant is forwarded, not refused. The per-request allow-list override is an operator opt-in (
Gateway:Auth:EnableTenantOverride=true): a tenant header may then select a tenant in the principal’sstellaops:allowed_tenantsclaim, and any other tenant is refused with403 tenant_override_forbidden. ADR-023 itself keeps non-administrators claim-bound; the opt-in is a separate operator choice, off in the shipped compose files and bundle. - Authorization/DPoP passthrough is fail-closed:
- the matched route must be configured with
PreserveAuthHeaders=true, and - route prefix must also be in the approved passthrough allow-list configured under
Gateway:Auth:ApprovedAuthPassthroughPrefixes. - The option-class default approves
/connect,/console,/authorityand/api. The shipped route configurations approve/connect,/console,/authority,/issuer-directory,/api,/policy/shadowand/policy/simulations. - Gateway DPoP replay protection is durable in non-testing runtime:
StellaOps.Gateway.WebServiceresolvesIDpopReplayCachethrough messaging idempotency (valkeyin compose,postgreswhen configured) and only permitsInMemoryDpopReplayCacheunder the explicitTestingenvironment. WhenGateway:Auth:DpopEnabled=trueorGateway:Auth:DpopRequiredPrefixesis nonempty and no durable backend is configured, the host fails fast. Disabling gateway-wide DPoP does not disable route-scoped requirements. Only a host with both forms disabled can start without a replay backend. - Tenant override attempts are logged with deterministic fields including route, actor, requested tenant, and resolved tenant.
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 (src/Router/__Libraries/StellaOps.Microservice.AspNetCore/StellaRouterBridgeOptions.cs, mirrored on StellaRouterOptions and StellaRouterOptionsBase in StellaOps.Router.AspNet). It has three modes (enum GatewayAuthorizationTrustMode, same file):
| Mode | Behaviour in AspNetRouterRequestDispatcher.PopulateIdentity |
|---|---|
ServiceEnforced | Envelope 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). |
GatewayEnforced | Requires 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
Hybridmode, 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 (StellaOps.Router.AspNet/IdentityEnvelopeMiddlewareExtensions.cs), 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 untilFrameEnvelopeOptionsis 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 thestellaops:tenantvalue that tenancy is derived from. BindFrameEnvelopeOptionsand preferGatewayEnforcedfor services handling tenant-scoped or privileged data.
Dispatcher failure responses
AspNetRouterRequestDispatcher calls the matched ASP.NET endpoint delegate directly; transport-dispatched requests bypass the ordinary ASP.NET middleware pipeline. Within the dispatcher’s existing catch boundary, an unexpected exception produces status 500 with the existing four-field problem body (type, title, status, detail). The detail is always An internal error occurred. Argument, state, missing-key and endpoint-thrown authorization exceptions are server failures, not evidence of a caller error. NotImplementedException retains status 501 with the same generic detail. A matched endpoint without a delegate also returns the sanitized 500.
The bridge preserves explicit owner responses, including validation 400, denial 403 and missing-record 404, with their sanctioned bodies and allowed response headers. An unmatched endpoint keeps the bridge’s 404 and No endpoint matched the request. detail. The bridge’s own GatewayEnforced envelope rejection remains a distinct 403 with the fixed detail Gateway identity envelope verification failed.; it does not invoke the endpoint. An endpoint throwing an authorization exception cannot select that denial path. Cancellation still propagates when the supplied transport token is cancelled.
Generated failures contain only the problem content-type header and discard any partial endpoint body or headers. Caught server exceptions are logged with request identity, method and exception type, without the exception object, message, inner exceptions, data, headers or body. The known envelope rejection keeps its fixed warning. This contract covers the existing dispatch catch boundary, not scope creation or asynchronous scope disposal.
CCP-078 source tests qualify this SDK behavior. Deployed adoption requires each affected downstream service to contain the rebuilt bridge and a separately authorized forcing function through the actual gateway-to-service path. A gateway-only rollout or a passing source test does not establish that adoption.
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 IReadOnlyList<string> TransportCapabilities { 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:
- No deserialization or schema interpretation
- Headers and bytes forwarded as-is
- Schema validation is microservice responsibility
Payload Limits
Configurable limits protect against resource exhaustion:
Limits are bound to PayloadLimits (src/Router/__Libraries/StellaOps.Router.Common/Models/PayloadLimits.cs); MaxRequestBytesPerCall is derived from Gateway:Routing:MaxRequestBodySize.
| Limit | Scope |
|---|---|
MaxRequestBytesPerCall | Single request |
MaxRequestBytesPerConnection | All requests on connection |
MaxAggregateInflightBytes | All in-flight across gateway |
Exceeded limits result in:
- Early rejection (HTTP 413) if
Content-Lengthknown - Mid-stream abort with CANCEL frame
- Appropriate error response (413 or 503)
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:
- Source Generator (preferred): Compile-time discovery via Roslyn
- Reflection (fallback): Runtime assembly scanning
Connection Behavior
On connection:
- Send HELLO with instance identity.
- Start heartbeat timer.
- For messaging transport, replay endpoint/schema/OpenAPI metadata only when the router explicitly asks for it.
- Listen for REQUEST frames.
HELLO payload:
public sealed record HelloPayload
{
public required InstanceDescriptor Instance { get; init; }
public required IReadOnlyList<EndpointDescriptor> Endpoints { get; init; }
public IReadOnlyList<string> TransportCapabilities { 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:
- startup
HELLOcarries identity and may leaveEndpointsempty - the gateway sends
ResyncRequeston service startup, administrative replay, gateway-state miss, or a cooldown-gated heartbeat retry while a known connection still has no endpoint metadata - the microservice answers with
EndpointsUpdatecontaining endpoints, schemas, and OpenAPI metadata
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
- Microservice provides defaults in registration metadata
- Authority can override centrally
- Gateway enforces final effective claims
Authority claims-overrides refresh
AuthorityClaimsRefreshService polls Authority’s GET /api/v1/claims/overrides (base URL Gateway:Auth:Authority:ClaimsOverridesUrl, falling back to the issuer origin) every 30 seconds and merges the result into the effective claims store. Authority requires the machine-only scope authority:claims-overrides.read on that route (policy authority.claims-overrides.read), so the refresh presents a client-credentials token minted as stellaops-gateway-claims:
| Key | Deployed value |
|---|---|
Gateway__Auth__ClaimsOverridesClient__ClientId | stellaops-gateway-claims |
Gateway__Auth__ClaimsOverridesClient__TokenEndpoint | https://authority.stella-ops.local/connect/token |
Gateway__Auth__ClaimsOverridesClient__ClientSecret | ${GATEWAY_INTROSPECTION_CLIENT_SECRET} |
The token comes from the shared StellaOps auth client on the refresh’s named HttpClient (StellaOps.Router.Gateway.AuthorityClaims) and is cached in process until shortly before expiry. The client shares the break-glass introspection client’s secret variable, so no new operator secret is required. If any of the three keys is missing, the gateway logs a warning at startup and the refresh calls anonymously. Authority answers 401 to that call outside its resource-server bypass networks, and the gateway then runs on microservice-declared claims only.
Enforcement
Gateway AuthorizationMiddleware:
- Validates user principal has all required claims
- Empty claims list = authenticated access only
- Missing claim = 403 Forbidden
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 (release bundle and devops/compose/docker-compose.stella-services.yml). The release bundle requires GATEWAY_INTROSPECTION_CLIENT_SECRET and refuses to start without it. 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). The estate-wide stellaops client id is not used for introspection. 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.
Break-glass per-call introspection
Owner ruling Q-33 (2026-09-24, SPRINT_20260924_004 BGI-1) confirms the control FND-17 shipped: a request whose validated token carries a break-glass scope is checked against Authority on every call, so revoking the token takes effect on the next request instead of at token expiry.
- Guarded scopes.
Gateway:Auth:BreakGlassIntrospection:Scopes, default the singletonfindings:recovery. It is the only catalogue scope granted to no persona and no service client (StellaOpsScopes.FindingsRecovery). It gates the four Findings recovery endpointsPOST /api/findings/v1/security/advisory-generations/recovery/{hold,rollback,restore,resume}. The guard matches the scope, not the route, so every route a guarded token reaches is covered. - Position.
BreakGlassIntrospectionMiddlewareruns afterUseAuthenticationandSenderConstraintMiddleware, and beforeIdentityHeaderPolicyMiddleware. Below that middleware the regex route has already removed the token, so there is nothing to introspect. - Cost. One uncached
POST /introspectper break-glass request, bounded byTimeoutSeconds(default 5). A request without a guarded scope makes no call. - Identity. The gateway calls Authority only through its named
BreakGlassIntrospectionHTTP client, asstellaops-gateway-introspection. The estate-widestellaopscredential is never used.
Every exit other than an explicit active: true refuses the request. Refusals use the gateway’s standard RFC 7807 envelope (RouterErrorWriter, application/problem+json). The refusal code is the title, and type is urn:stellaops:router:<code> with underscores turned into hyphens.
| Condition | Status | title |
|---|---|---|
| Authority reports the token inactive (revoked, expired, unknown) | 401, with WWW-Authenticate: Bearer error="invalid_token" | break_glass_token_inactive |
Authority unreachable, timeout, non-200, or no boolean active | 503 | break_glass_introspection_unavailable |
| Endpoint, client id or client secret not configured | 503 | break_glass_introspection_unconfigured |
| Token audiences do not include the introspection client | 503 | break_glass_introspection_not_authorized_party |
No Bearer or DPoP token on the request | 503 | break_glass_introspection_no_token |
Failure logs record the exception type only, never the exception message: the failed request carried the caller’s token and the client secret.
Doctor check. doctor.router.break-glass.introspection-identity (category health, served at GET /doctor/router-gateway/checks, scope ops.health) measures the identity on demand. It posts a non-token to /introspect with the gateway’s credentials. A 200 with active: false proves the endpoint is reachable and the credential is accepted. A 401, 403 or 400 means the credential is rejected. A disarmed scope list, missing configuration, an unreachable endpoint, or a non-token reported active are all critical. The evidence reports the client secret as set or absent, never its value.
Re-verify with BreakGlassIntrospectionPipelineTests. These tests boot the real gateway Program. A revoked token is forwarded on the first call and refused 401 on the next, without reaching the upstream. Ordinary scopes cost zero introspection calls. Removing the middleware turns all three tests red, and so does moving it below IdentityHeaderPolicyMiddleware. Unit coverage: BreakGlassIntrospectionMiddlewareTests and GatewayBreakGlassIntrospectionIdentityCheckTests.
Gateway-owned HTTP endpoints
Beyond proxying to microservices, the gateway maps its own endpoints. They are registered as system paths, so they bypass RouteDispatchMiddleware and the microservice pipeline.
| Method & Path | Auth (scope) | Purpose | Source |
|---|---|---|---|
GET /health, /health/live, /health/ready, /health/startup | anonymous | Liveness/readiness; /health/ready stays 503 until required first-party microservices have live registrations | HealthCheckMiddleware |
GET /metrics | anonymous | Prometheus metrics | HealthCheckMiddleware |
GET /openapi.json, /openapi.yaml, /.well-known/openapi, /api/openapi/aggregate | anonymous | Aggregated OpenAPI document over connected services (mapped only when Gateway:OpenApi:Enabled) | OpenApiAggregatorEndpoints |
GET /buildinfo.json | anonymous | Build-info aggregator | BuildInfoAggregatorEndpoints |
GET /.well-known/security.txt, /.well-known/stella-product-security.json, /api/v1/product/support-lifecycle | anonymous (fail-closed 503) | Regulatory product-security metadata | ProductMetadataMiddleware |
GET /doctor/router-gateway/checks | ops.health (policy gateway:health:read) | Gateway Doctor checks | MapServiceDoctorEndpoints |
GET /internal/plugins/status | ops.health (policy gateway:health:read) | Report of loaded transport and messaging-backend plugins (paths, SHA-256 of plugin assemblies) | GatewayPluginProbeEndpoints |
POST /internal/plugins/probe | ops.admin (policy gateway:plugins:probe) | The same report, filtered by the probe request | GatewayPluginProbeEndpoints |
GET /api/v1/router/instances | router:routing:read | Live instance discovery (version/region/weight/health); optional ?service= filter | RouterInstancesEndpoints |
GET /api/v1/router/routing/weights | router:routing:read | Read process-local routing weight overrides and effective weights | RoutingWeightEndpoints |
POST /api/v1/router/routing/weights | router:routing:write | Upsert one process-local routing weight override | RoutingWeightEndpoints |
POST /api/v1/gateway/administration/router/resync | router:routing:write | Force a ResyncRequest to one exact connection or, when the selector is omitted, all connected microservices | RouterAdministrationEndpoints |
GET /api/v1/operator/verify/buildinfo | anonymous | Operator-verify proxy for the buildinfo aggregator | VerifyEndpoints |
GET /api/v1/operator/verify/sbom-by-digest/{sha256} | operator.verify.read and sbom:read | Image digest → indexed SBOM record ids | VerifyEndpoints |
GET /api/v1/operator/verify/ledger-chain/{ledgerId} | operator.verify.read | Findings-ledger chain integrity verification | VerifyEndpoints |
GET /api/v1/operator/verify/token-revocation | operator.verify.read | Authority revocation bundle status and gateway propagation telemetry | VerifyEndpoints |
GET /api/v1/operator/verify/cra-provenance/{tenantId} | operator.verify.read | Policy CRA technical-documentation provenance | VerifyEndpoints |
POST /api/v1/operator/verify/replay-compare | operator.verify.admin | Deterministic replay comparison | VerifyEndpoints |
Endpoint sources are under src/Router/StellaOps.Gateway.WebService/Endpoints/ unless named as middleware. The scopes are declared in StellaOps.Auth.Abstractions.StellaOpsScopes (RouterRoutingRead, RouterRoutingWrite, OperatorVerifyRead, OperatorVerifyAdmin, OpsHealth, SbomRead) and bound to policies in Program.cs. operator.verify.* uses dotted form and the router routing scopes use colon form. The routing-weight override is process-local (in-memory IRoutingWeightOverrideStore), not persisted cluster-wide.
MapRouterAdministrationEndpoints protects the complete /api/v1/gateway/administration group with policy router.routing.write. 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.
The /internal/plugins/* diagnostics are scope-gated like the gateway Doctor checks: status on ops.health, probe on ops.admin; an anonymous request is refused 401. The prefix stays a system path because that classification is what makes the gateway answer it itself: a request under /internal/plugins is never dispatched to a microservice or a configured route, so a service’s own plugin diagnostics (Authority, Scanner, and the other hosts) are not reachable through the gateway.
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:
- HTTP client disconnects (
HttpContext.RequestAborted) - Request timeout elapses
- Payload limit exceeded
- Gateway shutdown
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:
- Maps correlation ID to
CancellationTokenSource - Calls
Cancel()on the source - Handler receives cancellation via
CancellationToken
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
| Mode | Request Body | Response Body | Use Case |
|---|---|---|---|
| Buffered | Full in memory | Full in memory | Small payloads |
| Streaming | Chunked frames | Chunked frames | Large 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 record HeartbeatPayload
{
public InstanceDescriptor? Instance { get; init; }
public required string InstanceId { get; init; }
public required InstanceHealthStatus Status { get; init; }
public IReadOnlyList<string> TransportCapabilities { get; init; } = [];
public int InFlightRequestCount { get; init; }
public double ErrorRate { get; init; }
public DateTime TimestampUtc { get; init; }
}
Health Tracking
Gateway tracks:
LastHeartbeatUtcper connection- Derives status from heartbeat recency
- Marks stale instances as Unhealthy
- Uses health in routing decisions
- Messaging heartbeats include instance identity so the gateway can rebuild minimal state after a gateway restart or local routing-state loss without waiting for a full reconnect.
- Messaging transports stay push-first even when backed by notifiable queues; the missed-notification safety-net timeout is derived from the configured heartbeat interval and clamped to a short bounded window instead of falling back to a fixed long poll.
- Gateway degraded and stale transitions are normalized against the messaging heartbeat contract. A gateway may not mark an instance
Degradedearlier than2xthe heartbeat interval orUnhealthyearlier than3xthe heartbeat interval, even when looser defaults were configured. /health/readyis stricter than “process started”: it remains503until the configured required first-party microservices have live healthy or degraded registrations in router state. Local scratch compose uses this to hold the frontdoor unhealthy until the core Stella API surface has replayed HELLO after a rebuild.- The health payload separately reports
console.configured,console.directoryExists,console.indexExists,console.readyandconsole.statefrom the SPA fallback route. States arenot-configured,missing-root,missing-indexandready. An unconfigured optional Console has null existence/readiness values; a configured but unavailable Console hasready: false. Filesystem paths are not exposed. Console availability does not change the HTTP status or top-level API readiness: headless API deployments remain valid, and losing static assets must not remove a healthy API from service. Operators monitoring a configured Console must checkconsole.readyas well. - The required-service list must use canonical router
serviceNamevalues, not loose product-family aliases. Gateway readiness normalizes host-style suffixes such as-gateway,-web,.stella-ops.local, and ports, but it does not treat sibling services as interchangeable. - When a request already matched a configured
Microserviceroute but the target service has not registered yet, the gateway returns503 Service Unavailable, not404 Not Found.404remains reserved for genuinely unknown paths or missing endpoints on an otherwise registered service. - Router-enabled ASP.NET microservices preload the default first-party Router transport assembly for each configured transport before scanning the default AppDomain. The service image still must carry the referenced transport assembly in its deps graph; this preload only removes the fragile “first type use” dependency that made otherwise-published messaging transports invisible at startup.
- Messaging resync is explicit instead of periodic: startup, administrative replay, and gateway-state misses trigger
ResyncRequest. Normal heartbeats stay small; while a known connection’s endpoint catalog is still empty, they also trigger a cooldown-gated replay request untilEndpointsUpdateclears the pending state. Healthy catalogs do not replay on heartbeat. - The Valkey transport keeps its timeout fallback plus proactive randomized re-subscribe so silent Pub/Sub failures still recover. That fallback still produces some
XREADGROUP/XAUTOCLAIMtraffic, but it is resilience traffic rather than endpoint-catalog churn.
Configuration
Gateway configuration (GatewayOptions, section Gateway)
The StellaOps.Gateway.WebService host binds the Gateway section into GatewayOptions (src/Router/StellaOps.Gateway.WebService/Configuration/GatewayOptions.cs): Node, Transports, Routing, Auth, HttpClient, OpenApi, Health, HttpsRedirect and Routes. Durations are strings parsed by GatewayValueParser (e.g. "30s", "5m"); sizes accept "100MB". GatewayOptionsValidator validates the section at startup. Plugin locations (Gateway:TransportPlugins:*, Gateway:MessagingBackendPlugins:*) are read directly by Program.cs and are listed under Transport Architecture.
Shape of the shipped configuration (devops/release/bundle/config/router-gateway.json plus the bundle environment), abridged:
Gateway:
Node:
Region: "local"
NodeId: "gw-local-01" # auto-generated gw-<region>-<8hex> when blank
Environment: "dev"
NeighborRegions: []
Routing:
DefaultTimeout: "60s" # option-class default 30s
GlobalTimeoutCap: "120s" # cap applied after endpoint + route timeout resolution
MaxRequestBodySize: "100MB"
StreamingEnabled: true
PreferLocalRegion: true
AllowDegradedInstances: true
StrictVersionMatching: true
PublishedEndpointRouting: true # option-class default false
Transports:
Tcp: { Enabled: false, BindAddress: "0.0.0.0", Port: 9100 }
Tls: { Enabled: false, BindAddress: "0.0.0.0", Port: 9443, AllowSelfSigned: false }
Messaging: # bundle environment Gateway__Transports__Messaging__*
Enabled: true
transport: "valkey"
RequestQueueTemplate: "router:requests:{service}"
ResponseQueueName: "router:responses"
ConsumerGroup: "router-gateway"
HeartbeatInterval: "30s" # ROUTER_MESSAGING_HEARTBEAT_INTERVAL; option-class default 10s
Auth:
DpopEnabled: false # option-class default true; the shipped configurations set false
DpopRequiredPrefixes: ["/api/v1/signer"]
AllowAnonymous: true
EnableTenantOverride: false # ROUTER_GATEWAY_ENABLE_TENANT_OVERRIDE
EmitIdentityEnvelope: true
ApprovedAuthPassthroughPrefixes: ["/connect", "/console", "/authority", "/issuer-directory", "/api", "/policy/shadow", "/policy/simulations"]
Authority:
Issuer: "https://authority.stella-ops.local/"
MetadataAddress: "http://authority.stella-ops.local/.well-known/openid-configuration"
BreakGlassIntrospection: # Endpoint/ClientId/ClientSecret come from the environment only
TimeoutSeconds: 5
HttpsRedirect:
Port: 8443 # Gateway__HttpsRedirect__Port: ${STELLA_HTTPS_PORT:-8443}
OpenApi:
Enabled: true
Title: "StellaOps Gateway API"
CacheTtlSeconds: 300
Health:
StaleThreshold: "30s"
DegradedThreshold: "20s" # option-class default 15s
CheckInterval: "5s"
RequiredMicroservices: ["platform", "integrations", "release-orchestrator", "authority"]
Routes: [ ... ] # see Static route remainder
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.
The BreakGlassIntrospection block in the JSON files deliberately omits Endpoint, ClientId and ClientSecret: the JSON file is re-added after the environment source, so an empty string there would outrank the Gateway__Auth__BreakGlassIntrospection__* environment values and disarm the control (guarded by GatewayBreakGlassConfigurationPrecedenceTests).
Hot Reload
- Routing state (registered services/endpoints) is updated dynamically as microservices send HELLO /
EndpointsUpdateand heartbeats; no gateway restart is required to add or remove a service instance. - Changes to the
Gatewayoptions block itself are bound at startup (ValidateOnStart); they are not hot-reloaded.
Error Mapping
| Condition | HTTP Status |
|---|---|
| Version not found | 404 Not Found |
| No healthy instance | 503 Service Unavailable |
| Request timeout | 504 Gateway Timeout |
| Payload too large | 413 Payload Too Large |
| Unauthorized | 401 Unauthorized |
| Missing claims | 403 Forbidden |
| Validation error | 422 Unprocessable Entity |
| Rate limit exceeded | 429 Too Many Requests |
| Internal error | 500 Internal Server Error |
See Also
- schema-validation.md - JSON Schema validation
- openapi-aggregation.md - OpenAPI document generation
- migration-guide.md - WebService to Microservice migration
- rate-limiting.md - Centralized Router rate limiting
- messaging-valkey-transport.md - Messaging transport over Valkey
- transports/README.md - Transport plugin reference
Evidence
| Claim | Source |
|---|---|
| Gateway host, middleware order, plugin loading, policy registration | src/Router/StellaOps.Gateway.WebService/Program.cs |
| System paths | src/Router/StellaOps.Gateway.WebService/Middleware/GatewayRoutes.cs |
| Gateway-owned endpoints and their scopes/policies | src/Router/StellaOps.Gateway.WebService/Endpoints/*.cs, src/Router/StellaOps.Gateway.WebService/GatewayValkeyOwnedStateRegistration.cs |
| Scope catalog values | src/Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOpsScopes.cs |
| HTTPS redirect (DAY-5) | src/Router/StellaOps.Gateway.WebService/Middleware/HttpsRedirectMiddleware.cs, Configuration/GatewayOptions.cs (GatewayHttpsRedirectOptions), Configuration/GatewayOptionsValidator.cs |
| Gateway options and defaults | src/Router/StellaOps.Gateway.WebService/Configuration/GatewayOptions.cs |
| Transport plugin directory probe | src/Router/StellaOps.Gateway.WebService/Configuration/TransportPluginDirectoryProbe.cs |
| Route model and route types | src/Router/__Libraries/StellaOps.Router.Gateway/Configuration/StellaOpsRoute.cs |
| Shipped route table and gateway configuration | devops/release/bundle/config/router-gateway.json, devops/compose/router-gateway-local.json, src/Router/StellaOps.Gateway.WebService/appsettings.json |
| Release deployment (ports, aliases, plugin mounts, environment) | devops/release/bundle/docker-compose.yml (service router-gateway) |
| Development deployment | devops/compose/docker-compose.stella-services.yml (service router-gateway) |
| Published-endpoint routing | src/Router/StellaOps.Gateway.WebService/Routing/PublishedEndpointRouter.cs, Routing/GatewayOwnedPaths.cs |
| Break-glass introspection | src/Router/StellaOps.Gateway.WebService/Middleware/BreakGlassIntrospectionMiddleware.cs, Services/GatewayBreakGlassIntrospectionIdentityCheck.cs |
| DPoP replay cache rule | src/Router/StellaOps.Gateway.WebService/Security/GatewayDpopReplayRuntimeExtensions.cs |
| Readiness and Console state | src/Router/StellaOps.Gateway.WebService/Services/GatewayReadinessEvaluator.cs, Middleware/HealthCheckMiddleware.cs |
| Regulatory feed route metadata | src/Router/StellaOps.Gateway.WebService/Middleware/RegulatoryFeedRouteMetadata.cs, Middleware/ProductMetadataMiddleware.cs |
| Frame, payload and descriptor models | src/Router/__Libraries/StellaOps.Router.Common/Models/, Enums/FrameType.cs |
| Transport abstractions and plugin loader | src/Router/__Libraries/StellaOps.Router.Common/Abstractions/, Plugins/ |
| Region configuration | src/Router/__Libraries/StellaOps.Router.Gateway/Configuration/RouterNodeConfig.cs |
| Downstream trust mode | src/Router/__Libraries/StellaOps.Microservice.AspNetCore/StellaRouterBridgeOptions.cs, AspNetRouterRequestDispatcher.cs |
| Error status codes | src/Router/__Libraries/StellaOps.Router.Gateway/Middleware/RouterErrorWriter.cs, PayloadLimitsMiddleware.cs, TransportDispatchMiddleware.cs, GlobalErrorHandlerMiddleware.cs |
| Transport plugin adapter | src/Router/StellaOps.Router.Plugin.Unified/ |
| Frame-envelope compose posture check | tools/scripts/validate/validate_runtime_posture.py |
Bounded route-match reuse
Source verified against commit 23420d863b (2026-10-10). Re-verify from the repository root with:
pwsh tools/scripts/test-targeted-xunit.ps1 -Project src/Router/__Tests/StellaOps.Router.Gateway.Tests/StellaOps.Router.Gateway.Tests.csproj -All -BuildProjectReferences
The gateway’s local registry caches matching route-template keys for at most 2,048 request paths. Paths above 2,048 characters and match sets above 64 templates bypass the cache. Registration, descriptor refresh and withdrawal invalidate cached matches, including misses. The cache does not retain authorization decisions or a selected service instance: endpoint descriptors, version/health precedence and routing weights are evaluated against live state.
SPA diversion retains its resolved descriptor for the same request method and path. Endpoint resolution reuses it only when translation and an explicit service hint have not changed the resolution context. Instance selection uses the canonical endpoint index rather than scanning route matchers again. Explicit service-hint resolution also reuses registered compiled matchers. Generic concrete-path lookups preserve their existing matching behavior. This reduces repeated matching while preserving literal-route precedence, root ownership, encoded-path handling, authorization and current instance selection.
