# Spec 2 — Instance Licensing and Enforcement

Date: 2026-07-24
Status: Design approved, not implemented
Depends on: spec 0a, spec 0b, spec 1 (`licensing-core`)
Ships: independently, with licenses issued by hand via `lkctl`. No admin site
needed.

## Context

Spec 1 defines what a license is. This spec makes the control plane hold one,
act on it, and let a self-hosted operator paste one in.

The guiding rule: **an expired license must never break a running fleet.** Agents
keep their keys, monitors keep watching, alerts keep firing. What stops is
growth and change. A customer whose card fails should be inconvenienced, not
paged at 3am because their monitoring went dark when Vantage decided to sulk.

## Goals

1. A license lives on the instance document and is verified on read.
2. Enforcement is deny-by-default: a new mutating route is gated because of where
   it is mounted, not because someone remembered.
3. Degraded mode is obvious in the UI and reversible by pasting a valid license.
4. Self-hosted operators get an instance UUID they can hand to the admin site.

## Non-goals

- Issuing licenses. `lkctl` (spec 1) or the admin backend (spec 3).
- Any outbound network call. Verification is offline, permanently.
- Per-user or per-role licensing. The unit is the instance.

## Design

### Instance identity

Every install already has an `Instance` document with an `InstanceID` UUID. For
cloud instances this is created by signup; for self-hosted it is created by
`/setup`.

Change to `/setup`: after bootstrapping the first instance and its owner, the
setup page **displays the instance UUID** with a copy button and the text that
it is needed to activate a license. It is also shown permanently on
`/settings/license`.

No new identifier is invented. The instance UUID is the licensing identity.

### Storage

`shared/models.Instance` gains:

```go
LicenseBlob   string     `bson:"license_blob,omitempty" json:"-"`
LicenseTier   string     `bson:"license_tier,omitempty" json:"license_tier,omitempty"`
LicenseExpiry *time.Time `bson:"license_expiry,omitempty" json:"license_expiry,omitempty"`
```

The blob is authoritative. `LicenseTier` and `LicenseExpiry` are a denormalised
cache for listing and for the admin site's queries, rewritten from the verified
payload every time a blob is accepted. Nothing reads them for enforcement.

`LicenseBlob` is `json:"-"`. It is not a secret in the confidentiality sense —
it is signed public data — but there is no reason to spray it through API
responses.

### Runtime state

```go
type State struct {
    Status     license.State // valid | expired | invalid
    Reason     string
    Tier       string
    ExpiresAt  *time.Time
    Limits     license.Limits
    Features   map[string]bool
}
```

Resolved by `services.LicenseState(instanceID) State`, cached for 60 seconds
alongside the existing instance cache and invalidated immediately when a blob is
stored.

Three inputs, in precedence order:

1. `Instance.LicenseBlob`.
2. `VANTAGE_LICENSE` environment variable, used **only when the instance has no
   stored blob**. This lets an automated self-hosted deployment ship a license
   without a human pasting one. A blob stored through the UI always wins
   afterwards, so an operator is never locked out by a stale environment value.
3. Neither → `Status: invalid`, `Reason: no_license`.

The verifier is called with `InstanceID` from the instance document and
`Deployment` from `VANTAGE_DEPLOYMENT` (`cloud` on our infrastructure,
`self_hosted` everywhere else, defaulting to `self_hosted`). The default matters:
an operator who removes the variable gets the stricter mode, not the looser one.

`invalid` and `expired` degrade identically. They differ only in the message.

### Enforcement

Three layers, deliberately separate because they answer different questions.

**Layer 1 — mutation gate.** A gin middleware `RequireActiveLicense` mounted on
the `/api` group, applying to every request whose method is not `GET` or `HEAD`.

```go
api := r.Group("/api", auth.RequireSession(), services.RequireActiveLicense())
```

Non-`valid` → `403 {"error":"license_required","state":"expired","reason":"..."}`.

Mounting at the group means **a route added tomorrow is gated by default**. That
is the whole point of putting it here rather than on individual handlers.

Explicit exemptions, allow-listed by path because they must work in degraded
mode:

| Route | Why |
|---|---|
| `POST /api/license` | Pasting a valid license is how you recover |
| `POST /auth/*` | Login and logout are outside `/api` already; listed for clarity |
| `DELETE` on any resource | Deleting is how you get back under a limit |
| `POST /api/servers/:id/apply-updates` | Security patching must never be paywalled |

The `DELETE` exemption deserves emphasis: a customer downgraded to Free with 10
servers must be able to remove 7 of them. Blocking deletes would trap them.

**Layer 2 — feature gate.** `RequireFeature(name)` on the route groups that need
it:

- `console` → `POST /api/console/connect`, `GET /api/console/tunnel`
- `oidc` → `GET,PUT /api/instance/oidc`

Missing feature → `403 {"error":"feature_unavailable","feature":"console"}`.

