PRD: License lifecycle, activation & runtime validation
| Module | Licensing | PRD ID | PRD-ACT-001 |
| Status | Ready to dev | FEAT | ACT |
| Epic | — | Plane | BANA-1545 |
| Date | 2026-06-15 | Version | v1.0 |
| Packages | @nx/licensing | URD | ACT |
| Surface | Backend | ||
| Owner | Phát Nguyễn | ||
What lifecycle, activation & runtime validation are
PRD-PLN-001 established the plan spine - reusable plans with typed feature flags. It defines what a license is; it does not specify 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.
This PRD pins down the issue → activate → validate path as one trustworthy 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. This increment turns an issued license into something any running service can trust offline, without calling licensing on every request.
One license, from issuance to validation
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; re-activating the same device reuses its existing seat, so no device ever burns two seats. On every launch, a consuming service validates the key once and receives a valid result 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 an expired result; renewing it restores it to active with a fresh validity window and re-signs the certificate.
Where it's easy to get wrong
Revoke is terminal; expiry is caught lazily, never swept eagerly
The system refuses every nonsensical transition: renewing a perpetual license, reinstating one that was never suspended, and renewing or reinstating a revoked one. A revoked license never comes back to life.
A license past its grace window is caught lazily: it flips to expired on the very next validation, with no background sweeper running eagerly. Before that call, the status still reads active even though it is technically past due.
1. Goals & Exclusions
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.
Exclusions
- 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).
2. 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 |
3. 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 a valid result 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 an expired result; renewing it restores it to active with a fresh validity window and re-signs the certificate.
4. User Stories
| # | As a | I want | So that |
|---|---|---|---|
| 01 | platform operator | I issue a license from a plan and get a unique key | the entitlement is tied to a known principal |
| 02 | platform operator | I suspend then reinstate a license, and the system refuses to reinstate anything that isn't suspended | the lifecycle can't be corrupted |
| 03 | platform operator | I renew a license to extend it, and renewing a perpetual or a revoked license is refused | renewal only happens where it makes sense |
| 04 | platform operator | I revoke a license permanently | I know revoked is terminal and can never be renewed or reinstated |
| 05 | device client | I activate by fingerprint and re-activating the same device reuses its seat | a device never burns two seats |
| 06 | consuming service | I validate a key once and gate features and seats offline against the certificate | I never call licensing per request |
| 07 | consuming service | I get a precise status code on validation | I can tell a grace-period license from a suspended one |
5. Functional Requirements
| # | Requirement | Status | URD ref |
|---|---|---|---|
FR-001 | Issue a license from a plan and compute its expiry from the plan's duration. • Issue a license from a plan, generating a unique key. • Compute expiry and grace from the plan's duration; a perpetual plan has no expiry. | ✅ | URD-LIC-001..002 |
FR-002 | Suspend then reinstate a license, with a guarded condition. • Suspend an active license, then reinstate it. • Reinstate is refused unless the current status is suspended. | ✅ | URD-LIC-003..004 |
FR-003 | Renew extends validity and restores an expired license. • Renew extends validity from the later of the current expiry or the moment of renewal. • Renew restores an expired license to active status. | ✅ | URD-LIC-005 |
FR-004 | Renew is refused for a perpetual license, and for a suspended or revoked license. | ✅ | URD-LIC-006..007 |
FR-005 | Revoke is terminal and cannot be undone. • A revoked license moves into a terminal status. • A revoked license can never be renewed or reinstated. | ✅ | URD-LIC-007 |
FR-006 | Every lifecycle action is audited and re-signs the certificate. • Every lifecycle action writes an append-only event. • Every lifecycle action re-signs and re-publishes the certificate. | ✅ | URD-LIC-009..010 |
FR-007 | A user self-serves a free-trial license, never duplicated. • An authenticated user self-serves a single free-trial license bound to their user account. • If a trial already exists, the system returns that same trial, never a second one. | ✅ | URD-LIC-008 |
FR-008 | Activate a device by fingerprint, reusing its seat on re-activation. • Activate a license on a device by fingerprint, with an optional label / platform / hostname. • Re-activating the same device (same fingerprint) reuses its existing seat, without creating a new one. | 🚧 | URD-ACT-001..002 · URD-ACT-006 |
FR-009 | Active device seats are capped by the seat limit. • Active device seats cannot exceed the effective seat limit (an override takes priority, otherwise the plan's limit applies). • A null limit means unlimited seats. • Deactivating a device frees its seat. | 🚧 | URD-ACT-003..005 · URD-LIC-011 |
FR-010 | Activation is refused when the license is not in an active status. | 🚧 | URD-ACT-007 |
FR-011 | 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-012 | A license past its grace window is caught lazily, with no active sweeping. • A license past its grace window flips to expired at the next validation (lazily, not immediately). • No background sweeper runs to detect expiry earlier. | ✅ | URD-VAL-005 |
FR-013 | Validation returns the resolved features and the seat usage. • Validation returns the resolved feature set, with any per-license overrides applied. • Validation returns the seat usage, as seats used over the seat limit. | ✅ | URD-VAL-006..007 · URD-LIC-011 |
FR-014 | A successful validation returns a signed certificate and records the check time. • A successful validation returns a signed certificate that other services can verify offline. • Each validation records the last-validated time on a best-effort basis. | ✅ | URD-VAL-008..009 |
5.1 Acceptance Criteria
- Issue a license from a term-limited plan → the license gets a unique key, an expiry equal to the issue date plus the plan's duration, and a grace window added on top of that. Issue from a perpetual plan → the license has no expiry.
- Suspend an active license → status flips to suspended; reinstate right after → status returns to active. Try to reinstate a license that was never suspended → refused.
- Renew a license that's still valid → the new expiry is computed from the current expiry plus the plan's duration. Renew an expired license → it returns to active with a fresh expiry computed from the moment of renewal.
- Renew a perpetual license → refused. Renew a suspended or revoked license → refused.
- Revoke a license → status flips to revoked. Try to renew or reinstate a revoked license → always refused, no exceptions.
- Perform any lifecycle action (issue / suspend / reinstate / renew / revoke) → an append-only event is written and the license's certificate is re-signed right after.
- An authenticated user self-serves a free-trial license → they get a trial bound to their account. Call the self-serve action a second time → they get back the same existing trial, never a second one.
- Activate a license on a device by fingerprint → the device takes one seat. Re-activate the same device (same fingerprint) → the existing seat is reused, no new seat is created.
- Activate one more device once the license's seat limit is already reached → refused for hitting the limit. A license with a null limit → any number of devices can activate. Deactivate a device → its seat is freed and another device can activate into the open seat.
- Activate a device against a license that's suspended, expired, revoked, or not yet started → refused.
- Validate a key for an active license → returns a valid code. Validate a key for a suspended / expired / revoked / not-started license → returns the matching status-specific code. Validate a key that doesn't exist → returns a not-found code.
- Validate a license that's past its grace window → that validation call flips it to expired and returns an expired result. Before that call, the status still reads active even though it's technically past due.
- Validate a license with per-license feature overrides → the result returns the overridden feature set, not the plan's defaults. Every validation result carries the seat usage over the seat limit.
- Successfully validate a valid license → get back a signed certificate other services can verify offline, without calling licensing again. The license records its last-validated time.
6. 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 cryptographically 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 (English / Vietnamese) |
7. UX & Flows
License lifecycle state machine - each transition is guarded by the current status:
Runtime validation with device seat handling:
Backend-only surface - issuance and lifecycle actions, device activation, and the validation call. There is no license-management UI in this increment.
8. Data & Domain
| Concept | Role in this path |
|---|---|
| License | The issued entitlement - unique key, status, computed expiry / grace, optional overrides, and the cached signed certificate |
| Device activation | One device seat bound to a license by fingerprint, with optional label / platform / hostname |
| License audit event | An append-only record of every lifecycle and activation action |
| Plan | The plan a license is issued from - supplies duration, grace, seat limit, and feature flags read during resolution |
| Plan feature flag | A typed feature flag resolved into the validation result and the certificate |
Conceptual only - full schema and invariants in the licensing domain model.
9. Dependencies & Assumptions
Depends on
| # | Feature | Depends on what |
|---|---|---|
| 01 | 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. |
| 02 | Commerce (Commerce) | a license is bound to a merchant (or user) principal. |
| 03 | Permissions (Permissions) | lifecycle and activation actions are gated by licensing-scoped permissions. |
| 04 | Signing key pair | a signing key pair for the certificate, with the public key distributed to consumers for offline verification. |
Assumptions
| # | Assumption | What breaks if wrong |
|---|---|---|
| 01 | A free-trial plan is seeded so merchants can self-serve from day one. | If no trial plan is seeded, the self-serve action fails and a new merchant cannot get in without an operator manually issuing a license. |
| 02 | Consuming modules do their own per-feature gating against the certificate; licensing does not enforce features inside them. | If a consuming module fails to gate itself, a merchant could use a feature outside their plan, since licensing has no mechanism to block it from inside that module. |
| 03 | Reporting tolerates lazy (not eager) expiry detection. | If reporting needs real-time expiry status, a license past grace but not yet validated still shows as active in reports until the next validation call. |
10. Release Plan & Launch Criteria
| Aspect | Plan |
|---|---|
| Phase | P1 (lifecycle + validation) and P2 (device activation + certificate distribution) - see URD feature catalog |
| Rollout | Part of the licensing module; no feature flag; a free-trial plan is seeded so merchants self-serve from day one |
| Migration | None - runs on the existing licensing data |
| 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 |
References
- URD: Licensing - License Lifecycle · Device Activation · Runtime Validation & Entitlements
- Sibling PRD: Plans & feature flags
- Module: Licensing - URD
- Developer: @nx/licensing · domain model
Risks & Open Questions
| # | Risk / question | Mitigation / status |
|---|---|---|
| 01 | Two concurrent validations both detect expiry | Conditional status flip - only one writer transitions; the other re-reads the actual current status |
| 02 | 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 |
| 03 | Lazy expiry may lag reporting accuracy | Accepted; expiry flips on the next validation. Open: a scheduled sweeper if eager expiry is ever needed |
| 04 | Lifecycle transition on a stale read | Each transition locks the license row inside a transaction before checking status |
| 05 | Certificate trust depends on key distribution | Public key shipped to consumers; the rotation path must be defined before any key change |
| 06 | Fixed-duration arithmetic for renew / expiry | Open: move to calendar-aware months / years if required |
FAQ
| # | Question | Answer |
|---|---|---|
| 01 | 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. |
| 02 | Is revoke reversible? | No - revoked is terminal. A revoked license can never be renewed or reinstated; issue a new one instead. |
| 03 | 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. |
| 04 | 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 an expired result. |
| 05 | 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 a grace-period code, while a suspended one validates as invalid with a status-specific code. |
| 06 | 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. |