Compare commits
4
Commits
dd306e4757
...
3fc726da9e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fc726da9e | ||
|
|
73b6b548f0 | ||
|
|
ff0caf5a90 | ||
|
|
87dc9fc858 |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,455 @@
|
||||
# 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
|
||||
|
||||
**Feature gating already exists and is already mounted.** `RequireFeature` in
|
||||
`server/internal/api/licence.go` answers 403 `feature_unavailable`, and
|
||||
`server/internal/api/handlers.go` already wraps `POST /api/console/connect`,
|
||||
`GET /api/console/tunnel` and `GET`/`PUT /api/org/oidc` in it. Free's feature list
|
||||
is empty, so a Free instance already cannot open the console. **No capability is
|
||||
taken away from an existing tenant by this spec, and no customer email is owed.**
|
||||
|
||||
**One gap remains, and it is a single check.** `HandleOIDCStart` already tests
|
||||
`Feature("oidc")` and redirects to `/login?error=oidc_unavailable`.
|
||||
`HandleOIDCCallback` does not test it at all. A start that 403s is a dead end; an
|
||||
ungated callback completes a sign-in, so the unguarded half is the half that
|
||||
matters.
|
||||
|
||||
The callback cannot copy the start's instance resolution: the start reads
|
||||
`InstanceFromHost(c)`, while the callback resolves the instance from the OAuth
|
||||
state it consumes, and by then it holds `instanceID` directly. The check goes
|
||||
after `ConsumeStateInstance` and before `providerForInstance`, so a licence that
|
||||
lapsed mid-flow stops the exchange rather than completing it.
|
||||
|
||||
`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.
|
||||
|
||||
**`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
|
||||
(already true; the new part is that a customer controls the tick).
|
||||
- `/auth/oidc/callback` answers 403 on an instance whose licence lacks `oidc`.
|
||||
- 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.
|
||||
@@ -10,13 +10,18 @@ Build in this order. Specs 0a–5 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
|
||||
|
||||
@@ -68,7 +68,8 @@ func issue(args []string) {
|
||||
instanceID := fs.String("instance-id", "", "instance UUID the licence is bound to (required)")
|
||||
instanceName := fs.String("instance-name", "", "display name")
|
||||
accountID := fs.String("account-id", "", "admin-side account id, optional")
|
||||
tier := fs.String("tier", "", "free | professional | self_hosted (required)")
|
||||
tier := fs.String("tier", "", "free | professional | enterprise (required)")
|
||||
deployment := fs.String("deployment", "cloud", "cloud | self_hosted")
|
||||
term := fs.String("term", "1y", "1m or 1y")
|
||||
expires := fs.String("expires", "", "explicit RFC3339 expiry, overrides --term")
|
||||
out := fs.String("out", "", "write the blob to this file instead of stdout")
|
||||
@@ -78,9 +79,9 @@ func issue(args []string) {
|
||||
fatal("--instance-id and --tier are required")
|
||||
}
|
||||
|
||||
plan, ok := license.PlanFor(*tier)
|
||||
plan, ok := license.PlanFor(*deployment, *tier)
|
||||
if !ok {
|
||||
fatal("unknown tier %q", *tier)
|
||||
fatal("no plan for deployment %q tier %q", *deployment, *tier)
|
||||
}
|
||||
|
||||
key := os.Getenv("LICENSE_SIGNING_KEY")
|
||||
@@ -107,7 +108,7 @@ func issue(args []string) {
|
||||
|
||||
// Self Hosted is sold annually only, so the window in which a cancelled
|
||||
// licence keeps working is bounded at a year.
|
||||
if plan.Tier == license.TierSelfHosted && *term == "1m" && *expires == "" {
|
||||
if plan.Deployment == license.DeploymentSelfHosted && *term == "1m" && *expires == "" {
|
||||
fatal("self_hosted is annual only; use --term=1y or an explicit --expires")
|
||||
}
|
||||
|
||||
@@ -123,6 +124,7 @@ func issue(args []string) {
|
||||
InstanceName: name,
|
||||
Tier: plan.Tier,
|
||||
Deployment: plan.Deployment,
|
||||
SupportLevel: plan.SupportLevel,
|
||||
IssuedAt: now,
|
||||
ExpiresAt: exp,
|
||||
Limits: plan.Limits,
|
||||
|
||||
@@ -16,7 +16,15 @@ import "time"
|
||||
const (
|
||||
TierFree = "free"
|
||||
TierProfessional = "professional"
|
||||
TierSelfHosted = "self_hosted"
|
||||
TierEnterprise = "enterprise"
|
||||
|
||||
// TierSelfHosted is LEGACY and no new licence carries it.
|
||||
//
|
||||
// It was a tier when self-hosting was a tier rather than a deployment. Blobs
|
||||
// already signed with it exist and cannot be rewritten, so it stays a
|
||||
// recognised value that NormaliseTier maps forward. Never put it in a plan
|
||||
// row and never offer it in a UI.
|
||||
TierSelfHosted = "self_hosted"
|
||||
|
||||
DeploymentCloud = "cloud"
|
||||
DeploymentSelfHosted = "self_hosted"
|
||||
@@ -25,14 +33,59 @@ const (
|
||||
FeatureOIDC = "oidc" // per-instance single sign-on
|
||||
)
|
||||
|
||||
// Support levels. Carried for display and enforced by nothing — there is no code
|
||||
// path anywhere that branches on these, and there must not be one. They are here
|
||||
// so an air-gapped install can tell its operator who to call without reaching
|
||||
// Vantage HQ.
|
||||
const (
|
||||
SupportCommunity = "community"
|
||||
SupportEmail24x5 = "email_24_5"
|
||||
SupportEmailCall24x7 = "email_call_24_7"
|
||||
)
|
||||
|
||||
// Unlimited is the sentinel for "no cap" in every Limits field.
|
||||
const Unlimited = -1
|
||||
|
||||
// Limits are the countable caps a licence grants.
|
||||
//
|
||||
// Every field is a plain int with Unlimited as the sentinel. AuditRetentionDays
|
||||
// is the odd one out: it bounds a duration rather than a count, and Unlimited
|
||||
// there means "never trim" rather than "no cap".
|
||||
type Limits struct {
|
||||
MaxServers int `json:"max_servers"`
|
||||
MaxSecretGroups int `json:"max_secret_groups"`
|
||||
MaxChannels int `json:"max_channels"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// FillUnset replaces any zero field with the same field from base.
|
||||
//
|
||||
// This exists for one reason: a licence signed before a field existed decodes it
|
||||
// as 0, and 0 would read as the most restrictive possible value — no monitors,
|
||||
// and an audit log trimmed to nothing. A blob we cannot re-sign must not be
|
||||
// allowed to mean that.
|
||||
//
|
||||
// The cost is that 0 stops being expressible as a real allowance. No plan grants
|
||||
// zero of anything, so nothing is lost today; a plan that genuinely means zero
|
||||
// must use a negative-free sentinel of its own rather than reintroducing 0 here.
|
||||
func (l Limits) FillUnset(base Limits) Limits {
|
||||
if l.MaxServers == 0 {
|
||||
l.MaxServers = base.MaxServers
|
||||
}
|
||||
if l.MaxMonitors == 0 {
|
||||
l.MaxMonitors = base.MaxMonitors
|
||||
}
|
||||
if l.MaxSecretGroups == 0 {
|
||||
l.MaxSecretGroups = base.MaxSecretGroups
|
||||
}
|
||||
if l.MaxChannels == 0 {
|
||||
l.MaxChannels = base.MaxChannels
|
||||
}
|
||||
if l.AuditRetentionDays == 0 {
|
||||
l.AuditRetentionDays = base.AuditRetentionDays
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// License is the signed payload.
|
||||
@@ -47,6 +100,7 @@ type License struct {
|
||||
InstanceName string `json:"instance_name"` // display only
|
||||
Tier string `json:"tier"`
|
||||
Deployment string `json:"deployment"`
|
||||
SupportLevel string `json:"support_level,omitempty"` // display only
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Limits Limits `json:"limits"`
|
||||
|
||||
+112
-32
@@ -1,49 +1,129 @@
|
||||
package license
|
||||
|
||||
// Plan is the contents of a tier at issue time.
|
||||
// Plan is the contents of one (deployment, tier) pair at issue time.
|
||||
//
|
||||
// This table is the seed. Once the admin service exists (spec 3) it owns the
|
||||
// authoritative copy in its `plans` collection, and every issued licence
|
||||
// snapshots the plan it was cut from — so editing a plan never rewrites an
|
||||
// existing licence, the same rule as workflow_runs.steps_snapshot.
|
||||
// This table is the seed. The admin service owns the authoritative copy in its
|
||||
// `plans` collection, and every issued licence snapshots the plan it was cut
|
||||
// from — so editing a plan never rewrites an existing licence, the same rule as
|
||||
// workflow_runs.steps_snapshot.
|
||||
//
|
||||
// lkctl uses this table to issue by hand until then.
|
||||
// Limits here are the BASE allowance: what the tier grants before anything is
|
||||
// bought. A metered dimension adds to it, which is why max_servers is a real
|
||||
// number at Professional and Enterprise rather than Unlimited.
|
||||
type Plan struct {
|
||||
Tier string
|
||||
Name string
|
||||
Deployment string
|
||||
Limits Limits
|
||||
Features []string
|
||||
Tier string
|
||||
Name string
|
||||
Deployment string
|
||||
SupportLevel string
|
||||
Limits Limits
|
||||
Features []string
|
||||
}
|
||||
|
||||
var plans = map[string]Plan{
|
||||
TierFree: {
|
||||
Tier: TierFree,
|
||||
Name: "Free",
|
||||
Deployment: DeploymentCloud, // cloud only, by construction
|
||||
Limits: Limits{MaxServers: 3, MaxSecretGroups: 1, MaxChannels: 1},
|
||||
// planKey is the composite the table is keyed on.
|
||||
//
|
||||
// Keying on tier alone was what made Free cloud-only by construction. There is
|
||||
// now a self-hosted Free plan, so that guarantee is gone and the Free limit is
|
||||
// enforced per account AND deployment instead. See licensing.checkFreeLimit.
|
||||
type planKey struct {
|
||||
Deployment string
|
||||
Tier string
|
||||
}
|
||||
|
||||
// baseFree, baseProfessional and baseEnterprise are shared by both deployments.
|
||||
//
|
||||
// The allowances are deliberately identical across cloud and self-hosted: what
|
||||
// differs between the two is the term on offer, not what you get. Duplicating
|
||||
// them per deployment would be four places to forget.
|
||||
var (
|
||||
baseFree = Limits{
|
||||
MaxServers: 3, MaxMonitors: 3, MaxSecretGroups: 1,
|
||||
MaxChannels: 1, AuditRetentionDays: 30,
|
||||
}
|
||||
baseProfessional = Limits{
|
||||
MaxServers: 3, MaxMonitors: Unlimited, MaxSecretGroups: Unlimited,
|
||||
MaxChannels: Unlimited, AuditRetentionDays: 365,
|
||||
}
|
||||
baseEnterprise = Limits{
|
||||
MaxServers: 10, MaxMonitors: Unlimited, MaxSecretGroups: Unlimited,
|
||||
MaxChannels: Unlimited, AuditRetentionDays: Unlimited,
|
||||
}
|
||||
)
|
||||
|
||||
var plans = map[planKey]Plan{
|
||||
planKey{DeploymentCloud, TierFree}: {
|
||||
Tier: TierFree, Name: "Free", Deployment: DeploymentCloud,
|
||||
SupportLevel: SupportCommunity, Limits: baseFree,
|
||||
// Empty rather than nil: nil marshals as JSON null, and this table is
|
||||
// the seed every plan and licence is cut from.
|
||||
Features: []string{},
|
||||
},
|
||||
TierProfessional: {
|
||||
Tier: TierProfessional,
|
||||
Name: "Professional",
|
||||
Deployment: DeploymentCloud,
|
||||
Limits: Limits{MaxServers: Unlimited, MaxSecretGroups: Unlimited, MaxChannels: Unlimited},
|
||||
Features: []string{FeatureConsole, FeatureOIDC},
|
||||
planKey{DeploymentCloud, TierProfessional}: {
|
||||
Tier: TierProfessional, Name: "Professional", Deployment: DeploymentCloud,
|
||||
SupportLevel: SupportEmail24x5, Limits: baseProfessional,
|
||||
// Console and SSO are opt-in per customer, so no tier bundles them. The
|
||||
// field stays because a future tier might.
|
||||
Features: []string{},
|
||||
},
|
||||
TierSelfHosted: {
|
||||
Tier: TierSelfHosted,
|
||||
Name: "Self Hosted",
|
||||
Deployment: DeploymentSelfHosted,
|
||||
Limits: Limits{MaxServers: Unlimited, MaxSecretGroups: Unlimited, MaxChannels: Unlimited},
|
||||
Features: []string{FeatureConsole, FeatureOIDC},
|
||||
planKey{DeploymentCloud, TierEnterprise}: {
|
||||
Tier: TierEnterprise, Name: "Enterprise", Deployment: DeploymentCloud,
|
||||
SupportLevel: SupportEmailCall24x7, Limits: baseEnterprise,
|
||||
Features: []string{},
|
||||
},
|
||||
planKey{DeploymentSelfHosted, TierFree}: {
|
||||
Tier: TierFree, Name: "Free", Deployment: DeploymentSelfHosted,
|
||||
SupportLevel: SupportCommunity, Limits: baseFree,
|
||||
Features: []string{},
|
||||
},
|
||||
planKey{DeploymentSelfHosted, TierProfessional}: {
|
||||
Tier: TierProfessional, Name: "Professional", Deployment: DeploymentSelfHosted,
|
||||
SupportLevel: SupportEmail24x5, Limits: baseProfessional,
|
||||
Features: []string{},
|
||||
},
|
||||
planKey{DeploymentSelfHosted, TierEnterprise}: {
|
||||
Tier: TierEnterprise, Name: "Enterprise", Deployment: DeploymentSelfHosted,
|
||||
SupportLevel: SupportEmailCall24x7, Limits: baseEnterprise,
|
||||
Features: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
// PlanFor returns the seed plan for a tier.
|
||||
func PlanFor(tier string) (Plan, bool) {
|
||||
p, ok := plans[tier]
|
||||
// PlanFor returns the seed plan for one deployment and tier.
|
||||
//
|
||||
// It normalises first, so a legacy self_hosted licence resolves to the plan that
|
||||
// replaced it rather than to nothing.
|
||||
func PlanFor(deployment, tier string) (Plan, bool) {
|
||||
deployment, tier = NormaliseTier(deployment, tier)
|
||||
p, ok := plans[planKey{deployment, tier}]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// NormaliseTier maps a legacy tier forward.
|
||||
//
|
||||
// tier "self_hosted" predates deployments being separate from tiers. Such a
|
||||
// licence granted what Professional now grants, on a self-hosted install, so it
|
||||
// maps to exactly that. Called by PlanFor and by anything reading a tier off an
|
||||
// already-signed payload.
|
||||
func NormaliseTier(deployment, tier string) (string, string) {
|
||||
if tier == TierSelfHosted {
|
||||
return DeploymentSelfHosted, TierProfessional
|
||||
}
|
||||
return deployment, tier
|
||||
}
|
||||
|
||||
// Tiers is the offer order, for any UI that lists them.
|
||||
func Tiers() []string { return []string{TierFree, TierProfessional, TierEnterprise} }
|
||||
|
||||
// Deployments is the offer order.
|
||||
func Deployments() []string { return []string{DeploymentCloud, DeploymentSelfHosted} }
|
||||
|
||||
// TermsFor reports which billing terms a deployment sells.
|
||||
//
|
||||
// Self-hosted is annual only, and the reason is in this package's doc comment: an
|
||||
// offline licence cannot be revoked, so the term length IS the revocation
|
||||
// window. A self-hosted monthly licence would renew that window twelve times a
|
||||
// year for no commercial gain.
|
||||
func TermsFor(deployment string) []string {
|
||||
if deployment == DeploymentSelfHosted {
|
||||
return []string{"annual"}
|
||||
}
|
||||
return []string{"monthly", "annual"}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user