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.cs and src/__Libraries/StellaOps.Components.Licensing/ on 2026-11-04, and “Reading the Endpoint count” against the running Router samples on 2026-11-04. Re-verify with pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Router/__Tests/StellaOps.Router.Gateway.Host.Tests/StellaOps.Router.Gateway.Host.Tests.csproj -Method '*' and python3 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:

Path templates.

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:

Reading the Endpoint count

An operator reads the count from the running gateways and adds what they do not see.

  1. Read the gateway’s Endpoint list, /api/v1/router/endpoints, with a token that carries the scope router:routing:read (the same gate as the instance list, see Endpoints). It lists one entry per counted Endpoint: group (individual, extension or any), method (for the individual group), template (the template in its comparison form, each parameter reduced to its structure, for example /items/{} or /files/{**}), and the registeredMethods, registeredTemplates, services and instances behind it. count is the number of entries, and countNote repeats that the displayed count is not conclusive.

  2. In an installation with several gateways, collect the lists of every gateway and count each distinct combination of group, method and template once. With jq, over the response bodies saved as gateway-*.json:

    jq -s '[.[].endpoints[] | [.group, .method, .template]] | unique | length' gateway-*.json
    
  3. Add 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 keyPurpose
StellaOps:ComponentLicence:PathA licence file, or a directory whose *.dsse.json files are read (at most 64). Local path only. Optional.
StellaOps:ComponentLicence:KeyThe 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:InstallationIdThe 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:

StatusWhen
within-free-limitThe Endpoint count is at or below the notice level.
embedded-scope-not-establishedThe count is above the notice level, and an embedded entry decides. The reader describes the recorded permission and cannot establish its scope.
record-matchesThe 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-capacityThe count is above the free limit and above the capacity of the entry that decides.
above-notice-levelThe 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-buildThe count is above the free limit, no entry covers the running version, and the build carries no first-publication date.
no-matching-recordThe 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:

FieldContent
textThe one-line notice, as the log line writes it.
caselicensed-to, no-licence-file, no-file-loaded, records-not-assessed or no-covering-record.
licenseeThe licensee display name of the deciding line; null unless the case is licensed-to.
permissionstandard-capacity or embedded; null unless the case is licensed-to.
licensedCapacityThe capacity of the deciding standard-capacity entry, in Endpoints; null otherwise (an embedded entry is unlimited).
coversVersionsPublishedBeforeThe date of the deciding standard-capacity entry, or for no-covering-record the latest date of the records in force; null otherwise.
effectiveDateThe effective date of the deciding embedded entry; null otherwise.
recordsInForceThe number of licence records (serials) in force.
licenceFilesNotInForceThe 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

OutputContent
LogA 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.
HealthThe 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.
MetricsThe 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.