DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Originally published at delta1labs.com

Selling updates: how a license decides which versions it may run

A perpetual license and a maintenance window are different promises that customers routinely buy as one SKU. "Perpetual, with one year of updates" means the license is valid forever but is only entitled to the releases cut in the first year. Encode that as an expiry and you have broken the perpetual promise — the app stops working after a year. Ignore it and you have given away every future release for a one-time fee.

Keyright separates the two. Validity — signature, seat, expiry, revocation — answers is this license good? Release coverage answers a different question: may this license run this version? Coverage is a set of signed grants attached to a license, evaluated locally in your build against the version it is, with no reactivation and no network on the hot path. This post is how that machinery is shaped and how you wire it into a .NET build.

A grant is a version range, a date window, or both

A coverage grant is the smallest unit of "you are entitled to these releases." It can constrain by version, by date, or by both at once:

  • Version range — [2.0.0, 3.0.0): every 2.x release, nothing from 3.0 on. This is "one major version."
  • Date window — releases cut between two dates. This is "one year of updates," expressed as the releases whose entitlement date falls inside the window.
  • Both — a range and a window, intersected: "2.x releases cut within your first year."

You rarely append raw grants by hand. A preset expands a plain-language term into the right grant shape — lifetime, updates-for-period, one-major, one-major-plus-upgrade-protection, subscription, subscription-with-fallback — so "one year of updates from purchase" becomes a date-window grant anchored on the purchase date without you computing anything.

# Apply a preset to a license; it expands into the concrete grants.
curl -X POST "$BASE/admin/licenses/$ID/coverage/preset" \
  -H "X-Admin-Token: $TOKEN" -H "content-type: application/json" \
  -d '{"preset":"updates-for-period","months":12,"from":"2026-10-10"}'
Enter fullscreen mode Exit fullscreen mode

The entitlement date is the whole trick

The subtle part of a date window is which date a release is measured by. It is not the customer's install date, not the wall clock, and not the file timestamp. Every release you register carries an entitlement date — the date coverage is judged against — and a date-window grant covers a release when that release's entitlement date falls inside the window.

# Register each release so coverage has a date to judge it by.
curl -X POST "$BASE/admin/products/acme-app/releases" \
  -H "X-Admin-Token: $TOKEN" -H "content-type: application/json" \
  -d '{"version":"3.1.0","entitlementDate":"2026-09-01","channel":"stable"}'
Enter fullscreen mode Exit fullscreen mode

This matters most for hotfixes. If a customer's update window closed on 1 August and you ship 3.1.4 in October to fix a bug in 3.1.0 (which was inside their window), judging 3.1.4 by its own cut date would push it outside the window and lock them out of a fix they are plainly entitled to. A hotfix instead inherits its base release's entitlement date, so 3.1.4 is judged exactly as 3.1.0 was. The rule: calendar time on the customer's machine never enters into it — only the entitlement date of the release does.

The signed block rides with the lease

Coverage is only trustworthy if the build cannot lie to itself about it, so grants are signed into a coverage block with the same tenant key that signs the license — verifiable offline against the public key compiled into your binary. Activation delivers that block and caches it next to the lease, so by the time your app asks "am I covered," the answer is already on disk. No separate fetch, no coverage server, no network on the path that gates startup.

Enforcing it in a .NET build

The Keyright.NET package ships MSBuild targets that stamp this build's identity into the assembly. Set them from your release pipeline so the shipped binary knows what version it is and what date it should be judged by:

<PropertyGroup>
  <KeyrightCoverageVersion>3.1.0</KeyrightCoverageVersion>                 <!-- this build -->
  <KeyrightCoverageEntitlementDate>2026-09-01</KeyrightCoverageEntitlementDate>  <!-- base release's date for a hotfix -->
  <KeyrightCoverageChannel>stable</KeyrightCoverageChannel>               <!-- or beta -->
  <KeyrightRequireCoverage>true</KeyrightRequireCoverage>                 <!-- enforce; build fails (KR0001) if the date is missing -->
</PropertyGroup>
Enter fullscreen mode Exit fullscreen mode

At startup you ask once. The SDK reads the cached block, decides locally, silently refreshes if the cached answer is "no" (coverage may have been extended since activation), and never throws on network trouble:

using Keyright.Coverage;

if (CoverageBuildInfo.Current.RequireCoverage)
{
    CoverageCheckResult check = await License.CheckCoverageAsync(ct: ct);
    if (!check.IsCovered)   // Covered and NotConfigured both pass
    {
        // check.Status: NotCovered  (Reason = WindowLapsed → renew,
        //                            OutsideVersionRange → upgrade entitlement)
        //            or UnavailableOffline (nothing cached, no network → import a coverage file)
        RunReadOnlyOrBlock(check);
    }
}

// For your own "update available" banner — local, no network, no gating:
bool canTake31 = License.IsCovered("3.1.0");
CoverageInfo cov = License.GetCoverage();   // the terms + support-until date
Enter fullscreen mode Exit fullscreen mode

NotConfigured passing is deliberate: a product that has never set up coverage must keep running, so enforcement is opt-in per build and a license with no grants is never locked out by accident.

Turning enforcement on without locking out the people who already paid

The one dangerous moment is the first time you enforce on a product that has sold perpetual licenses for years — none of them carry a grant, so a naive flip would deny every existing customer. Keyright gates that behind two steps:

# 1. How many licenses still have no coverage? This must reach zero first.
curl "$BASE/admin/products/acme-app/coverage/readiness" -H "X-Admin-Token: $TOKEN"

# 2. Give every legacy license a grant so enforcing is a no-op for them.
curl -X POST "$BASE/admin/products/acme-app/coverage/backfill" \
  -H "X-Admin-Token: $TOKEN" -H "content-type: application/json" \
  -d '{"grant":"lifetime"}'    # or a support-end date, or CSV rows
Enter fullscreen mode Exit fullscreen mode

Backfill hands existing licenses a legacy grant — lifetime, a support-end date, or explicit per-license rows from a CSV — so that enforcement, once you turn it on, changes nothing for anyone who bought before you had coverage. You enforce only after readiness reports zero uncovered licenses. A migrate endpoint with dryRun previews the gain before you apply a grant across a product.

Air-gapped: the same answer, no network ever

A machine that can never reach the service still enforces coverage, because the evaluation is local either way. Export the signed coverage block from the dashboard and import it on the offline machine:

License.ImportCoverageBlock(File.ReadAllText("acme.coverage.json"));
CoverageCheckResult check = await License.CheckCoverageAsync(ct: ct);  // fully offline
Enter fullscreen mode Exit fullscreen mode

The import is the only coverage step an air-gapped customer performs, and it carries its own signature — a tampered block fails verification and resolves to not-covered, not to a free pass.

What to take away

Keep validity and coverage apart: one says the license is good, the other says which versions it may run, and conflating them either breaks a perpetual promise or gives future releases away. Model coverage as signed grants — a version range, a date window, or both — judge every release by its entitlement date rather than any clock on the customer's machine, let hotfixes inherit their base release's date, and ship the signed block with the lease so the build decides locally and offline. Before you enforce, backfill the licenses that predate coverage so the switch costs your existing customers nothing. The payoff is the thing perpetual-plus-maintenance always wanted to be: an app that keeps running forever, and an update stream that stops exactly where the customer's entitlement does — decided in the binary, with no reactivation and nothing on the hot path.

Top comments (0)