PRD: License lifecycle, activation & runtime validation
| Module | Licensing (CORE-15) | PRD ID | PRD-ACT-001 |
| Status | Shipped | Owner | Licensing squad |
| Date | 2026-06-15 | Version | v1.0 |
| Packages | @nx/licensing · @nx/core | URD | LIC · ACT · VAL |
TL;DR
Turns an issued license into something a running product can trust. A license is issued from a plan with a unique key, then driven through a guarded lifecycle - suspend, reinstate, renew, revoke - where each transition is checked against the current status, audited, and triggers a re-signed certificate. Devices activate against the license by fingerprint, each unique device taking one seat up to the plan's limit. At runtime any service validates a key and gets back a status code, the resolved feature set, the seat usage, and a signed certificate it verifies offline - so feature and seat gating never needs a live call to licensing. Lifecycle transitions are refused when they don't make sense (renew a perpetual, reinstate a revoked), and expiry is detected lazily on the next validation rather than swept eagerly.
1. Context & Problem
PRD-PLN-001 established the licensing spine - reusable plans with typed feature flags, license issuance, and a first validation call. It defines what a license is; it does not specify, in depth, the operational runtime path a license travels once issued: how each lifecycle transition is guarded, how devices take and release seats, and exactly what a validation call returns under every license state.
Without that depth, the runtime contract is ambiguous. Can an expired license be renewed back to life? What happens when a suspended license is renewed? Does a device that re-validates consume a second seat? What code does a service receive when a license is past its grace window versus merely suspended? Each consuming service would have to guess, and the answers would drift apart across modules.
This increment pins down the issue → activate → validate path as one trustworthy, code-accurate contract: a status-guarded lifecycle state machine, fingerprint-based device activation with seat enforcement, and a validation pipeline whose outcome codes, lazy-expiry behaviour, resolved features, seat handling, and signed certificate are all specified - so every service gates features and seats offline against one definition.
2. Goals & Non-Goals
Goals
- Issue a license from a plan with a unique generated key, computing expiry and grace from the plan.
- A guarded lifecycle - suspend, reinstate, renew, revoke - where every transition validates the current status, is audited, and re-signs the certificate.
- Device activation by fingerprint that is idempotent per device and enforces the effective seat limit.
- A runtime validation call returning a precise status code, the resolved feature set, the seat usage, and a signed certificate.
- Lazy expiry - a license past its grace window flips to expired on the next validation, with no background sweeper.
- A signed certificate consuming services verify offline, refreshed on every lifecycle change.
Non-Goals
- Defining plans and feature flags themselves - specified in PRD-PLN-001 (URD-PLN).
- Billing, invoicing, dunning, or payment-charge integration.
- Per-feature runtime enforcement inside consuming modules - each module gates itself against the certificate.
- Usage metering, heartbeats, or eager / scheduled expiry sweeping.
- A license-management UI (frontend concern).
3. Success Metrics
| Metric | Target / signal |
|---|---|
| Lifecycle correctness | Every guarded transition (renew a perpetual, reinstate a non-suspended, renew/reinstate a revoked) is refused with a status-specific reason |
| Seat integrity | One seat per unique (license, device fingerprint); a re-activating device never consumes a second seat |
| Validation determinism | Validation outcome code matches the license state for every case (valid / grace / not-started / expired / suspended / revoked / not-found / seat-limit) |
| Offline gating | Consuming services gate features and seats against the certificate - zero live licensing calls per request |
| Audit completeness | 100% of lifecycle and activation actions write an append-only event; each lifecycle change re-signs the certificate |
4. Personas & Use Cases
| Persona | Goal in this feature |
|---|---|
| Platform Operator | Issue a license and drive it through suspend / reinstate / renew / revoke with confidence each transition is guarded |
| Merchant Owner | Self-serve a single free trial and run on a valid license across their devices |
| Device / POS client | Activate against a license by fingerprint and stay within the seat limit |
| Consuming service | Validate a key, read the resolved features and seat usage, and gate offline against the signed certificate |
Core scenario: an operator issues a subscription license for a merchant; the merchant's POS devices activate by fingerprint, each taking one seat up to the plan's limit; on every launch a service validates the key and receives VALID with the resolved features, seat usage, and a signed certificate it caches and verifies offline. When the license passes its grace window, the next validation flips it to expired and returns LICENSE_EXPIRED; renewing it restores it to active with a fresh validity window and re-signs the certificate.
5. User Stories
- As a platform operator, I issue a license from a plan and get a unique key, so the entitlement is tied to a known principal.
- As a platform operator, I suspend then reinstate a license, and the system refuses to reinstate anything that isn't suspended, so the lifecycle can't be corrupted.
- As a platform operator, I renew a license to extend it, and renewing a perpetual or a revoked license is refused, so renewal only happens where it makes sense.
- As a platform operator, I revoke a license permanently, knowing revoked is terminal and can never be renewed or reinstated.
- As a device client, I activate by fingerprint and re-activating the same device reuses its seat, so a device never burns two seats.
- As a consuming service, I validate a key once and gate features and seats offline against the certificate, so I never call licensing per request.
- As a consuming service, I get a precise status code on validation, so I can tell a grace-period license from a suspended one.
6. Functional Requirements
| # | Requirement | URD ref |
|---|---|---|
| FR-1 | Issue a license from a plan with a unique generated key; compute expiry and grace from the plan's duration (perpetual = no expiry) | URD-LIC-001..002 |
| FR-2 | Suspend an active license, then reinstate it - reinstate is refused unless the current status is suspended | URD-LIC-003..004 |
| FR-3 | Renew extends validity from the later of the current expiry or now and restores an expired license to active | URD-LIC-005 |
| FR-4 | Renew is refused for a perpetual license, and for a suspended or revoked license | URD-LIC-006..007 |
| FR-5 | Revoke is terminal - a revoked license can never be renewed or reinstated | URD-LIC-007 |
| FR-6 | Every lifecycle action writes an append-only event and re-signs & re-publishes the certificate | URD-LIC-009..010 |
| FR-7 | A merchant self-serves a single free-trial license; an existing trial is returned, never duplicated | URD-LIC-008 |
| FR-8 | Activate a license on a device by fingerprint (optional label / platform / hostname); re-activating the same device reuses its seat | URD-ACT-001..002 |
| FR-9 | Active device seats cannot exceed the effective seat limit (override else plan); a null limit means unlimited; deactivation frees a seat | URD-ACT-003..005 |
| FR-10 | Activation is refused when the license is not in an active status | URD-ACT-007 |
| FR-11 | Validate a key and return a result code: valid, grace-period, not-started, expired, suspended, revoked, not-found, or seat-limit-reached | URD-VAL-001..004 · URD-VAL-007 |
| FR-12 | A license past its grace window is lazily flipped to expired on validation - no background sweeper | URD-VAL-005 |
| FR-13 | Validation returns the resolved feature set (per-license overrides applied) and the seat usage (used / limit) | URD-VAL-006..007 |
| FR-14 | A successful validation returns a signed certificate other services verify offline; validation records the last-validated time best-effort | URD-VAL-008..009 |
Full requirement text and acceptance criteria live in the Licensing URD. This PRD references them rather than restating them. Plans and feature-flag requirements (URD-PLN) are specified in PRD-PLN-001.
7. Non-Functional Requirements
| Area | Requirement |
|---|---|
| Consistency | Lifecycle transitions lock the license row and run in a transaction; a partial failure rolls back fully |
| Concurrency | Lazy expiry uses a conditional status flip so two simultaneous validations cannot double-transition; a losing writer re-reads the actual status |
| Idempotency | A device seat is keyed on (license, fingerprint) - re-activation and re-validation of the same device never create a second seat |
| Data integrity | No state change without a matching append-only audit event; each lifecycle change re-signs the certificate |
| Trust | The certificate is Ed25519-signed and verifiable offline by any consuming service; it is cached with a TTL and refreshed on lifecycle change |
| Performance / scale | Consumers verify the certificate offline - no live licensing call per request; expiry is detected lazily, never swept |
| Tenancy & authz | A license is bound to exactly one principal (merchant or user); lifecycle and activation actions are gated by licensing-scoped permissions |
| i18n | License display name is bilingual ({ en, vi }) |
8. UX & Flows
License lifecycle state machine - each transition is guarded by the current status:
Runtime validation with device seat handling:
Backend-only surface in @nx/licensing - issuance and lifecycle actions, device activation, and the validation call. There is no license-management UI in this increment.
9. Data & Domain
| Entity | Role in this path |
|---|---|
License | The issued entitlement - unique key, status, computed expiry / grace, optional overrides, and the cached signed certificate |
Activation | One device seat bound to a license by fingerprint, with optional label / platform / hostname |
LicenseEvent | An append-only audit record of every lifecycle and activation action |
Policy | The plan a license is issued from - supplies duration, grace, seat limit, and feature flags read during resolution |
PolicyFeature | A typed feature flag resolved into the validation result and the certificate |
Conceptual only - full schema and invariants in the licensing domain model. All five tables live in
@nx/coreand are re-exported by@nx/licensing.
10. Dependencies & Assumptions
Depends on
- Plans & feature flags (PRD-PLN-001, URD-PLN) - a license is issued from a plan whose duration, grace, seat limit, and flags drive this path.
- Commerce (Commerce) - a license is bound to a merchant (or user) principal.
- Permissions (Permissions) - lifecycle and activation actions are gated by
licensing-scoped permissions. - A signing key pair (Ed25519) for the certificate, with the public key distributed to consumers for offline verification.
Assumptions
- A free-trial plan is seeded so merchants can self-serve from day one.
- Consuming modules do their own per-feature gating against the certificate; licensing does not enforce features inside them.
- Reporting tolerates lazy (not eager) expiry detection.
11. Risks & Open Questions
| Risk / question | Mitigation / status |
|---|---|
| Two concurrent validations both detect expiry | Conditional status flip - only one writer transitions; the other re-reads the actual current status |
| A device re-validating could consume a second seat | Seat is keyed on (license, fingerprint) - an existing seat is reused before any new one is created |
| Lazy expiry may lag reporting accuracy | Accepted; expiry flips on the next validation. Open: a scheduled sweeper if eager expiry is ever needed |
| Lifecycle transition on a stale read | Each transition locks the license row inside a transaction before checking status |
| Certificate trust depends on key distribution | Public key shipped to consumers; the rotation path must be defined before any key change |
| Fixed-duration arithmetic for renew / expiry | Open: move to calendar-aware months / years if required |
12. Release Plan & Launch Criteria
| Aspect | Plan |
|---|---|
| Phase | P1 (lifecycle + validation) and P2 (device activation + certificate distribution) - see URD feature catalog |
| Rollout | Part of @nx/licensing; no feature flag; a free-trial plan is seeded so merchants self-serve from day one |
| Migration | None - runs on the existing licensing entities (tables live in @nx/core) |
| Launch criteria | Issue → activate → validate works end-to-end; every guarded transition is refused with a status-specific reason; a device never burns two seats; validation returns the correct code, resolved features, seat usage, and a certificate verified offline; a past-grace license flips to expired on the next validation |
| Monitoring | Validation outcome-code distribution, seat-limit rejection rate, lifecycle-action vs. audit-event consistency, certificate signing failures |
13. FAQ
Can I renew an expired license back to life? Yes - renew restores an expired license to active with a fresh validity window. But renew is refused for a perpetual license (nothing to extend) and for a suspended or revoked one.
Is revoke reversible? No - revoked is terminal. A revoked license can never be renewed or reinstated; issue a new one instead.
Does a device that validates twice take two seats? No - a seat is keyed on the (license, device fingerprint) pair. The second validation reuses the existing seat; no new seat is consumed.
What happens the moment a license passes its grace window? Nothing until it is next validated - there is no background sweeper. The next validation lazily flips it to expired, audits the change, re-signs the certificate, and returns LICENSE_EXPIRED.
How does a service tell a grace-period license from a suspended one? By the result code - a license within grace validates as valid with GRACE_PERIOD, while a suspended one validates as invalid with a status-specific code.
Does each service call licensing on every request? No - a service validates the key once and verifies the returned signed certificate offline. Per-request gating happens against the certificate.
References
- URD: Licensing - License Lifecycle · Device Activation · Runtime Validation & Entitlements
- Sibling PRD: Plans, Licenses & Runtime Entitlements
- Module: Licensing - overview + traceability
- Developer: @nx/licensing · domain model