Skip to content
bitzorcas
中EN

Concept

Runtime License

Understand automatic licensing enforcement, signature verification, and the metering, configuration, and recovery boundaries of the built-in entitlement for deployments with up to 30 users.

Last updated

Runtime License decides whether a deployment may run a product version, tenancy mode, and commercial Feature. It does not download packages and is not user RBAC or tenant Feature entitlement. Enforcement is automatic: three source-generated Mediator pipeline behaviors evaluate every command, query, stream, and notification before the handler runs. Developers do not scatter manual license checks (such as if (!license.IsFeatureAllowed(featureId))) across endpoints.

Keep three authorization layers separate

LayerControlsExample
Package EntitlementPackages and versions a customer may downloadShort-lived private-feed token
Runtime LicenseProduct capabilities a deployment may runedition, versionRange, features
Tenant Feature EntitlementBusiness capabilities available to one tenantPlan entitlement, Feature Flag

Signatures and deployment identity

Licenses use ES256 signatures. Runtime contains a public-key ring selected by keyId; private keys stay in the issuer or HSM/KMS. After signature verification, runtime also matches product, version, environment, tenancy mode, and persistent DeploymentId.

Persist DeploymentId in a Kubernetes PV, Secret, or equivalent store. Do not bind it to Pod name, MAC address, or CPU identity, which change during scaling and recovery.

States and behavior

StateReadinessNormal writesData portability
ValidHealthyAllowedAllowed
GraceDegradedAllowed for the contracted grace periodAllowed
ExpiredUnhealthy; Healthy when the community policy appliesRejected; falls back when eligibleBackup/Export/Migration remain
RevokedUnhealthyRejectedRejected, fail-closed
InvalidUnhealthyRejectedRejected, fail-closed
UnavailableUnhealthy; Healthy when the community policy appliesRejected by default; allowed when eligibleControlled policy only

The matrix is enforced automatically by the pipeline (see the next section). A request is classified into a LicensedOperation — BusinessRead for queries, BusinessWrite for everything else, or Backup/Export/Migration for requests marked with the corresponding data-portability interface — and the status×operation matrix above decides whether it may proceed.

"No verified snapshot""Acquire and verify a lease""Refresh to a newer lease""Effective/offline windowends""Renewal succeeds""graceUntil passes""Revocation statereceived""Revocation statereceived""Context, clock, or contentfails""Context, clock, or contentfails""A newer valid lease isaccepted"

Unavailable

Valid

Grace

Expired

Revoked

Invalid

State precedence is Revoked, Invalid, Expired, Grace, then Valid. Revocation and invalidity outrank date windows, so an in-range but revoked snapshot can never look operational.

Host registration and safe default

AddBitzOrcasLicensing() registers UnavailableLicenseAdapter by default. The host can compose, but every decision is Unavailable with IsOperational=false. Passing configuration with Licensing:Runtime:Enabled=true adds persistent deployment identity, signature verification, envelope cache, the runtime adapter, and background refresh.

{
"Licensing": {
"Runtime": {
"Enabled": true,
"ProductId": "bitzorcas-modern",
"ProductVersion": "1.0.0",
"Environment": "production",
"TenancyMode": "multi-tenant",
"DeploymentIdentityPath": "/var/lib/bitzorcas/license/deployment-id",
"CachePath": "/var/lib/bitzorcas/license/runtime-license.json",
"OfflineLicensePath": "/run/secrets/bitzorcas-license.json",
"RefreshInterval": "00:15:00",
"ClockSkewTolerance": "00:05:00",
"PolicyId": "",
"TrustedPublicKeys": {
"prod-2026-01": "<PEM public key from secret/config provider>"
}
}
}
}

The example shows key shape; never store a real envelope or sensitive material directly in appsettings.json. TrustedPublicKeys contains verification keys only. Issuer private keys must never enter a host. A dedicated commercial deployment may leave PolicyId blank. Use community.web.v1 for the exact free Web composition, or community.small.v1 to enable the built-in entitlement for any static composition with at most 30 users. The latter needs no license file or verification key, but still requires a persistent DeploymentId, authoritative Identity metering, and database-level atomic capacity enforcement. The API and JobHost Development configurations, and AppHost, select this policy by default; do not hard-code a license in Program.cs.

Automatic enforcement through the Mediator pipeline

