Documentation
Table of Contents

Licensing enforcement

IntentSharp checks a signed license key offline when a client is built. By default a missing, invalid or expired key never stops the library; it logs. The one hard stop is the version gate: a build released after a paid key's maintenance-until date refuses that key at construction. Evaluation keys are never gated. There is no phone-home and no kill switch.

The terms behind this are in LICENSE and COMMERCIAL-LICENSE.md. The decision is ADR 0010. The plan that built it is workplan-v3-licensing.md.

Where the pieces live

Path What Ships with the library
src/IntentSharp/Licensing/LicenseKey.cs LicenseEdition, LicensePayload, LicenseKeyCodec, LicenseKeyStatus, LicenseVerification, LicenseVerifier Yes
src/IntentSharp/Licensing/LicensePublicKeys.cs LicensePublicKeys (trusted public keys) and ReleaseInfo (this build's release date and version) Yes
src/IntentSharp/Licensing/LicenseEnforcer.cs LicenseEnforcer, the LicenseState enum and the LicenseStatus record it returns Yes
src/IntentSharp/Licensing/LicenseException.cs LicenseException, thrown by Build() Yes
src/IntentSharp/Licensing/LicenseNotice.cs LicenseInfo (public constants: license name, licensor, contact, purchase URL, free-use summary, purchase instructions) and LicenseNotice (internal and stateless: the text and log level of each state's notice, and the EventId) Yes
src/IntentSharp.Licensing.Signing/ LicenseSigner: key pair generation and signing (private-key side) No
tools/IntentSharp.LicenseTool/ intentsharp-license, the command-line tool for keys No
services/IntentSharp.LicenseService/ The license service: evaluation form, Stripe checkout and webhook No

The library only verifies. It contains public keys and LicenseVerifier, never a private key and never a reference to the signing project. The signing project is referenced by the tool, the service and the test projects.

Key format

A key is one line of text:

ISK1.<base64url payload JSON>.<base64url signature>
  • ISK1 is the format prefix (LicenseKeyCodec.Prefix). Before the product was renamed to IntentSharp the prefix was SSK1; no production key was issued with it, and a key that starts with SSK1. is refused as Malformed like any other wrong prefix (ADR 0010, addendum).
  • The payload is UTF-8 JSON serialized with web defaults (camelCase), enums as strings and nulls omitted (LicenseKeyCodec.JsonOptions).
  • The signature is ECDSA P-256 over SHA-256 of the exact payload bytes that appear in the key, in IEEE P1363 fixed-field form (64 bytes). Because the signed bytes are the bytes in the key, there is no canonicalization step.
  • base64url is unpadded, with - and _ in place of + and /.

The payload (LicensePayload):

JSON name Property Meaning
v Version Payload version. Must be 1.
id Id License id, for example lic_ plus 20 characters (LicenseSigner.NewLicenseId()). Required.
licensee Licensee The licensed organization or person. Required.
email Email Contact address. Optional.
edition Edition Evaluation, Developer, Team, Business, Enterprise or Oem.
seats Seats Licensed developers; 0 means unlimited (Enterprise and Oem) and is shown as "unlimited seats". Informational; the library never counts seats. LicenseSigner.Sign rejects a negative count.
issued Issued Issue date.
expires Expires End of the paid term, or of the evaluation.
maintenanceUntil MaintenanceUntil The version gate date (see below).
features Features A list of strings. Not interpreted by the library today.
notes Notes Free text. Optional.

Dates are calendar dates in UTC (DateOnly).

LicenseKeyCodec.TryDecode rejects a key that is empty, does not have three dot-separated parts starting with ISK1, contains invalid base64url, has a signature that is not 64 bytes, has a payload that is not valid JSON, or has a payload with a version other than 1 or an empty id or licensee. It does not check the signature.

The verifier

LicenseVerifier is built from one or more SubjectPublicKeyInfo-encoded P-256 public keys (base64). It needs at least one. Verify(key) returns a LicenseVerification whose Status is:

LicenseKeyStatus When
Missing The key is null, empty or whitespace.
Malformed TryDecode failed; Error says why.
BadSignature The key decodes, but no trusted public key verifies the signature. The decoded payload is returned for diagnostics and must not be trusted.
Valid One trusted public key verifies the signature.

The verifier is thread-safe and does not interpret dates or editions. That is the enforcer's job.

Enforcement states

LicenseEnforcer applies the policy for one client. Its public surface:

Member What it does
constructor (verifier, timeProvider, releaseDate, logger = null, reminderInterval = null) The trusted keys, the clock, the build's release date (ReleaseInfo.ReleaseDate in the client), the logger for the notices (null discards them) and the reminder interval (null uses DefaultReminderInterval; zero or less throws).
Evaluate(key) Verifies and classifies the key for today, makes the result the current Status and returns it. Logs nothing.
Status The current LicenseStatus. Set by Evaluate; MaybeRemind refreshes its date-based states (see The reminder). Throws InvalidOperationException before Evaluate.
ThrowIfGated() Throws LicenseException when the state is VersionGated.
ThrowIfUnlicensed() Throws LicenseException when the state is None, Invalid or EvaluationExpired. The builder calls it only under FailWhenUnlicensed.
LogStatus() Writes the current state's notice now (the Build() line) and restarts the reminder interval.
MaybeRemind() The request-path reminder, which also refreshes the date-based state. Returns true when it wrote a notice. Never throws for a license reason.
ReleaseDate, ReminderInterval The values it was constructed with.
RenewalWindowDays 30: the days after a paid key's expiry during which it is RenewalDue rather than Lapsed.
DefaultReminderInterval One hour, the default of IntentClientOptions.LicenseReminderInterval.

LicenseStatus is a record with State (a LicenseState), Payload (the signed payload, or null for None and Invalid), Message (the notice text) and DaysRemaining (days to the expiry date for Evaluation and Licensed, to the end of the renewal window for RenewalDue, 0 on the last day, null otherwise).

LicenseState has the values None, Invalid, Evaluation, EvaluationExpired, Licensed, RenewalDue, Lapsed and VersionGated. Missing maps to None, Malformed and BadSignature map to Invalid (the untrusted payload of a bad signature is not exposed), and a Valid key maps to one of the remaining states by its edition and dates:

State Condition Behaviour
None no key configured works; Warning logged at Build() and again at most once per hour on use
Invalid malformed or bad signature works; Error logged at Build() and hourly, naming the problem
Evaluation edition Evaluation, today <= Expires works; Information at Build() with days remaining
EvaluationExpired edition Evaluation, today > Expires works; Error at Build() and hourly: "evaluation ended, purchase required"
Licensed paid edition, today <= Expires quiet; one Information line at Build() naming licensee and edition
RenewalDue paid edition, Expires < today <= Expires + 30 days works; Warning at Build() and hourly
Lapsed paid edition, today > Expires + 30 days works; Error at Build() and hourly
VersionGated paid edition, ReleaseInfo.ReleaseDate > MaintenanceUntil (checked before the date rows) Build() throws LicenseException with licensee, dates, and the purchase instructions. Nothing else runs.

"Today" is the UTC calendar date from the enforcer's TimeProvider, so tests can set the clock. Paid editions are every edition except Evaluation. An Evaluation key is never VersionGated: it is Evaluation or EvaluationExpired by its expiry date, whatever the build's release date.

Every notice is written through the injected ILogger with EventId 9100, named "IntentSharp.License", so operators can filter or route license lines. The level is Information for Evaluation and Licensed, Warning for None and RenewalDue, and Error for Invalid, EvaluationExpired and Lapsed. When Build() throws (the gate, or FailWhenUnlicensed) nothing is logged; the exception carries the message. The text and level of each notice come from the internal, stateless LicenseNotice; when and how often a notice is written is decided by the client's own LicenseEnforcer. No license state is static: two clients in one process keep separate reminder clocks.

The version gate

Every build carries its release date in ReleaseInfo.ReleaseDate (2026-10-11 for version 2.0.0-preview.1, the value in ReleaseInfo.Version). Both values are read at run time from the assembly: <Version> and <ReleaseDate> are set in Directory.Build.props at the repository root, shared by every project, and src/IntentSharp/IntentSharp.csproj stamps the date into the core assembly as the AssemblyMetadata entry "ReleaseDate". A valid paid key whose MaintenanceUntil is earlier than that date is refused by this build: Build() throws a LicenseException that names the licensee and the dates and says how to buy, and no client is created.

  • The gate applies to paid editions only and is checked before their date states. An Evaluation key never throws at Build() because of the gate; it reports Evaluation or EvaluationExpired.
  • It is the only hard failure, and it fires at construction, never during a request. A process that is already running is never stopped.
  • A key with no trusted signature has no trustworthy MaintenanceUntil, so the gate does not apply to None or Invalid; those states log instead (unless FailWhenUnlicensed is set).
  • This is what makes the commercial fallback work: after a subscription lapses, every build released on or before the key's maintenance-until date keeps accepting the key, and every later build refuses it. The contract wording is in COMMERCIAL-LICENSE.md section 3 item 5 and FAQ 7.6.
  • <ReleaseDate> in Directory.Build.props must be bumped together with <Version> for every release, or the gate compares keys against an old date. A build without the metadata fails at type initialization rather than defaulting silently.

The reminder

IntentClient.TransformAsync<T> calls MaybeRemind() at its start. GenerateAsync, ExecuteIntentAsync, the extension methods and SemanticDatabase all go through TransformAsync<T>.

MaybeRemind() does nothing but read the clock until at least IntentClientOptions.LicenseReminderInterval (default one hour) has passed since the last notice. It is a timestamp comparison against the TimeProvider on the request path: no timer and no background thread. One caller per interval does the rest; concurrent callers return at once. If the clock moves backwards, the interval restarts without a notice.

When the interval has passed, MaybeRemind() does two things:

  1. It refreshes the state. It re-applies the date rules to a verified key for today and replaces Status when the state or the days remaining changed. A long-running client built as Licensed therefore becomes RenewalDue the day after the expiry date and Lapsed the day after the 30-day renewal window ends, and an Evaluation becomes EvaluationExpired the day after its expiry date, without a restart. The change appears on the first request after the date changes once the interval has passed since the last notice. The signature and the version gate are not checked again: they cannot change while the process runs. None, Invalid and VersionGated are never refreshed.
  2. It repeats the notice for the states whose row says "hourly" (None, Invalid, EvaluationExpired, RenewalDue and Lapsed), using the refreshed state. Evaluation and Licensed do not remind, but once a refresh moves a client into a repeating state, that state's Warning or Error is logged from then on.

The refreshed state is what IntentClient.License returns.

FailWhenUnlicensed

IntentClientOptions.FailWhenUnlicensed defaults to false. When it is true, Build() throws a LicenseException for None, Invalid and EvaluationExpired instead of logging. It is for teams that want a hard stop in their own pipelines. RenewalDue and Lapsed are not affected; they still work and log.

How the client uses it

  • IntentClientBuilder.Build() creates the enforcer with LicensePublicKeys.All, evaluates IntentClientOptions.LicenseKey, throws for the gate or for FailWhenUnlicensed, logs the state, and hands the enforcer to the client. An internal hook lets tests pass their own verifier, TimeProvider and release date.
  • IntentClient.License returns the enforcer's current LicenseStatus: the state evaluated at Build(), with its date-based state refreshed at most once per LicenseReminderInterval while the client is used (see The reminder).
  • services.AddIntentSharp(...) builds the client through the same builder, so the same evaluation runs. A LicenseException propagates unchanged; an application that resolves IntentClient at startup fails at startup when the gate fires.
  • The key comes from IntentClientOptions.LicenseKey: IntentClientBuilder.WithLicenseKey(...) in code, or IntentSharp:Options:LicenseKey in configuration with AddIntentSharp.

Key rotation

LicensePublicKeys.All lists every trusted public key; today it holds one, LicensePublicKeys.Production2026. LicenseVerifier accepts a key signed by any listed key, so rotation is additive:

  1. Generate a new pair with intentsharp-license keygen --out <dir>. It prints the public key and the C# line to paste into LicensePublicKeys.cs.
  2. Add the new public key to LicensePublicKeys and to All. Keep the old key listed until every license signed with it has expired.
  3. Release a build that trusts the new key.
  4. Only then switch the tool and the license service to the new private key. A key signed with a private key that a build does not trust is Invalid in that build.

The service side of rotation (configuration, backups) is in ../operations/license-service.md.

The private key

  • The production private key is not in the repository and never will be. It is kept outside the repository by the owner. .gitignore excludes *.pem and *signing-key*.
  • Nothing logs, stores or returns a private key.
  • Tests never need the production key. They generate a pair with LicenseSigner.GenerateKeyPair() and construct LicenseVerifier with that public key.

The license tool

tools/IntentSharp.LicenseTool builds the console tool intentsharp-license. It parses its own arguments and depends only on the signing project.

Command What it does
keygen --out <dir> Writes intentsharp-license-signing-key.pem (private key, PKCS#8) and intentsharp-license-public-key.txt (SPKI base64). The private key file is readable by the current user only: mode 0600, or on Windows a protected access list with one full-control rule for the current user and nothing inherited from the folder. Refuses to overwrite. Prints the public key and the C# line for LicensePublicKeys.cs.
sign --key <pem path> --licensee <name> --edition <edition> [options] Signs a key and prints it on stdout only, so it can be piped; a one-line summary goes to stderr. Options: --email, --seats <n\|unlimited> (0 or unlimited means unlimited), --issued, --expires, --maintenance-until, --notes, --id. The key file is read like the service reads it: blocks that hold no key, such as the EC PARAMETERS block openssl ecparam -genkey writes before the key, are skipped.
verify --public <base64 or path> <key> Prints the status and the decoded payload as a table. For a key with a bad signature it still prints the payload, under a line saying it is not trusted. - as <key> reads the key from standard input. Exit code 0 only for a valid key.
inspect <key> Prints the payload without checking the signature, and says so. - as <key> reads the key from standard input.

verify, inspect and the sign summary print key text with control characters, format characters (bidirectional overrides, zero-width and other invisible marks) and line separators escaped as \uXXXX, so a licensee cannot hide or reorder text on the terminal. Zero-width non-joiner and joiner stay as they are. | help [<command>] | Prints the tool's usage, or one command's. |

sign defaults: issued is today (UTC); seats are 1 for Developer and Evaluation, 10 for Team, 50 for Business, and unlimited (stored as 0) for Enterprise and Oem; expires is issued plus 29 days for Evaluation (30 calendar days including the issue day) and issued plus one year otherwise; maintenance-until equals expires; the id is new. Every command takes --help or -h. Options can be written --name value or --name=value. Exit codes: 0 success, 1 failure, 2 usage error.

The license service

services/IntentSharp.LicenseService is an ASP.NET Core minimal API on net10.0 that issues evaluation keys from a web form and paid keys from Stripe. The licensor and seller it names is C2Consultants LLC. It runs on Azure App Service: a test copy on the Stripe sandbox is live, and the production address, https://license.intentsharp.com, is bound to App Service but not live yet; until it is, email is the way to buy (COMMERCIAL-LICENSE.md section 4).

Endpoint What it does
GET /, GET /evaluate The evaluation form: name, email, company, intended use, and a checkbox accepting the license terms.
POST /api/evaluation Validates the form, allows one evaluation per email domain per 365 days (per address for free-mail domains), rate-limits to 5 requests per IP per hour, signs a 30-day one-seat Evaluation key, stores the lead and the key, and emails the key. The response never shows the key.
GET /buy The buy page: Developer, Team, Business and Enterprise with their prices, one form that posts to /api/checkout.
POST /api/checkout Creates a Stripe Checkout Session in subscription mode for an edition and returns its URL (JSON), or redirects to it (a form post from the buy page). Seat ranges: Developer 1 to 1,000, Team 1 to 10 (the page always sends 10), Business 11 to 50, Enterprise 1 to 100,000 (the key is issued with unlimited seats whatever the count). With Stripe:RequireTermsAcceptance true the session requires the buyer to accept the terms of service. Stripe returns the buyer to Stripe:SuccessUrl (/buy/complete) or Stripe:CancelUrl (/buy).
GET /buy/complete The success page: thanks the buyer and says the key is on its way to the checkout email address, normally within a few minutes, and what to do if it has not come within an hour. It never shows a key, reads no query value (Stripe's session_id is never echoed), calls no Stripe API and sets no cookies; the key comes only from the webhook, by email.
POST /api/stripe/webhook Verifies the Stripe signature, ignores duplicate event ids, and handles checkout.session.completed when the session is paid and checkout.session.async_payment_succeeded (sign and email a paid key for one year; an unpaid session waits for the later event), invoice.paid for a subscription cycle (sign and email a key that ends when the billing period the invoice paid for ends, never before the old dates; one year past the old dates when the invoice carries no period; an invoice whose period is not about one year renews nothing), and customer.subscription.deleted (mark the license lapsed; the version gate does the rest). Answers 400 for a bad or missing signature and 503 when the webhook secret is not configured.
GET /LICENSE, GET /COMMERCIAL-LICENSE.md The license texts, embedded at build time from the repository root files. The evaluation form links to them.
GET /healthz Health check.

It stores leads, licenses and processed Stripe event ids in SQLite. A lead holds what the evaluation form asked for (name, email address, company, intended use), when the terms were accepted, the issued key's license id, and the normalized address and domain the one-per-year rule compares; the key itself, with the company as licensee and the address, is in the lead's Evaluation row in licenses. A background job (Retention/) deletes each lead, with the Evaluation license row it names, once its key was issued more than LicenseService:LeadRetentionDays days ago (default 730, the 24 months of the privacy notice): one minute after start and then daily, in batches of 100 with one transaction each. It never deletes a paid license, a row with any Stripe id, or a Stripe event id, and never a lead the one-per-year rule can still read (one younger than 365 days, whatever the setting). A shutdown during a run rolls back the batch in progress and keeps the batches before it, and the run logs their counts. A request to see or delete one person's records is handled by hand with SQL commands; there is no endpoint for it. Deleting a lead on request removes what the one-per-year rule reads, so the rule resets for that domain or free-mail address unless another lead of the last 365 days shares it; whether to keep a record without personal data instead is an open owner decision (operations guide, section 9). Copies of the key emails kept by the sending mailbox, and the log store, are outside the database and outside both deletions.

The service sends mail through one of three providers chosen by Email:Provider: Microsoft Graph sendMail with an Entra ID app registration (the owner's Office 365 path), SMTP, or a log sender that is allowed only in Development and Testing unless Email:AllowLogSender is true (then every key is written to the log). Keys bought through Stripe are stored before their email is queued, so a failed send never loses a key; it is logged with the license id for a manual resend, and so is every key whose email was not confirmed sent when the host's shutdown timeout passed.

Every page ends with a footer naming the seller, C2Consultants LLC, and the support address, with "Terms of sale" and "Privacy" links when LicenseService:TermsUrl and LicenseService:PrivacyUrl are set. Every key email ends with the support address, the license id to quote and the seller's name; the purchase and renewal emails also link the terms of sale when LicenseService:TermsUrl is set. The support address is LicenseService:SupportEmail, else Email:From, else licensing@c2consultants.net (Configuration/ServiceContact.cs).

Running, configuring, deploying and backing it up are in ../operations/license-service.md; the browser runbook for Stripe and the Entra app is ../operations/stripe-and-microsoft365-setup.md.

What enforcement does not do

  • It makes no network call. A key is checked against public keys compiled into the build.
  • It never stops a running process and never refuses a request. Expired and lapsed keys log.
  • It does not count seats, machines or deployments.
  • It cannot stop someone who edits the source. IntentSharp is source-available; removing or circumventing the check is a breach of LICENSE section 5.3, and that is the enforcement for it.