Platform licences
French version: ../licensing.md
The platform is installed with an annual (12 months) or triennial (36 months) licence, signed by the publisher and
verified locally without any network call. The licence never limits users, agents, applications, use cases or
plugins and carries no price: a licence payload holding a price, amount, currency, seat, quota, limit or counter
field is refused (422 license_forbidden_field). FinOps budgets and security limits chosen by the customer remain
customer settings, unrelated to the licence.
Licence content
JWS compact signed with EdDSA (Ed25519), header {"alg": "EdDSA", "kid": ..., "typ": "license+jws"}, canonical
JSON payload conforming to contracts/v1/license-entitlement.schema.json:
| Field | Purpose |
|---|---|
license_id | lic-... identifier |
customer_id, product_id, edition | customer, product, edition (enterprise, enterprise-sovereign) |
installation_group_id | installation group: every installation of the group accepts the licence |
environments | included environments (dev, test, staging, prod) |
valid_from, valid_until, term_months | period; term_months is 12 or 36 and valid_until = valid_from + term_months months |
grace_days | grace period after valid_until (0 to 90 days) |
offline_allowed | true: may be activated from a file on an air-gapped installation; false: API upload only |
support_ref, issued_at, key_id, schema_version | support reference, issue date, signing key, contract version |
Issuance happens on the publisher side with tools/license-issuer (README (tools/license-issuer/README.md)).
Preparing the installation
- Declare the publisher public key(s) delivered with the release bundle:
EAI_LICENSE_PUBLIC_KEYS=publisher-2026:<x>(see configuration.md). - An installation receives a unique
installation_idat migrationV110; it never changes, even after a database restore. Theinstallation_group_idis set by the first activation. - Without a licence the installation works from day one (state
unlicensed, information banner).
Online activation (upload)
A platform administrator uploads the token:
curl -sS -X POST https://<control-plane>/v1/license/activations \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "{\"token\": \"$(cat acme.lic)\"}"
"Online" only means going through the Control Plane API: verification stays local, no publisher service is
contacted. Response 201 {activation: activated | already_active, license, status}.
Air-gapped activation
Copy the licence file into the Control Plane pod, then:
kubectl -n enterprise-ai exec deploy/control-plane -- \
python -m eai.domains.licensing.cli activate --file /tmp/acme.lic --actor ops-jdoe
kubectl -n enterprise-ai exec deploy/control-plane -- python -m eai.domains.licensing.cli status
The licence must have offline_allowed: true (else 422 license_offline_not_allowed: upload it through the API).
The activation is written to the history and the platform audit chain with actor cli:<actor>. The test
tests/test_licensing.py::test_offline_issuance_and_file_activation_without_network runs issuance and activation
with every network access denied.
States
With now the UTC time, soon = EAI_LICENSE_EXPIRING_SOON_DAYS (default 30) and the effective licence = the
started licence (valid_from <= now) whose grace ends last:
| State | Exact rule | Effect |
|---|---|---|
unlicensed | no started licence | nothing refused, information banner |
valid | valid_from <= now < valid_until - soon | none |
expiring_soon | valid_until - soon <= now < valid_until | warning banner (critical at 7 days) |
grace | valid_until <= now < valid_until + grace_days | critical banner, nothing refused |
expired | now >= valid_until + grace_days | critical banner, only the actions listed below are refused |
GET /v1/license returns the state, the dates, the remaining days and the banner (banner.level, banner.code,
banner.message_i18n.en/fr) to any authenticated user, without customer id or support reference.
Support contract
Every activation (API upload or offline file) records, in the same transaction, the support contract of the
licence (table eai.support_contracts, migration V168), from the signed data only: reference (support_ref),
customer, level (the signed edition), period (valid_from, valid_until, end of grace grace_until). A renewal
licence of the same installation group replaces the previous contract from its own start date (superseded_by,
superseded_from). The state (scheduled, active, grace, superseded, ended) is computed with the licensing
clock. The activation response (support_contract) and GET /v1/license/history (support_contracts, platform
administrator) return it; GET /v1/license never does. The contract carries no price, no amount and no currency;
the table is platform only (forced RLS, no tenant reads or writes it).
What expiry changes, and what it never changes
After the grace period (state expired) only two actions are refused with 403 license_expired:
distribution.upgrade: installing a new distribution version;marketplace.install: installing a new pack from the marketplace.
The components owning these two entry points call eai.domains.licensing.guard.require(principal, action) before
acting; each refusal is written to the history and the audit chain (license.guard.<action>).
Expiry never changes (list never_restricted of GET /v1/license): authentication, authorization and policies,
security controls, the audit trail and SIEM forwarding, incidents and containment, the kill switch, backup, restore
and recovery, running and critical agents, new agent runs, the Model Gateway, installed plugins and packs, users
and tenants, configuration, monitoring and alerts, licence activation and renewal. There is no commercial kill
switch. The test tests/test_licensing.py::test_expiry_refuses_only_the_documented_actions_and_nothing_stops
demonstrates it.
Warnings
The license_check job of python -m eai.worker (interval EAI_WORKER_LICENSE_CHECK_SECONDS) records each notice
once: 30, 7 and 1 days before valid_until (EAI_LICENSE_WARNING_DAYS), start of grace, end of grace. Each notice
writes a history event, an audit event license.notice.<kind>, a license.alert log line and an event sent to the
configured SIEM targets. A renewal scheduled without a gap silences the warnings. The job never stops anything.
Renewal
Issue a new licence (new license_id, same installation_group_id) whose valid_from is at the latest the end of
the grace of the current licence, then activate it as above. It becomes effective by itself on its valid_from;
the banner shows renewal_scheduled until then. An expired licence is renewed the same way: activation is never
blocked.
DR, cluster migration and second site
- Restore: the restored database keeps
installation_id,installation_group_idand the licences; the state is unchanged, no reactivation is needed (reactivating the same token answersalready_active). - Cluster migration or DR site: a new installation (new
installation_id) activates the same licence file; it joins the licence group. - A licence of another group is refused (409
license_group_mismatch). - Demonstration:
tests/test_licensing.py::test_dr_restore_cluster_migration_and_second_site.
History and API
| Route | Required role | Use |
|---|---|---|
GET /v1/license | any authenticated user | state and banner |
POST /v1/license/activations | platform_admin | activation by upload |
GET /v1/license/history | platform_admin | installation, licences, paginated events |
Each route takes a policy decision (license.read, license.activate, license.history.read) recorded in the
audit chain. Error codes: license_invalid_token, license_signature_invalid, license_unknown_key,
license_invalid_payload, license_forbidden_field, license_invalid_term, license_environment_not_included,
license_offline_not_allowed, license_group_mismatch, license_conflict, license_expired.
Publisher key rotation
Add the new key to EAI_LICENSE_PUBLIC_KEYS and keep the old one while licences signed with it are current. A
removed key makes its licences unverifiable for a new activation (422 license_unknown_key); licences already
activated stay recorded and the state does not change.
Troubleshooting
| Code | Cause | Action |
|---|---|---|
license_unknown_key | kid missing from EAI_LICENSE_PUBLIC_KEYS | declare the delivered public key |
license_signature_invalid | file modified or different key | ask the publisher for the file again |
license_environment_not_included | EAI_ENVIRONMENT not in environments | ask for a licence including this environment |
license_group_mismatch | licence of another installation group | use the licence of the group |
license_offline_not_allowed | offline_allowed: false | upload through the API |