Every template Host wires three license behaviors into the Mediator composition at startup, and a startup-time seam check (EnsureSourceGeneratedMediatorLicenseSeams) hard-fails if the composition is incomplete. There is no opt-out path and no way to accidentally bypass licensing.

Pipeline surfaceBehaviorOn deny / failure
Request (non-streaming)RuntimeLicenseResultPipelineBehavior wraps RuntimeLicensePipelineBehaviorFor IResult<T> responses the adapter returns TResponse.Failure(error); otherwise RuntimeLicenseExecutionDeniedException propagates and maps to 403/503
StreamRuntimeLicenseStreamPipelineBehaviorThrows RuntimeLicenseExecutionDeniedException before the handler enumerates; the exception handler maps it to 403 before response start
NotificationRuntimeLicenseNotificationPublisherEvaluates the gate before publishing to any handler

The gate is fail-closed at every level: a null decision, any non-cancellation exception from the gate or provider, or an Unavailable status all throw RuntimeLicenseExecutionDeniedException(LicensingErrors.RuntimeUnavailable, Unavailable). The host never falls through to the handler when the gate cannot produce a positive decision.

Pipeline ordering (source-generated, fixed)
Request: Logging → RuntimeLicenseResult → RuntimeLicense → Authorization → Validation → … → Transaction
Stream: LoggingStream → RuntimeLicenseStream → AuthorizationStream → handler

Only IOuterPipelineObservability behaviors (logging/metrics) may precede the license gate. The startup seam check enforces this ordering, the one-behavior-per-pipeline invariant, the Result-adapter adjacency (the Result adapter must sit immediately outside the enforcement behavior), and that IMediator/ISender/IPublisher resolve to one source-generated facade.

Static bootstrap boundary for the first license

The product Host supplies a precise, closed compile-time message list that remains usable before a customer Runtime License exists:

  • minimum identity session flows, forced password change, password-recovery request, and one-time-token reset;
  • permission-filtered application navigation;
  • authorized LicenseManagement create, review, issue, revoke, query, download, and signer-readiness operations.
  • user search, details, disable, and soft delete, which are required to bring an over-capacity deployment back into compliance.

These messages bypass only the customer commercial-entitlement gate. Authentication, authorization, validation, transactions, idempotency, and audit still execute. The recovery surface excludes user creation, invitation, registration, activation, re-enabling, and profile changes, so it cannot be used to expand capacity. Normal business messages remain licensed. The list comes from the generated pipeline capability manifest and cannot be expanded through configuration, namespace, assembly, interface, or wildcard rules. Consumer templates keep an empty list.

Operation classification and the status×operation matrix

RuntimeLicenseExecutionPolicy.ResolveOperation classifies each message:

Message shapeLicensedOperation
IBaseQuery / IBaseStreamQueryBusinessRead
ILicensedBackupRequestBackup
ILicensedExportRequestExport
ILicensedMigrationRequestMigration
anything elseBusinessWrite (default)

A request type cannot declare more than one data-portability marker — the guard throws. LicenseOperationStatusPolicy.AllowsExecution then applies the matrix from the state table: Valid/Grace allow all; Expired allows read + backup + export + migration but blocks business writes; Revoked/Invalid/Unavailable block everything.

Stable error surfaces

StatusError codeHTTPRFC 9457
Unavailable (or gate exception / null decision)Licensing.Runtime.Unavailable503service-unavailable
Expired / Revoked / InvalidLicensing.Runtime.Denied403forbidden

For IResult<T> responses, the denial is a typed Result.Failure carrying the same error code; the handler never runs. For non-Result responses and streams, RuntimeLicenseExecutionDeniedExceptionHandler projects the exception to a ProblemDetails with errorCode and no signature/payload detail.

Registering the licensed application

AddBitzOrcasLicensedApplication(features) freezes the Profile’s static feature closure into one RuntimeLicenseExecutionPolicy singleton. A second call throws "Runtime License application execution policy is already registered." The feature strings (e.g. framework.core, framework.aspnetcore, workflow.runtime) become the immutable requirement every message is checked against.

Host composition (template BitzConsumer.Api/Program.cs)
builder.Services.AddBitzOrcasLicensing(builder.Configuration);
builder.Services.AddBitzOrcasLicensedApplication(
["framework.core", "framework.aspnetcore", "framework.infrastructure", "workflow.runtime"]);
// Keep commercial entitlement out of base readiness.
builder.Services.AddHealthChecks().AddBitzOrcasLicenseReadiness(
tags: [ServiceDefaultsExtensions.LicenseTag, "runtime"]);
var app = builder.Build();
// Filter only LicenseTag so this does not become the aggregate health endpoint.
app.MapHealthChecks("/health/license", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains(ServiceDefaultsExtensions.LicenseTag)
}).AllowAnonymous();

