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:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user