OIDC needs care: `/auth/oidc/start` and `/auth/oidc/callback` are unauthenticated
and outside `/api`. They check the feature directly and, if unavailable, redirect
to `/login?error=oidc_unavailable` rather than returning JSON. **Existing OIDC
sessions are not terminated** — losing the feature stops new SSO logins, it does
not evict people mid-session.

**Layer 3 — limits.** Enforced in the service layer, because a limit needs a
count that middleware does not have:

| Limit | Checked in |
|---|---|
| `max_servers` | `services.CreateServer` / `POST /api/servers/new` |
| `max_secret_groups` | `services.CreateSecretGroup` |
| `max_channels` | `services.CreateChannel` |

`-1` means unlimited. Over limit → `403 {"error":"limit_exceeded","limit":"max_servers","current":3,"max":3}`.

Counts are of live rows: revoked assignments and deleted servers do not count.

**Over-limit instances are never truncated.** A Professional instance with 20
servers that lapses to Free keeps all 20 running; it simply cannot add a 21st.
Deleting resources is always permitted. Silently disabling a customer's servers
because their card expired is not a behaviour this system will have.

### Background work in degraded mode

This is where "read-only" needs to be specific, because these paths do not go
through gin at all.

| Subsystem | Degraded behaviour |
|---|---|
| **Monitor scheduler** | **Keeps running.** Checks execute, incidents open, notifications fire. |
| Monitor create/edit/delete | Blocked by layer 1 (delete exempted). |
| Workflow runner | New runs blocked by layer 1. **In-flight runs finish** rather than being killed mid-step — a half-run workflow is worse than a completed one. |
| Agent `SyncKeys` | Returns the existing desired key set unchanged. Nothing is torn off disk. New assignments cannot be created, so nothing changes anyway. |
| Agent registration | A **new** agent registering against an over-limit instance is refused with a clear message; existing agents re-register freely. |
| Inventory, heartbeat, update reporting | Unaffected. |
| `ApplyUpdatesCmd` | Allowed. Security patching is not gated. |
| ESO secrets read (`GET /api/secrets/:group/values`) | **Allowed.** It is a `GET`, and breaking a Kubernetes cluster's secret sync over a billing state is disproportionate. |
| Log retention sweep, offline sweep | Unaffected. |

Keeping monitors alive is a deliberate reversal of a stricter earlier draft. It
is the single most important line in this spec: **billing state must not take
away a customer's ability to know their infrastructure is on fire.**

### API

```
GET  /api/license      any authenticated user
POST /api/license      owner only
```

`GET` returns:

```json
{
  "instance_id": "…",
  "state": "valid",
  "reason": "",
  "tier": "professional",
  "expires_at": "2027-07-24T00:00:00Z",
  "days_remaining": 365,
  "limits":   { "max_servers": -1, "max_secret_groups": -1, "max_channels": -1 },
  "features": { "console": true, "oidc": true },
  "usage":    { "servers": 12, "secret_groups": 4, "channels": 2 },
  "source":   "stored"
}
```

`usage` is included so the UI can render "12 of 3 servers" honestly when an
instance is over its limit, rather than pretending.

`POST` takes `{"blob": "..."}`, verifies with the instance's own ID and
deployment mode, and on success stores the blob, refreshes the cache, and writes
an audit event. On failure it returns `400` with the specific reason:

| Reason | Message |
|---|---|
| `bad_signature` | This licence key is not valid. Check it was copied in full. |
| `deployment_mismatch` | This licence is for Vantage Cloud and cannot be used on a self-hosted install. |
| `instance_mismatch` | This licence was issued for a different instance. Your instance ID is `<uuid>`. |
| `expired` | This licence expired on `<date>`. |

An **expired** blob is still stored if it is otherwise valid, so the UI can show
what expired and when. An **invalid** blob is rejected and the previous one kept.

Rate-limited to 10 attempts per instance per hour. There is no oracle here worth
protecting, but an unbounded verify endpoint is an unbounded CPU endpoint.

### Frontend

`useLicense()` hook over `GET /api/license`, cached by TanStack Query and
invalidated after a successful paste.

- **Banner, persistent, top of every page** when `state != valid`:
  - `expired` — "Your Vantage licence expired on `<date>`. Your servers and
    monitors are still running, but changes are disabled until it is renewed."
    with a link to the admin site.
  - `invalid` / `no_license` — "This instance has no valid licence. Add one in
    Settings → Licence."
- **Warning banner** in the final 14 days of a valid term, dismissible per
  session.
- **Gated features render disabled with an upgrade tooltip, not hidden.** A
  customer cannot buy what they cannot see, and a feature that vanishes reads as
  a bug.
- **Limit indicators** on the servers, secrets and channels list pages: "3 of 3
  servers used" with the create button disabled at the cap.