When a use case needs an explicit gate

The pipeline already enforces licensing for every message, so most use cases never call ILicenseGate directly. The explicit seam exists for fine-grained, non-message-scoped decisions (for example, a workflow engine evaluating a feature before each background job, or a module branching on a Feature at runtime). WorkflowRuntimeLicenseGuard is the canonical example: it calls ILicenseGate.EvaluateAsync with the workflow.runtime feature before every workflow write and every background job.

// Host composition injects the single license decision boundary.
public sealed class ReportingLicensePolicy(ILicenseGate licenseGate)
{
public Task<LicenseAccessDecision> CanGenerateAsync(
CancellationToken cancellationToken)
{
// Express the exact entitlement with a stable feature and operation type.
var requirement = new LicenseRequirement(
Features: ["reporting.generate"],
Operation: LicensedOperation.BusinessWrite);
return licenseGate.EvaluateAsync(requirement, cancellationToken);
}
}

A repository lacks business-operation context and must not decide commercial authorization.

Policy admission and static composition

Shared Runtime License policies are catalogued and admitted by RuntimeLicensePolicyEvaluator at registration time. RuntimeLicenseOptions.PolicyId selects the policy; Composition declares the static deployment boundary the policy pins.

PolicyStatusAdmitted composition
community.web.v1ActiveProfile: mini-api, CombinationId: mini-api-single, TenancyMode: single-tenant, PlatformModule: none, IndustryExtension: none, Features: [framework.core, framework.aspnetcore]
community.small.v1Activeany static composition; at most 30 authoritative active natural-person users across all tenants in one Deployment; no customer-specific license, envelope, or key
anything else—RuntimeLicensePolicyAdmissionException (PolicyNotActive)

An empty PolicyId keeps the protocol-v1 dedicated-deployment compatibility path. An unknown PolicyId throws at startup. A community.web.v1 protocol-v2 envelope still requires an exact match on edition (Community), profile (mini-api), DeploymentId (bitzorcas-shared-policy), tenancy modes, and the full feature set — any extra or missing feature is rejected. community.small.v1 neither accepts nor manufactures a shared envelope; it is a built-in capacity entitlement explicitly selected by the host and proven by the persistent Identity meter.

“Small application / SaaS” is a product applicability classification, not a client-supplied authorization claim. Runtime does not trust settings such as ApplicationKind=small/saas; its objective admission facts are the explicit PolicyId, persistent DeploymentId, and authoritative Identity count. If a production contract requires a license regardless of headcount, do not select community.small.v1; keep the dedicated signed mode.

The 30-user entitlement is active automatically. A seat is an Active or Locked, non-Host natural-person user across every tenant in the same Deployment. PendingActivation, Disabled, Expired, soft-deleted, Host operations, API clients, and application identities do not consume seats. Before serving traffic, the host rebuilds per-user allocation state and one persistent meter from authoritative Identity facts. User creation, activation, and re-enabling first select one winner through the unique (DeploymentId, TenantId, UserId) allocation row, then acquire a seat with a conditional database update, so concurrent requests cannot create user 31. An over-capacity write returns Identity.User.CommunityActiveUserLimitExceeded; disabling or deleting conditionally releases the allocation, so repeated or concurrent release decrements once.

A Valid or Grace commercial license always wins and removes the 30-user cap. Metering continues so removing or expiring that license immediately restores the correct boundary. Unavailable or Expired may fall back when the authoritative count is at most 30; Invalid and Revoked are security states and never fall back. If a commercial license becomes unavailable while more than 30 users already exist, startup and the base control plane remain available. User search, details, disable, and delete stay available for remediation, while creation, invitation, registration, activation, and re-enabling remain denied.

License readiness health check

Every template Host maps /health/license (anonymous) and registers AddBitzOrcasLicenseReadiness(). When the built-in entitlement applies, the endpoint is Healthy with status code LIC.COMMUNITY_SMALL and reports the active count, maximum, and meter sequence. Invalid/Revoked, and Unavailable/Expired states that do not qualify for the community entitlement, are Unhealthy. Descriptions expose only safe status and reason data — never a signature or payload. The default check name is license.

