From ff0caf5a900bfa6451f1aac50f2b53eee5d3af03 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Sun, 26 Jul 2026 23:16:35 +0100 Subject: [PATCH] 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 --- .../plans/2026-07-26-paddle-billing.md | 54 ++- .../2026-07-26-metered-licensing-design.md | 453 ++++++++++++++++++ docs/superpowers/specs/README.md | 18 +- 3 files changed, 522 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-26-metered-licensing-design.md diff --git a/docs/superpowers/plans/2026-07-26-paddle-billing.md b/docs/superpowers/plans/2026-07-26-paddle-billing.md index 242b61f..62c8115 100644 --- a/docs/superpowers/plans/2026-07-26-paddle-billing.md +++ b/docs/superpowers/plans/2026-07-26-paddle-billing.md @@ -8,6 +8,58 @@ **Tech Stack:** Go 1.26, gin, MongoDB driver v2.8.0, `github.com/PaddleHQ/paddle-go-sdk/v4` v4.2.0, Next.js 16, TanStack Query, `@paddle/paddle-js`. +--- + +## ⚠ REVISED BY SPEC 7 — DO NOT EXECUTE AS WRITTEN + +[Spec 7, metered-licensing](../specs/2026-07-26-metered-licensing-design.md), was +designed on 2026-07-26, after this plan and before any of it was implemented. +`admin/internal/paddle` and `admin/internal/billing` do not exist, so nothing here +has shipped and nothing needs unpicking. + +**Spec 7 must be built first, and this plan is then regenerated against it.** The +break is structural rather than cosmetic: this plan assumes **one price per +subscription**, and a metered plan has three or more — a base fee, a per-server +unit at quantity N, and an item per paid feature. Every place that maps a price +ID to a tier changes shape. + +What spec 7 changes: + +- Two deployments × three tiers = **six plans**. `plans` is re-keyed on + `(deployment, tier)`; `Enterprise` is new; Free exists in both deployments. +- **`plans.paddle_price_ids` and `paddle_product_id` are deleted.** Every price ID + moves to a new `catalogue` collection, one row per priceable component + (`base` / `limit` / `feature`). +- A new **`entitlements`** collection, one row per instance, holding `desired` + beside `granted`. A licence is only ever signed from `granted`; a checkout is + only ever built from `desired`. +- Servers are metered: `3 + N` at Professional, `10 + N` at Enterprise. Increases + are prorated and immediate; **reductions are scheduled for the next renewal.** +- Console and SSO become per-customer opt-in toggles, and the control plane starts + actually enforcing `license.HasFeature`, which today it calls from nowhere. + +Task by task: + +| Task | Status under spec 7 | +|---|---| +| 1 — Paddle client, config, price IDs as data | **Split.** The `paddle` package and the three config variables survive verbatim. Everything about `Plan.PaddlePriceIDs`, `Plan.PriceID` and `models.ResolvePriceID` is replaced by the `catalogue` collection and `catalogue.LineItems` / reverse resolution. `TermMonthly`/`TermAnnual` and the `Sub*` status constants survive. | +| 2 — the webhook: signature, idempotency, dispatch | **Survives unchanged.** Nothing about verification, `paddle_events` or the dispatch switch depends on how many prices a subscription has. | +| 3 — subscription events | **Rewritten.** `SubState.PriceID` becomes the full item list. Tier, deployment and term come from the item matching a `kind: "base"` row; the server count from the `kind: "limit"` item's quantity; features from the `kind: "feature"` items. On success it promotes `desired` into `granted` and reissues from the entitlement. `MarkCanceled` and `MarkPastDue` survive verbatim — they take no licence action, and that does not change. The self-hosted-annual-only refusal survives, moved into catalogue resolution. | +| 4 — renewals and payment failures | **Extended.** A renewal must now promote a *scheduled reduction* — `desired` into `granted` — before issuing, and clear `scheduled_change_at`. That is the only mechanism by which a licence ever gets a smaller cap. | +| 5 — checkout endpoints and the placeholder | **Reworked.** `GET /api/checkout/options` serves the catalogue: six plans, base allowances, the per-server price, and the feature list with prices where they exist. `POST /api/instances/self-hosted` gains tier, term, server count and features. A new `PUT /api/instances/:id/entitlement` makes admin's first outbound Paddle call beyond the portal session. Placeholder creation and claiming survive verbatim. | +| 6 — the awaiting-link sweep | **Survives**, except backstop issuance reads the entitlement rather than the plan. | +| 7 — checkout in the portal | **Reworked.** `UpgradePanel` becomes the configurator: deployment, tier, term, a server stepper and feature checkboxes, with a live price. `CheckoutButton` opens an overlay with several items rather than one price. A pending reduction is shown with the date it takes effect. | +| 8 — staff price-ID editor | **Reworked.** Two tables — `plans` (allowances, support level, active) and `catalogue` (price IDs per environment and term) — instead of one nested map per plan row. | +| 9 — deployment configuration | **Survives unchanged.** | +| 10 — the sandbox catalog and the end-to-end pass | **Grows.** Eight sandbox products, not two: a base and an additional-server product for each of the four paid plans, giving twelve prices (cloud gets monthly and annual, self-hosted annual only). Free is still not a Paddle product — now for both of its deployments. The end-to-end pass gains a server increase, a scheduled reduction, and a feature toggle. | + +And one correction to this plan's own "Not in this plan" list at the foot: +**metered pricing and a self-service downgrade path are now in scope**, by spec 7. +Discounts, coupons, our own proration arithmetic, invoice display and tax remain +out. + +--- + ## Global Constraints - **No automated Go tests.** This repo has no Go test suite (one exception: `agent/internal/config/config_test.go`). Verification is by compiler, `grep`, `curl` and running built images against scratch databases. Every "confirm" step below is a command with expected output. **Do not add `*_test.go` files.** @@ -28,7 +80,7 @@ ``` - **`MSYS_NO_PATHCONV=1` on every `docker` call.** Git Bash rewrites container paths otherwise. - **Run `go mod tidy` with `GOWORK=off`.** In workspace mode it drops `require` lines and the Docker build then fails with "missing go.sum entry". -- **No price ID, product ID or Paddle URL is ever hard-coded** in Go or TypeScript. Price IDs live in `plans.paddle_price_ids`, keyed by environment, and are edited through the staff UI. The only Paddle literals allowed in code are the two base URLs inside the SDK. +- **No price ID, product ID or Paddle URL is ever hard-coded** in Go or TypeScript. Price IDs live in `plans.paddle_price_ids`, keyed by environment, and are edited through the staff UI. The only Paddle literals allowed in code are the two base URLs inside the SDK. (Under spec 7 the location changes to the `catalogue` collection; the constraint itself does not.) - **Licences are offline-verified: nothing Paddle says revokes one early.** `subscription.canceled` and `subscription.past_due` take **no licence action** whatsoever. If you find yourself shortening an expiry, stop — that is not this system. - **Self Hosted is annual only.** A resolved self-hosted price whose term is not `annual` is a configuration error and must fail the handler loudly rather than issue a monthly self-hosted licence. - **Every webhook is idempotent.** The event ID is claimed in `paddle_events` before processing. A duplicate of a *handled* event returns `200` and does nothing. A retry of a *failed* event is reprocessed. diff --git a/docs/superpowers/specs/2026-07-26-metered-licensing-design.md b/docs/superpowers/specs/2026-07-26-metered-licensing-design.md new file mode 100644 index 0000000..496d56b --- /dev/null +++ b/docs/superpowers/specs/2026-07-26-metered-licensing-design.md @@ -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. diff --git a/docs/superpowers/specs/README.md b/docs/superpowers/specs/README.md index 05a115f..68da893 100644 --- a/docs/superpowers/specs/README.md +++ b/docs/superpowers/specs/README.md @@ -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