- `/settings/license`: current state, tier, expiry, limits with live usage, the
  instance UUID with a copy button, and a textarea plus file upload for a new
  blob. Owner-only; other roles see the state read-only.

### Grandfathering existing tenants

Migration `0005_grandfather_licenses`, cloud only, guarded on
`VANTAGE_DEPLOYMENT == "cloud"`:

For every instance with no `license_blob`, issue a Professional license expiring
**one year** from the migration date and store it.

The migration cannot sign — the server has no private key and, per spec 1, no
signing code. So the blobs are **generated ahead of time with `lkctl`** and
supplied to the migration through `VANTAGE_GRANDFATHER_BLOBS`, a JSON map of
instance ID to blob. The migration stores what it is given, verifies each blob
against its instance before storing, and logs any instance it had no blob for.

Clumsy, and correct. The alternative is putting a signing key in the control
plane, which is the thing this design most wants to avoid.

Self-hosted installs are not grandfathered. On upgrade they land in `no_license`
and read-only until an operator pastes a key — which is the intended behaviour
for a paid product, and is why the release notes must lead with it.

## Testing

**Unit, no database:**

1. `State` resolution precedence: stored blob wins over `VANTAGE_LICENSE`;
   environment used when no blob; neither → `no_license`.
2. Feature map construction from the payload's `Features` slice.
3. Limit comparison with `-1`, with zero, and with a count exactly at the cap.

**Middleware, with a stub state:**

4. `GET` passes in every state.
5. `POST`/`PUT`/`DELETE` pass when `valid`, fail `403` when `expired` and when
   `invalid` — except `DELETE`, which passes in all states.
6. `POST /api/license` passes when `expired` (the recovery path).
7. `POST /api/servers/:id/apply-updates` passes when `expired`.
8. `RequireFeature("console")` passes with the feature, `403`s without it.
9. **Coverage test:** enumerate every registered route and assert that every
   non-`GET` route is either behind `RequireActiveLicense` or on the exemption
   allow-list. This test is what stops layer 1 rotting as routes are added.

**Service layer, against MongoDB:**

10. `CreateServer` at the cap → `limit_exceeded`; one below → succeeds.
11. Over-limit instance can still `DELETE` a server, and can create again once
    back under the cap.
12. Deleted and revoked rows do not count toward limits.

**Degraded background behaviour:**

13. Monitor scheduler executes checks for an instance with an expired license.
14. An incident opened during degraded mode still dispatches notifications.
15. `SyncKeys` for an expired instance returns the same key set as before expiry.
16. A new agent registering against an over-limit instance is refused; an
    existing agent re-registers successfully.
17. A workflow run in flight when the license expires completes its remaining
    steps.

**API:**

18. `POST /api/license` with a valid blob stores it and flips state to `valid`.
19. Each rejection reason returns its own message and leaves the stored blob
    untouched.
20. An expired-but-well-formed blob is stored and reported as `expired`.
21. Non-owner `POST` → `403`.

**Migration:**

22. `0005` stores and verifies supplied blobs, skips instances that already have
    one, logs instances with no blob supplied, and is a no-op when
    `VANTAGE_DEPLOYMENT != "cloud"`.

## Verification before merge

1. Full test suite green, including the route-coverage test (test 9).
2. Manual pass on a scratch instance: issue a Professional license with `lkctl`,
   paste it, confirm everything works. Issue one expiring in 60 seconds, wait,
   confirm the banner appears, mutations `403`, **monitors keep firing**, and
   pasting a fresh license restores normal operation without a restart.
3. Manual pass on the Free tier: confirm the 3-server cap, that console and OIDC
   are visibly disabled with upgrade tooltips, and that a 4th server is refused
   with a clear message.
4. Confirm a cloud-issued Free license is rejected on a `self_hosted` install
   with `deployment_mismatch`.
5. Confirm a license issued for another instance is rejected with
   `instance_mismatch` and the message shows the correct local UUID.

## Rollout

1. Generate grandfather blobs with `lkctl` for every existing cloud instance.
2. Deploy with `VANTAGE_GRANDFATHER_BLOBS` set; migration `0005` runs.
3. Verify every cloud instance reports `valid`, Professional, one year out.
4. Unset the variable on the next deploy — it is single-use.
5. Release notes for self-hosted must state plainly that upgrading requires a
   licence key, and how to get one.

## Risks

| Risk | Mitigation |
|---|---|
| A mutating route added later without a gate | Route-coverage test (test 9) fails the build |
| Customer locked out and unable to recover | `POST /api/license` and all `DELETE`s exempt from the gate |
| Existing cloud tenants degrade on deploy | Migration 0005, verified before the traffic switch |
| Over-limit customer trapped | Deletes always allowed; existing resources never truncated |
| Clock wrong on a self-hosted host | `Verify` warns on a future `IssuedAt`; documented in the licence settings page |
| Monitoring lost on billing failure | Explicitly designed out — the scheduler ignores licence state |