Kubernetes traffic probes use /health/ready, which contains base runtime dependencies but excludes the commercial License check. That keeps login, password recovery, navigation, and LicenseManagement reachable before issuance. Monitor and alert on /health/license separately. /health remains the aggregate diagnostic and can return 503 while licensing is unavailable. Normal business messages still fail closed with 503/403.

Online and offline lifecycle

A background service refreshes through ILicenseLeaseSource; request paths only read the latest atomically published decision and never access network or files. Cached envelopes remain signed and must be reverified after reading.

When the online source is unavailable, expiresAt, offlineUntil, and graceUntil drive Valid → Grace → Expired. Offline imports follow the same verification, context matching, and atomic cache path.

Replay protection requires monotonically increasing issuedAt, rejects different payloads with the same issue time, reverifies damaged caches, and treats excessive clock rollback as Invalid.

Example: temporary network loss

  1. Runtime reverifies yesterday’s signed snapshot.
  2. Before offlineUntil, state remains Valid.
  3. After the offline window but before graceUntil, state becomes Grace and emits an alert.
  4. After grace, state becomes Expired; normal writes stop while export and migration remain.
  5. When connectivity returns, only a newer, validly signed lease is accepted.

This is more operable than stopping immediately when the License Server is unreachable and safer than caching one decision forever.

Public-key rotation and revocation

Use a dual-key window: distribute the new public key to every target deployment, issue leases with the new keyId, observe successful refresh, and only then remove the old key. Removing it early makes instances with an old signed cache Invalid or Unavailable.

Revocation is not deletion of a local file. The issuer must publish a verifiable Revoked state or equivalent controlled result. Runtime marks readiness Unhealthy and fails closed. Record revocation, transitions, and administrative actions through ILicenseAuditSink, without logging a full envelope.

Readiness and alerts

SignalSuggested severityOperational action
Valid and refreshingNormalObserve remaining lease time
Valid with repeated refresh failureWarningRestore source before offlineUntil
GraceCritical/DegradedRenew immediately and prepare controlled degradation
ExpiredUnhealthyStop normal writes; retain contract-approved portability
Revoked/InvalidSecurity incidentIsolate and investigate signature/context
LIC.COMMUNITY_SMALLNormalMonitor seats; plan a commercial license before user 31
Unavailable without community entitlementUnhealthyCheck policy, meter, configuration, file permissions, and lease source

Request paths read an atomic snapshot, so License Server latency should not enter business-request latency. Monitor refresh failures, current state, remaining offline/grace time, and cache-read failures instead.

Test matrix

Terminal window
# State machine, signatures, cache, replay, DI, and gate behavior.
dotnet test tests/BitzOrcas.Licensing.Tests --configuration Release
# Prove commercial modules cannot bypass the shared licensing seam.
dotnet test tests/BitzOrcas.Architecture.Tests \
--configuration Release \
--filter 'FullyQualifiedName~RuntimeLicenseArchitectureTests'

Cover Valid, Grace, Expired, Revoked, Invalid, and Unavailable; clock rollback; same-issuedAt payload conflicts; damaged cache; lease-source outage; key rotation; and DeploymentId persistence across restart.

Configuration

Production and Staging must explicitly configure product, version, environment, tenancy, and persistent DeploymentId/cache paths. Signed policies also require at least one verification key. An explicit community.small.v1 selection does not require a public key because it is not a signed license, but the host still fails fast when its Identity persistence adapter or authoritative meter is unavailable. Development and Test may run with the same community policy or inject snapshots through AddTestLicense; the latter rejects any environment other than Test/Testing, and production must never ship a universal test license.

Go-live acceptance

  • DeploymentId and cache paths survive process or Pod restart;
  • API and JobHost share product, version, environment, and tenancy semantics;
  • signed deployments receive at least one trusted public key through secure configuration; community-small deployments need no key, and no host ever contains an issuer private key;
  • community-small deployments verify the 29/30/31 boundary, concurrent activation, idempotent repeated/concurrent release, seat release through disable/delete, and startup reconciliation;
  • online or offline leases pass ES256 signature and context validation;
  • readiness, state transitions, and refresh failures have alerts;
  • Grace, Expired, and Revoked business/portability behavior has tests;
  • key rotation, License Server outage, and recovery have been rehearsed;
  • logs and Problem Details expose no KeyId, signature, or payload.

See also

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%