Router licence monitor
Scope. How the router gateway host counts Endpoints, reads a licence record offline and reports a licence status and a licence notice. For operators who run the gateway host and for implementers who compose it. The record format is the component licence file contract.
Verified against the source of
src/Router/__Libraries/StellaOps.Router.Gateway.Hosting/Licensing/,StellaOps.Router.Gateway.Hosting/Endpoints/RouterEndpointInventoryEndpoints.cs,StellaOps.Microservice.AspNetCore/AspNetCoreEndpointDiscoveryProvider.csandsrc/__Libraries/StellaOps.Components.Licensing/on 2026-11-04, and “Reading the Endpoint count” against the running Router samples on 2026-11-04. Re-verify withpwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Router/__Tests/StellaOps.Router.Gateway.Host.Tests/StellaOps.Router.Gateway.Host.Tests.csproj -Method '*'andpython3 tools/scripts/test/router-sample-round-trip.py run.
The monitor is informational. It never refuses, slows or stops a request, never fails readiness or liveness and never triggers a restart. The gateway routes requests in every status. The monitor makes no network call and sends no telemetry: the count, the licence record and the status stay in the installation.
The status describes the licence records the local reader can see. It is not a determination of entitlement or compliance; the order and the applicable licences prevail over the record and over any status. The displayed count is not conclusive: the licence counts the Endpoints configured for production routing, and the gateway sees only the ones registered with it (see What the count does not see). The absence of a warning is not a compliance attestation.
Counting
The gateway counts Endpoints: each distinct combination of a method group and a path template that the connected services register with it. Any number of services, instances and versions can register the same Endpoint; it counts once.
Method groups. For each path template:
GET,HEAD,POST,PUT,DELETE,CONNECT,OPTIONSandTRACEeach count once where registered. The list is fixed.- Every other method token,
PATCHincluded, counts in one extension-method Endpoint, however many such tokens the template has. - Every entry that accepts any method counts in one any-method Endpoint.
- The three groups count separately: an any-method entry does not absorb a
GETregistered for the same template. - Method tokens compare as written:
getis an extension-method token, notGET. HEADandOPTIONScount only where they are registered; they are not inferred fromGET.
Path templates.
- Literal text compares as registered, with its letter case and its slashes:
/v1/itemsand/v2/itemsare two templates, and so are/itemsand/items/. - Within a parameter, the name, the constraints and a default value are disregarded:
/items/{id}and/items/{itemId:int}are one template. - Whether a parameter is optional, has a default value or is a catch-all is kept, and the two catch-all forms stay apart:
/files/{path},/files/{*path}and/files/{**path}are three templates. - A template is compared as one template; it is never expanded into the paths it matches.
Provenance. The gateway answers its own health, metrics, OpenAPI, instance-list and Endpoint-list paths itself; they never enter the routing state, so they never count. A service’s own route counts whatever its name: a service that registers GET /health adds an Endpoint.
Current set. The count is the set of Endpoints registered when the monitor evaluates, once a minute; it is not a running total. An Endpoint stops counting when no connected instance registers it, and an instance that is disconnected at that moment is not in that evaluation.
What the services register. A microservice on the SDK registers the method and path of each [StellaEndpoint] (the SDK writes the method in upper case). The ASP.NET Core bridge (AddStellaRouterBridge) registers each method of an endpoint’s metadata as the metadata names it (ASP.NET Core writes the standard methods in upper case), an endpoint mapped without a method filter as one any-method entry, and the route template with its parameter constraints removed and everything else (catch-all, optional and default-value markers, letter case, trailing slash) as written. An endpoint excluded from publication (ExcludedPathPrefixes, EndpointFilter, or RouterPublicationExclusionMetadata at its mapping site) is not registered, so the gateway does not see it.
What the count does not see
The licence counts the Endpoints configured for production routing, including routes that the gateway does not see. The displayed count leaves out:
- services that are not connected to this gateway when the monitor evaluates, and services connected only to another gateway of the installation (each gateway counts what is registered with it);
- routes a service handles without registering them, for example routes matched inside the service behind a registered catch-all template, and routes excluded from publication;
- routes configured in other routing layers of the installation.
Reading the Endpoint count
An operator reads the count from the running gateways and adds what they do not see.
Read the gateway’s Endpoint list,
/api/v1/router/endpoints, with a token that carries the scoperouter:routing:read(the same gate as the instance list, see Endpoints). It lists one entry per counted Endpoint:group(individual,extensionorany),method(for theindividualgroup),template(the template in its comparison form, each parameter reduced to its structure, for example/items/{}or/files/{**}), and theregisteredMethods,registeredTemplates,servicesandinstancesbehind it.countis the number of entries, andcountNoterepeats that the displayed count is not conclusive.In an installation with several gateways, collect the lists of every gateway and count each distinct combination of
group,methodandtemplateonce. Withjq, over the response bodies saved asgateway-*.json:jq -s '[.[].endpoints[] | [.group, .method, .template]] | unique | length' gateway-*.jsonAdd the Endpoints of What the count does not see, read from the services’ routing configuration and code under the rules of Counting.
The number of step 1 equals endpoints in the licence object of /health and the gauge stellaops_router_licence_endpoints on /metrics, as of the monitor’s last evaluation (evaluatedAt): an Endpoint registered after it appears at the next evaluation. Do not add up the endpointCount values of the instance list (/api/v1/router/instances): they count an Endpoint once per instance that registers it.
The Router samples list their Endpoints, and tools/scripts/test/router-sample-round-trip.py checks the Endpoint list, the health detail and the gauge against them on the running samples.
Covered versions
A licence record entry covers the versions of the Router first published before its date (coversVersionsPublishedBefore, an exclusive UTC calendar date). The gateway takes the first-publication date of the running version from the StellaOps.Component.PublishedAt assembly metadata of StellaOps.Router.Gateway.Hosting, stamped when the release is packed. A build or adaptation of a released version keeps the stamp of its source release; its own build date is never used. A build without the stamp is a development build.
When several entries cover the running version, the highest capacity among them applies; entries are never added together. The current date never changes the result: a version first published before an entry’s date stays covered after that date has passed. The recorded capacity, in Endpoints, is compared with the Endpoint count.
The licence record
The record is a DSSE envelope signed with a Stella Ops vendor-entitlement key and checked offline, read from a file or from a licence key. No record is needed for the count to be reported. A record that fails a check is not loaded and its reason is shown as a detail in the warning log; it never stops the gateway.
| Configuration key | Purpose |
|---|---|
StellaOps:ComponentLicence:Path | A licence file, or a directory whose *.dsse.json files are read (at most 64). Local path only. Optional. |
StellaOps:ComponentLicence:Key | The licence key: the same signed file as one line of standard base64, read and verified exactly as the file. Base64 is an encoding, not a protection. Optional. |
StellaOps:ComponentLicence:InstallationId | The Installation identifier the record names, a lowercase UUID. A record for another identifier, or a record read while no identifier is configured, is not loaded (wrong_installation). |
STELLAOPS_VENDOR_TRUST_ROOTS (environment) | Optional directory with a vendor trust-root set that replaces the embedded published root, for key rotation. |
Configure a path or a key, not both: with both set, neither is read and the details report conflicting_sources. The gateway reads the record again at every evaluation, once a minute, so a new file takes effect within a minute without a restart. The refusal codes and the continuity rules of later revisions are in the contract.
Statuses
The statuses, their order and their messages are those of the contract, with the Endpoint count as the Router’s count:
| Status | When |
|---|---|
within-free-limit | The Endpoint count is at or below the notice level. |
embedded-scope-not-established | The count is above the notice level, and an embedded entry decides. The reader describes the recorded permission and cannot establish its scope. |
record-matches | The count is above the notice level, a standard-capacity entry covers the running version, and the count is within its capacity or the free limit. Messages about missing capacity are suppressed. |
count-exceeds-recorded-capacity | The count is above the free limit and above the capacity of the entry that decides. |
above-notice-level | The count is above the notice level and within the free limit, and no entry covers the running version. Informational: crossing the notice level creates no excess. |
development-build | The count is above the free limit, no entry covers the running version, and the build carries no first-publication date. |
no-matching-record | The count is above the free limit, and no loaded entry covers the running version. |
Every status but within-free-limit and record-matches is a warning. The message of each status and the licence-status explanation that accompanies them are the contract’s.
Licence notice
The gateway also shows a licence notice in its log and in its health output. When an entry of a loaded licence record covers the running version, the notice names the licensee recorded in that record’s Router line: Licensed to <licensee>, with the capacity and the covered-versions date of a standard-capacity entry, or the permission, version scope and effective date of an embedded entry. With several loaded records, it names the licensee of the record whose entry decides, the highest applicable entry, and gives the number of licence records in force. Without a covering entry, the notice gives the message of its case: no local licence record is loaded, the record could not be authenticated or matched to this Installation, the record covers only versions first published before its date, or the build carries no first-publication date. The texts and their one-line form (separated by |) are in the contract.
The notice follows the loaded records, not the count: a covering record shows its licensee also at a low count. Like the status, it is informational and changes nothing in routing, readiness or liveness.
The notice object of the licence health entry:
| Field | Content |
|---|---|
text | The one-line notice, as the log line writes it. |
case | licensed-to, no-licence-file, no-file-loaded, records-not-assessed or no-covering-record. |
licensee | The licensee display name of the deciding line; null unless the case is licensed-to. |
permission | standard-capacity or embedded; null unless the case is licensed-to. |
licensedCapacity | The capacity of the deciding standard-capacity entry, in Endpoints; null otherwise (an embedded entry is unlimited). |
coversVersionsPublishedBefore | The date of the deciding standard-capacity entry, or for no-covering-record the latest date of the records in force; null otherwise. |
effectiveDate | The effective date of the deciding embedded entry; null otherwise. |
recordsInForce | The number of licence records (serials) in force. |
licenceFilesNotInForce | The number of licence records that were read and are not in force. |
notice is null before the first evaluation. The health endpoints answer without authentication, so any client that reaches them can read the licensee name, as it can read the count; where the name must not be visible to clients, limit who can reach the health endpoints, for example at a reverse proxy. The name stays in the installation: the gateway sends it nowhere.
Output
| Output | Content |
|---|---|
| Log | A warning while the status warns: at the first evaluation after startup, when the status changes, and then once an hour. It gives the status, the message, the Endpoint count with the notice level and the free limit, the number of records loaded and not loaded, the licence-status explanation and the details (records not loaded with their reasons, the date and capacity of every loaded entry). within-free-limit and record-matches are logged at information level at the first evaluation and when the status changes. A notice line at information level, Router licence notice: followed by the one-line licence notice, comes before every status line, at once when the notice changes (for example a record revision with another licensee or capacity), and otherwise once an hour, in every status. |
| Health | The licence object of /health, /health/live, /health/ready and /health/startup: evaluated, status, message, explanation (the licence-status explanation), notice (see Licence notice), endpoints, endpointsNoticeLevel, endpointsFreeLimit, endpointCountNote (the displayed count is not conclusive), publishedAt (none in a development build), licenceFilesLoaded, licenceFilesNotLoaded, installationIdConfigured, evaluatedAt and informational. The HTTP status code of a health answer never depends on it. |
| Metrics | The gauge stellaops.router.licence.endpoints (meter StellaOps.Router.Licence), tagged licence.status, reaches only the metrics pipeline the operator configures. /metrics shows it as stellaops_router_licence_endpoints with a status label. |
| Endpoint list | /api/v1/router/endpoints, read-only, behind router:routing:read: the Endpoints the monitor counts (see Reading the Endpoint count). |
Registration
AddRouterGatewayHost(...) registers the monitor after the transport host, and UseRouterGatewayHost() maps the Endpoint list, so the gateway host and every application that composes the gateway with them report; there is no separate call. The monitor (RouterLicenceMonitor) evaluates once the host has started and then once a minute. The licence health object with the licence notice, the notice log line and the licence gauge are present only where the monitor is registered. The Stella Ops gateway (StellaOps.Gateway.WebService) shares the health middleware and does not register the monitor or map the Endpoint list, so its health output has no licence object and its log no licence notice.
