# Licensing

How an installation proves it is entitled to run, and — more importantly — what
happens when it cannot.

## The one rule

**A licence server problem is never a clinic problem.**

Every design decision below follows from that. A clinic that cannot reach us,
whose card expired on a Friday, or whose licence we have genuinely revoked, must
still be able to open a patient record, run a consultation, write a
prescription and take payment. The commercial leverage that makes a clinic
renew is losing the *administrative* surface, not losing access to patient
records — the second would be indefensible whatever the contract said, and in
some jurisdictions unlawful.

## Moving parts

| Class | Responsibility |
|---|---|
| `LicenseSigner` | Builds the HMAC headers the licence server verifies |
| `LicenseClient` | The four outbound calls; converts every failure into `LicenseServerException` |
| `LicenseStore` | The encrypted local record on the private disk |
| `LicenseCircuitBreaker` | Stops calling a server that is not answering |
| `LicenseManager` | Coordinates the three; the only thing callers touch |
| `LicensedFeatureGate` / `LicensedLimitGate` | Replace core's permissive defaults |
| `LicenseStatusAdapter` | Narrows the manager to the three questions core may ask |
| `EnforceLicense` (core) | The degradation ladder, on every web and API request |

## Transport

HMAC-SHA256 over the exact message:

```
timestamp \n nonce \n METHOD \n path \n body
```

`path` carries **no leading slash** — the server signs `$request->path()`, which
Laravel returns unslashed. The nonce is single-use within the 300-second
signature window; a repeat is rejected as `replayed_nonce`.

The signing secret depends on the phase. `activate` is signed with the licence
key itself, because the install secret is what that call returns; everything
afterwards is signed with the install secret.

The request body is JSON-encoded **once** and handed to the HTTP client as a
raw string, because the signature covers those exact bytes. Passing an array and
letting the client re-encode risks a different key order and a signature failure
that looks like a credential problem and is not.

Payloads carry the domain, the CMS version and nothing else. No patient data
ever leaves the installation, and the payload is asserted field-by-field
against an allow-list in the test suite so a future addition cannot widen it
quietly.

## Two clocks

State is evaluated against two independent clocks, and they are deliberately
**not** treated alike.

**Expiry** — the server's `expires_at`. Once past, the clinic gets
`grace_period_days` of full function, then drops to Expired. This is the
commercial gate and it is enforced.

**Contact** — how long since the server last answered. Staleness can only ever
demote as far as Grace, **never** to Expired. A clinic that has paid and whose
internet is down has done nothing wrong; cutting their features off over a
failed DNS lookup would be a support disaster and an unfair one. Staleness earns
a warning, not a penalty.

States the server declared terminal — revoked, suspended, invalid — are returned
untouched. A decision does not go stale.

## Refusal versus silence

`LicenseServerException::isReachable()` separates the two, and the distinction
drives everything:

- **Refused** — an answer. Recorded locally, so a revocation takes effect.
- **Unreachable** — an absence of one. Local state is left exactly as it was,
  and `checked_at` is *not* advanced, or a clinic could sit "verified" for years
  having spoken to nobody.

`domain_mismatch` is refused-but-ignored on purpose: it means *this* install is
asking about a licence bound elsewhere (a restored backup, a staging clone). It
says nothing about the real clinic's licence, and recording it would let a clone
downgrade the original.

## The degradation ladder

Enforced by `EnforceLicense` on every web and API request.

1. **Patient care is untouched.** Every route whose name matches
   `licensing.always_available` keeps working, writes included.
2. **Everything else becomes read-only.** Administrative screens still *open* —
   a clinic must always be able to read its own data — but writes return
   `license_read_only` (402).

The list is written in module terms (`prescriptions.`) but API routes are named
`api.v1.prescriptions.store`, so matching is done against the route name and
every suffix of it beginning at a dot boundary. Matching only the full name left
the entire clinical allow-list dead on the API — a lapsed licence blocked
prescribing from a mobile client while the browser was fine.

Read-only is decided by HTTP method. A GET that mutates would slip through; that
is the correct trade, because the alternative is an allow-list of mutating
endpoints that fails open for anything added later.

`license.*` routes are on the always-available list, and that is load-bearing:
the screen a clinic uses to renew must not be gated by the licence it renews.

## Local state, and what its encryption is worth

One encrypted file at `storage/app/license/license.token`.

Encryption with `APP_KEY` buys two real things: the install secret is not
sitting in plaintext on a shared host, and Laravel's AEAD means a hand-edited
file is *detected* rather than believed.

