docs: spec 7, metered licensing and the two-by-three plan matrix

Two deployments times three tiers, servers metered per month, console and
SSO opted into individually. plans is re-keyed on (deployment, tier); every
Paddle price ID moves out of plans into a new catalogue collection; a new
entitlements collection holds desired beside granted, and a licence is only
ever signed from granted.

Spec 5's plan is revised rather than followed: it assumes one price per
subscription and a metered plan has three or more. Nothing of it has
shipped, so the revision costs a rewrite of an unstarted plan.

Free stops being cloud-only by construction, which means the plan/instance
deployment comparison in licensing.Issue no longer enforces it and
checkFreeLimit has to count per deployment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-26 23:16:35 +01:00
co-authored by Claude Opus 5
parent 87dc9fc858
commit ff0caf5a90
3 changed files with 522 additions and 3 deletions
@@ -0,0 +1,453 @@
# Metered Licensing — Design
**Status:** designed 2026-07-26. Supersedes parts of spec 5 (paddle-billing) and
the tier table in [`README.md`](README.md).
**Goal:** turn the licence from a snapshot of a fixed tier into a snapshot of
what one customer configured and paid for. Two deployments times three tiers,
servers metered per month, features opted into individually, all of it
self-service in Vantage HQ.
**Why now:** spec 5 is designed but not implemented — `admin/internal/paddle`
and `admin/internal/billing` do not exist. Its `Subscription` struct, its
`plans.paddle_price_ids` shape, its single-price checkout and its
`ApplySubscription` all assume one price per subscription, and a metered plan has
several. Folding this in now costs a revision of an unstarted plan; folding it in
later would cost a rewrite of shipped billing code.
---
## The pricing model
Two deployments, three tiers, six plans.
| | servers | monitors | secret groups | channels | audit history | console | SSO | support |
|---|---|---|---|---|---|---|---|---|
| **Free** | 3 | 3 | 1 | 1 | 30 days | — | — | community |
| **Professional** | 3 + N | ∞ | ∞ | ∞ | 365 days | opt-in | opt-in | email, 24/5 |
| **Enterprise** | 10 + N | ∞ | ∞ | ∞ | ∞ | opt-in | opt-in | email + call, 24/7 |
The allowances are identical in both deployments. What differs is the term:
| | monthly | annual |
|---|---|---|
| Cloud Free | — | yes, renewed from HQ |
| Cloud Professional | yes | yes |
| Cloud Enterprise | yes | yes |
| Self-Hosted Free | — | yes, renewed from HQ |
| Self-Hosted Professional | — | yes |
| Self-Hosted Enterprise | — | yes |
**Self-Hosted stays annual-only, for the reason already written into
`shared/license/license.go`:** an offline licence cannot be revoked, so the term
length *is* the revocation window. A self-hosted monthly licence would renew that
unrevokable window twelve times a year for no commercial gain. A resolved
self-hosted monthly price is therefore a configuration error and must fail loudly
rather than issue.
**Servers are the only metered dimension.** Everything above Free is unlimited
except audit history. This was a deliberate narrowing: an earlier draft sold
secret groups in blocks of five, and dropping it leaves one number for a customer
to understand and one line item on an invoice.
**Enterprise is self-service at a published price**, bought through the same
configurator as Professional. The 24/7 phone commitment is an operational promise
we make, not a technical gate we build.
**Support level is not enforced by anything.** It is carried for display, and
that is the whole of its job.
---
## What breaks, and must be fixed in the same change
Three invariants stop being true. Each is load-bearing today.
**`plans` is keyed on `tier` alone.** It becomes `(deployment, tier)` with a
unique index on the pair. `license.PlanFor(tier)` becomes
`PlanFor(deployment, tier)`.
**Free is cloud-only by construction.** The single comparison in
`licensing.Issue``plan.Deployment != inst.Deployment` — is what enforces it
today, because Free's only plan row says `cloud`. With a self-hosted Free row
that comparison stops meaning "Free is cloud-only" and starts meaning only "the
plan row matches the instance". The paragraph in `shared/license/plans.go`
claiming construction-level enforcement must go, because it is no longer true.
**`checkFreeLimit` counts Free instances per account.** It must count per account
*and deployment*, or a customer holding a cloud Free instance is refused a
self-hosted Free one with a message about a limit they have not reached.
---
## Data model
### `plans` — the tier definition
Loses `paddle_product_id` and `paddle_price_ids` entirely; those move to
`catalogue`. Safe to delete because nothing has ever written to them.
```
{deployment: "cloud", tier: "professional", name: "Professional",
base_limits: {max_servers: 3, max_monitors: -1, max_secret_groups: -1,
max_channels: -1, audit_retention_days: 365},
base_features: [], support_level: "email_24_5", active: true}
```
`base_limits` replaces `limits`: it is the allowance before anything is bought,
which is a different claim from the one the old field made. `base_features` is
what the tier includes without opting in — empty for all six plans today, because
console and SSO are both opt-in, but the field is what lets a future tier bundle
one.
### `catalogue` — every priceable component
The only place a Paddle price ID appears anywhere in the system.
```
{kind: "base", deployment: "cloud", tier: "professional",
price_ids: {sandbox: {monthly: "pri_…", annual: "pri_…"},
production: {monthly: "pri_…", annual: "pri_…"}}}
{kind: "limit", deployment: "cloud", tier: "professional", limit_key: "max_servers",
price_ids: {sandbox: {monthly: "pri_…", annual: "pri_…"}, production: {…}}}
{kind: "feature", deployment: "cloud", tier: "professional", feature_key: "console",
price_ids: {}}
{kind: "feature", deployment: "cloud", tier: "professional", feature_key: "oidc",
price_ids: {}}
```
Unique index on `(deployment, tier, kind, limit_key, feature_key)`.
- **`kind: "base"`** is the plan's own fee, always quantity 1.
- **`kind: "limit"`** raises a named limit by one per quantity. `limit_key` is a
field name in `license.Limits`, so adding metered channels later is a catalogue
row and no code. There is deliberately **no `block_size` field**: with
secret-group blocks dropped it would be `1` in every row that will ever exist.
- **`kind: "feature"`** is a feature key. **An empty `price_ids` means free to
toggle.** A price appearing later is a staff edit in the plans UI, not a
migration and not a deploy — which is the whole reason features are catalogue
rows rather than a list on the plan.
A self-hosted row simply has no `monthly` key. Nesting by environment before term
keeps promoting sandbox to production a configuration change, as spec 5 already
established.
### `entitlements` — one row per instance
The customer's configuration. Both the subscription and the licence are derived
from it; it is derived from nothing.
```
{instance_id: "uuid", account_id: "uuid",
deployment: "cloud", tier: "professional", term: "monthly",
desired: {servers: 10, features: ["console"]},
granted: {servers: 5, features: []},
resolved_limits: {max_servers: 5, max_monitors: -1, max_secret_groups: -1,
max_channels: -1, audit_retention_days: 365},
granted_at, updated_at, scheduled_change_at}
```
Unique index on `instance_id`.
**`desired` is what they asked for; `granted` is what a payment confirmed.** The
checkout and the subscription update are built from `desired`. A licence is only
ever signed from `granted`. An abandoned checkout therefore leaves a `desired`
that reached no licence, which is harmless, and HQ can say "pending change"
truthfully instead of guessing.
**`resolved_limits` is stored, not derived on read.** It is `plan.base_limits`
with `granted.servers` folded in, and it is what `Issue` snapshots. Storing it
keeps the fold in exactly one place; deriving it at every read would put the
arithmetic in the issuer, the portal and the staff console.
**Free gets a row at instance creation** with `desired == granted` and no
subscription. Every one of the six cases then reads the same shape, and licence
issuance has one path rather than a Free branch.
### `license.Limits` gains two fields
```go
type Limits struct {
MaxServers int `json:"max_servers"`
MaxMonitors int `json:"max_monitors"`
MaxSecretGroups int `json:"max_secret_groups"`
MaxChannels int `json:"max_channels"`
AuditRetentionDays int `json:"audit_retention_days"`
}
```
`MaxMonitors` behaves exactly like the existing counts. `AuditRetentionDays` is a
new kind of limit — a duration rather than a cap — and `Unlimited` means never
trim.
### `license.License` gains `SupportLevel string`
Display-only, exactly as `InstanceName` already is. It goes in the signed payload
rather than being fetched from HQ so that `/settings/license` can state the
support level on an air-gapped install, which is the one deployment most likely
to need to know who to call.
### `models` additions
`ReasonEntitlementChange = "entitlement_change"` joins the issuance reasons.
Reasons end up in support conversations, so a mid-term server addition must not
be filed as a renewal — a renewal resets `relink_count`, and adding a server is
not a new term.
---
## Resolution
Two folds, in one package (`admin/internal/catalogue`), so the arithmetic exists
once.
**To a licence.** `Resolve(plan, granted) → (license.Limits, []string)`:
start from `plan.base_limits`, and for each `kind: "limit"` row add the
configured quantity to `limit_key`. `granted.servers` is the *total* the customer
sees, so the quantity billed is `servers - plan.base_limits.max_servers` and the
resolved limit is `servers`. Features are `plan.base_features` plus
`granted.features`, deduplicated, filtered to keys the catalogue actually offers
for that `(deployment, tier)` — a stale feature key in a stored entitlement must
not survive into a signed payload.
**To Paddle line items.** `LineItems(env, deployment, tier, term, desired) → []Item`:
the base row at quantity 1, the server row at quantity
`desired.servers - base_limits.max_servers`, and one item per desired feature
that has a price ID in this environment and term. A feature with no price ID
produces no line item and is granted for free. A quantity of zero produces no
line item at all, so a Professional customer at exactly 3 servers has a
single-item subscription.
**Reverse resolution replaces spec 5's `ResolvePriceID`.** A metered subscription
has several prices, and only one of them identifies the plan. Given the full item
list from a webhook:
1. Find the item whose price ID matches a `kind: "base"` row. That row gives
`deployment`, `tier` and — by which term key matched — `term`.
2. Sum the quantities of items matching that plan's `kind: "limit"` rows.
3. Collect the feature keys of items matching its `kind: "feature"` rows.
4. Any item matching nothing is a configuration error: fail the event loudly so
it lands on the staff dashboard. Guessing a tier from a price we cannot map is
how a customer ends up with the wrong licence and no record of why.
Only the running `PADDLE_ENV`'s IDs are consulted, so a production process cannot
be talked into resolving a sandbox price. That property is spec 5's and survives
unchanged.
**Out-of-order delivery is still handled by construction.** Paddle sends the
complete item list on every subscription event, so a handler that reads the whole
list is still a function of current state rather than of a transition. Nothing
about metering weakens this.
---
## Issuance
`licensing.Issue` reads the entitlement row for the instance and snapshots
`resolved_limits` and `granted.features`. When no row exists it falls back to the
plan's base — which covers staff manual issuance and any instance predating the
backfill.
`Issue` stays the only signer, and it stays the thing that does not deliver.
**Upgrades preserve the expiry.** A mid-term server addition passes
`ExpiresAt` = the current licence's expiry, so the licence is reissued with a
larger cap and the same end date. It must not extend the term: the customer paid
a prorated amount for the rest of this period, not for a new one. Note that the
current expiry already includes `GracePeriod`, so nothing adds it again —
`ExpiresAt` overriding `Term` is exactly the existing contract.
**Reductions issue nothing.** They live in `desired` with `scheduled_change_at`
set until the renewal webhook promotes `desired` into `granted` and issues the
next term at the lower cap. The customer keeps what they paid for to the end of
the period, there is no refund to reason about, and no licence ever shortens —
which is the rule spec 5 states and this design does not touch.
---
## Changing a live subscription
`PUT /api/instances/:id/entitlement` writes `desired`, then calls Paddle:
- **An increase** updates the subscription items prorated immediately. The
resulting `subscription.updated` webhook promotes `granted` and reissues.
- **A decrease** schedules the item change for the next billing period and sets
`scheduled_change_at`. No licence action now.
This is admin's **first outbound Paddle call beyond the portal session**, and
spec 5 currently states it has none. That statement changes. The important part
does not: **the webhook remains the only thing that promotes `granted` or issues
a licence.** The endpoint writes `desired` and asks Paddle for a change; it never
grants anything itself. A customer whose card is declined on a prorated upgrade
gets no licence, which is correct, and admin needs no compensating logic to
achieve it.
A tier change (Professional to Enterprise) is the same call with a different base
price, and issues with `ReasonTierChange` as it already would.
---
## Control-plane enforcement
Two of the six columns in the pricing table are enforced by nothing today. A
feature picker that sells an ungated checkbox sells nothing.
**`license.HasFeature` is currently called from nowhere.** Add gates:
- `POST /api/console/connect` and `GET /api/console/tunnel` require
`FeatureConsole`.
- `GET`/`PUT /api/org/oidc`, `/auth/oidc/start` and `/auth/oidc/callback` require
`FeatureOIDC`. The callback matters most: an expired or downgraded licence must
not leave a working side door into the instance.
A new `FeatureError` maps to 403 with a machine-readable body, mirroring the
existing `LimitError`. `web/` hides the Console button and the SSO card when the
feature is absent, but as everywhere else in this codebase the API is the
boundary and the UI is the courtesy.
**This removes a capability from existing Free cloud tenants.** Free's features
list has always been empty, but nothing gated on it, so a Free instance can use
the browser console today and will not be able to afterwards. That is the
intended pricing, and it is a deliberate behaviour change rather than a
side-effect — it needs to be named in the release note and, ideally, emailed to
affected accounts before the gate lands.
**`CheckMonitorLimit`** joins the three existing checks in
`server/internal/services/licence_limits.go`, counting `monitors` for the
instance. Same shape: refuse a new one at the cap, never truncate what exists.
`LicenseUsage` reports monitors alongside the other counts.
**Audit retention is new work.** Nothing trims `audit_logs` today. A daily sweep
deletes entries older than the licence's `AuditRetentionDays` per instance;
`Unlimited` skips the instance entirely. It is modelled on the existing workflow
log retention sweep, and it is the one item in this design that deletes customer
data — so it must read the *current* licence's value each run rather than caching
it, and an instance whose licence has lapsed must not be swept on the expired
term's allowance.
**Degraded mode is unchanged.** Expiry still stops mutations and leaves monitors
executing, alerts firing and agents keyed. A feature gate is a mutation gate for
console and SSO, so it behaves the same way.
---
## HQ, the configurator
One screen, reached from an instance in `InstanceRecord` and from the
self-hosted purchase page.
```
Deployment ( ) Cloud (•) Self-Hosted ← fixed after creation
Tier ( ) Free (•) Professional ( ) Enterprise
Term (•) Annual ← monthly hidden for self-hosted
Servers [ 10 ] base 3 included, 7 extra
Features [x] Browser console
[ ] Single sign-on
─────────────────────────────────────────────
£B + 7 × £S per year
[ Continue to payment ]
```
It is one component in both places, driven by the catalogue rather than by
anything hardcoded — a feature that gains a price shows its price with no
frontend change, which is the point of the catalogue being data.
**Existing subscriptions show `desired` and `granted` when they differ:** "10
servers, dropping to 5 on 12 August". A pending reduction is a fact about the
account and belongs on the screen, not only in Paddle.
**Choosing Free skips payment entirely.** With no catalogue rows there is no
checkout to open, so the configurator's Continue button links a UUID and issues
directly. For cloud that is the shipped `POST /api/instances`, untouched. For
self-hosted Free it is the existing link flow with no subscription attached — a
new path, and the only place in the system where an instance is licensed without
either a payment or a staff action. It is bounded by the same one-Free-per-account
rule, now scoped per deployment.
**The staff plans editor** edits `plans` (allowances, support level, active) and
`catalogue` (price IDs per environment and term) as two tables. This replaces
spec 5's price-ID editor, which was built for a single map on the plan row.
Follows `adminsite/`'s existing shell without exception: `PageHeader` with its
record line, `PageFrame`'s main-plus-rail split, tokens only and no hex values,
light default. Price and server count read as text as well as position, since
state never reads by colour alone here.
---
## Migration
Admin has no migrations collection: `models.Backfill` runs every boot and is
idempotent by filtering on the absence of what it writes. This all goes there.
1. **Seed six plan rows** from `shared/license/plans.go`, `$setOnInsert` only, so
staff edits to allowances survive a redeploy — the existing `SeedPlans` rule.
2. **Re-key existing plan rows.** The three current rows are keyed by tier alone.
`free` and `professional` gain `deployment: "cloud"`. The row with tier
`self_hosted` becomes `deployment: "self_hosted", tier: "professional"`.
3. **Re-tier existing self-hosted instances and their entitlements.** Instances
holding `tier: "self_hosted"` become `tier: "professional"`; their deployment
already says so.
4. **`license.TierSelfHosted` is kept as a legacy constant** that no new licence
uses. Licences already issued carry `tier: "self_hosted"` in a signed payload
we cannot rewrite, and the server reads limits and features from the payload
rather than from the tier name — so they keep working untouched. This is
exactly what "the server never branches on tier name" was for.
5. **Backfill an entitlement row per instance** from its current licence:
`granted.servers` from `limits.max_servers` (`Unlimited` maps to the plan
base, since an unlimited licence bought no server units), `granted.features`
from the licence's features, `desired` equal to `granted`.
6. **Seed the catalogue** with sixteen rows — the four paid plans times a `base`,
a `limit: max_servers`, a `feature: console` and a `feature: oidc` — price IDs
empty. **The two Free plans get no catalogue rows at all**, which is what keeps
Free outside Paddle: there is nothing to price, so no checkout can be built. Empty price IDs mean checkout refuses until staff paste them, which is
the correct failure: a checkout that silently picks the wrong price is worse
than one that will not open.
Existing licences are not reissued. `MaxMonitors` and `AuditRetentionDays` are
absent from their payloads and decode as `0`, which would read as "no monitors,
trim everything". **Zero must therefore be treated as unset on decode** and
filled from the plan base — a licence signed before a field existed cannot be
allowed to mean the most restrictive possible value of it. This is the one
sharp edge in the whole migration and it is worth a comment at the decode site.
---
## Out of scope
- **Paid feature add-ons.** The model supports one — a `price_ids` entry on a
`kind: "feature"` row — but no feature has a price at launch.
- **Metered channels, monitors or secret groups.** A catalogue row away, and
deliberately not taken.
- **Usage-based billing.** Servers are a configured cap, not a measured count. We
never bill for what an instance ran; we bill for what it is allowed to run.
- **Refunds and credits.** Paddle's, and only Paddle's.
- **Enterprise contract terms, POs and invoicing.** Card only at launch.
- **Anything that revokes or shortens a licence.** Offline verification means
this is not that kind of system, and no part of this design changes it.
---
## Done when
- Six plan rows exist, keyed on `(deployment, tier)`, and a customer can buy any
of the four paid combinations from the configurator.
- A Professional cloud customer can go from 3 to 10 servers and see the new cap
in `web/` without waiting for a renewal.
- The same customer can reduce to 5 and see both the current cap and the date it
drops, with their licence untouched until then.
- Free self-hosted can be created, renewed from HQ, and lapses to read-only
without being reaped.
- Unticking Browser console removes it from the next issued licence, and
`POST /api/console/connect` answers 403 on an instance whose licence lacks it.
- A monitor beyond the cap is refused with a machine-readable 403.
- `audit_logs` older than the licence's retention are gone, and an unlimited
licence's are not.
- Every price ID in the running environment resolves to a plan, and a webhook
naming one that does not fails loudly onto the staff dashboard.
+16 -2
View File
@@ -10,13 +10,18 @@ Build in this order. Specs 0a5 were designed 2026-07-24; spec 6 on 2026-07-26
| 2 | [instance-licensing](2026-07-24-instance-licensing-design.md) | [plan](../plans/2026-07-24-instance-licensing.md) | **shipped**, no grandfathering — existing cloud instances are read-only until admin backfills |
| 3 | [admin-backend](2026-07-24-admin-backend-design.md) | [plan](../plans/2026-07-24-admin-backend.md) | **shipped**, verified end to end against scratch databases |
| 4 | [admin-site](2026-07-24-admin-site-design.md) | — | ready to start |
| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | — | ready to start; its "signup migration off sitesvc" section is superseded by 6 |
| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | [plan](../plans/2026-07-26-paddle-billing.md) | ready to start, **but revised by 7** its "signup migration off sitesvc" section is superseded by 6, and its single-price-per-subscription assumption by 7 |
| 6 | [cloud-instance-creation](2026-07-26-cloud-instance-creation-design.md) | — | ready to start |
| 7 | [metered-licensing](2026-07-26-metered-licensing-design.md) | — | designed; **build before 5**, whose plan it revises |
Specs 1 and 2 together give working licensing with licences cut by hand with
`lkctl` — no admin service needed. 4 and 5 can run in parallel once 3 lands.
4 and 5 can run in parallel once 3 lands.
7 lands before 5. It re-keys `plans` on `(deployment, tier)`, moves every Paddle
price ID out of `plans` into a new `catalogue` collection, and adds the
`entitlements` collection that both a subscription and a licence are derived from
— all of which plan 5 builds on top of, so building 5 first would mean writing
its billing code twice.
## The shape
@@ -53,6 +58,11 @@ branches on tier name. Tier contents live in the admin `plans` table and are
snapshotted into each issued licence, so editing a plan never rewrites history —
the same rule as `workflow_runs.steps_snapshot`.
Spec 7 replaces the three-tier table below with two deployments times three
tiers, and makes the server count a metered quantity rather than a fixed
allowance. See [metered-licensing](2026-07-26-metered-licensing-design.md) for
the current grid. As shipped through spec 3, the table is:
| | Free | Professional | Self Hosted |
|---|---|---|---|
| deployment | cloud only | cloud | self-hosted |
@@ -67,6 +77,10 @@ Free is cloud-only by construction: it is only ever signed with
`deployment: "cloud"`, and verification rejects a deployment mismatch. There is
no server-side flag to edit. One Free instance per account.
**Spec 7 ends that construction-level guarantee** — there is a self-hosted Free
plan, so `plan.Deployment != inst.Deployment` no longer implies it, and the Free
limit becomes one per account *per deployment*.
**Existing cloud tenants are not grandfathered.** The migration that would have
done it was removed before plan 2 shipped, so every existing cloud instance is
read-only until it is licensed by hand through the admin service: attach it to an