It is **not** protection against the clinic itself. `APP_KEY` is in their `.env`;
an operator willing to write a script can mint any state they like. Only a
signature from the licence server's private key prevents that, and the server
does not sign its payloads yet. `LicenseStore::verifySignature()` is the seam
where that check lands when it does. Until then this is tamper-*evident*, not
tamper-proof, and describing it otherwise in a commercial context would be
dishonest.

Losing the file costs nothing permanent. Activation is idempotent per domain and
returns the same install secret, so `license:activate` with the licence key
restores it — which is also the recovery path after an `APP_KEY` rotation, since
that necessarily makes the old file unreadable.

## Packaging is not payment

`FeatureGate` answers **"did this clinic buy Laboratory?"** — never **"is this
month's invoice settled?"**. The split is load-bearing, because
`ModuleManager::blockReason()` asks the gate whether an optional module may
**boot at all**.

An earlier version of `LicensedFeatureGate` also required the licence to be
active or in grace. It looked reasonable and was badly wrong: a lapsed licence
denied every feature, so the kernel blocked Patients, Appointments,
Consultations, Prescriptions, Billing and Laboratory, and the clinic was left
with a login screen and nothing behind it — the exact outcome the ladder exists
to prevent, reached through a component that had never heard of the ladder.

Lapse is `EnforceLicense`'s job and only its job. Administrative writes stop;
patient care does not.

A consequence worth stating plainly: a fresh installation, before anyone has
entered a key, has no package and therefore gets everything. That is what makes
the product installable at all.

## Packages

The server returns a package **name**, not a capability list, so the mapping
lives in `config/licensing.php` and ships with the release.

An unrecognised package name **grants everything**. Deliberate: the failure mode
of guessing wrong is a paying clinic locked out of a feature they bought, on a
day nobody at our end is watching. The safe direction is open, and the real
commercial gate is the licence *status*, which is enforced and which a clinic
cannot forge without the install secret.

Limits never invalidate existing records. Dropping from 25 seats to 5 stops the
6th user being created; it does not deactivate twenty staff accounts.

## Commands

```bash
php artisan license:activate
```

```bash
php artisan license:status
```

```bash
php artisan license:verify
```

```bash
php artisan license:deactivate
```

`license:heartbeat` is scheduled hourly and usually does nothing — it applies
the server's interval plus a per-install offset derived from the domain, so
hundreds of clinics spread across the day instead of arriving together at
midnight. It never reports failure: a cron job that emails an error every hour
because a licence server is down trains people to ignore cron mail.

Run by hand, `license:verify` forces — it ignores both the interval and the
circuit breaker, and reports failures instead of swallowing them. Someone typed
it and is waiting for an answer.

## Configuration

`LICENSE_SERVER_URL` is the only required setting. `LICENSE_ENFORCEMENT=false`
disables the gates and the ladder entirely, which is the correct setting for
development. `LICENSE_GRACE_DAYS` is the offline tolerance before a stale
licence is demoted to Grace.

## The provider support account

`support:account` creates or rotates a single account the provider signs in
with to diagnose a reported problem, without asking the clinic for a member of
staff's password.

```bash
php artisan support:account --email=support@tmsoagency.com
php artisan support:account --disable
```

It is deliberately **absent from the two places a clinic manages its own
roster** — the staff listing and the seat count — because clinics that keep a
tight roster will not carry a login they did not create, and a name they cannot
explain only generates a support call. It is **not** absent from the audit
trail. Every action it takes is recorded under its own name, and a clinic
Super Admin cannot view, edit, delete or impersonate it even by typing its URL
(`app/Providers/AuthServiceProvider.php`, above the Super Admin bypass).

That split is the whole design and it is not a matter of taste: an unlogged
privileged account over patient records would make the tamper-evident audit
trail this product is sold on a falsehood, and undisclosed access to medical
data is unlawful under HIPAA-style and GDPR-style rules. So the account is
**invisible in management and fully accountable in the record**, and its
existence must be disclosed once in the service agreement with each clinic — one
line stating that the provider retains an audited support account for
maintenance. Hidden-and-unlogged was asked for and declined for these reasons;
this is the version that is safe to ship.

## Known gap

The licence server signs nothing. Until it does, local state is tamper-evident
rather than tamper-proof, and `config/licensing.php`'s `public_key` is unused.
Closing this means adding an Ed25519 detached signature over the `license`
object on the server; the client seam already exists and no caller changes.
