# Metered Licensing Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** make a licence express what one customer configured rather than what a fixed tier grants — two deployments times three tiers, a metered server count, and per-customer feature toggles — with the control plane enforcing every column of the pricing table.

**Architecture:** `plans` is re-keyed on `(deployment, tier)` and holds base allowances only. A new `catalogue` collection holds one row per priceable component and is the only place a Paddle price ID lives. A new `entitlements` collection holds one row per instance with `desired` beside `granted`; `licensing.Issue` snapshots `granted` and nothing else. One new package, `admin/internal/catalogue`, owns both folds — entitlement to licence limits, and entitlement to Paddle line items — so the arithmetic exists once.

**Tech Stack:** Go 1.26, gin, MongoDB driver v2.8.0, Next.js 16, TanStack Query, Tailwind 3.

Spec: [`docs/superpowers/specs/2026-07-26-metered-licensing-design.md`](../specs/2026-07-26-metered-licensing-design.md).

## 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.**
- **Never run `go` or `npm` on the host.** Everything runs in a container. Create the wrappers if missing:
    ```sh
    # /tmp/gorun.sh <module-dir> <command...>
    DIR="$1"; shift
    MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \
      -v vantage-gocache:/root/.cache/go-build -w "/src/$DIR" \
      golang:1.26 "$@"
    ```
    ```sh
    # /tmp/npmrun.sh <dir> <command...>
    DIR="$1"; shift
    MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-npm:/root/.npm \
      -w "/src/$DIR" node:26-alpine "$@"
    ```
- **`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".
- **This plan adds no Paddle dependency, no webhook and no checkout.** See "The seam with plan 5" below. If you find yourself importing a Paddle SDK, stop.
- **No price ID, product ID or Paddle URL is ever hard-coded** in Go or TypeScript. Price IDs live in `catalogue.price_ids`, keyed by environment then term, and are edited through the staff UI.
- **`licensing.Issue` stays the only signer.** Nothing else touches `licenses` or signs anything.
- **Admin's control-plane writes stay confined to `inject` (three licence fields) and `cloudprov` (instances and users).** This plan adds no third write path.
- **Every licence snapshots; nothing rewrites history.** Editing a plan, a catalogue row or an entitlement must never change an issued licence.
- **Customer endpoints answer 404, never 403,** for another account's resource. Every handler naming an instance goes through `ownedInstance`.
- **Nothing shortens or revokes a licence.** Reductions take effect at renewal. If you find yourself writing an earlier `expires_at`, stop.
- **`web/` is dark-only and carries no hex values;** `adminsite/` is light-default and carries no hex values. Tokens only, in both.

## The seam with plan 5

Spec 7 lands **before** [plan 5](2026-07-26-paddle-billing.md), whose preamble it revises. Plan 5 is unstarted: `admin/internal/paddle` and `admin/internal/billing` do not exist. That fixes this plan's boundary precisely.

**In this plan:** the data model, both resolution folds, issuance from an entitlement, control-plane enforcement, and staff-facing screens to edit plans, the catalogue and an instance's entitlement.

**Left to plan 5:** the Paddle client, webhooks, `GET /api/checkout/options`, the outbound subscription update, and the _customer's_ purchase configurator.

The customer-facing configurator is deliberately not built here. It cannot function without a checkout, and a dead screen is worse than no screen. Instead, task 8 builds the configurator as a **component** driven entirely by the catalogue, mounted first on the staff instance screen. Plan 5 then mounts the same component in the customer purchase flow and hands it a checkout. This mirrors how spec 3 made staff issuance work through the API before any UI existed for it.

**The consequence to accept:** at the end of this plan a customer cannot buy anything new. Staff can configure any instance's entitlement and issue against it, which is exactly the position spec 3 left licensing in, and it is what makes plan 5 a wiring job rather than a rewrite.

## Context this plan inherits

Shipped and load-bearing here:

- `licensing.Issue(ctx, IssueInput)` signs, records, supersedes the previous licence, sets `tier`/`status: active`/`current_license` on the instance, and resets `relink_count` when `Reason == ReasonRenewal`. `IssueInput.ExpiresAt` overrides `Term`. It does **not** deliver.
- `models.GracePeriod` is 3 days and is added by `Issue` when it derives an expiry from `Term`. **When you pass `ExpiresAt` you must add the grace yourself.**
- `models.SeedPlans` uses `$setOnInsert` only, so staff edits survive a redeploy.
- `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes. Admin has no migrations collection; this is where schema moves go.
- `db.EnsureIndexes` declares `plans.tier` **unique**. Re-keying means dropping that index by name, which Mongo will not do implicitly.
- **Feature gating already exists and is already mounted.** `api.RequireFeature` answers 403 `feature_unavailable`, and `handlers.go` wraps `/api/console/connect`, `/api/console/tunnel` and `GET`/`PUT /api/org/oidc` in it. `auth.HandleOIDCStart` checks `Feature("oidc")` itself. **Only `auth.HandleOIDCCallback` is ungated.**
- `services.GetLicenseState` caches per instance for 60s; `stateFromResult` is the single decode site where a payload becomes a `LicenseState`.
- `services.StartLogSweeper` is the pattern for a retention sweep: an immediate pass, then an hourly ticker.
- `api.limitStatus` maps a `*services.LimitError` to a 403 with `limit`/`current`/`max`.

---

## File Structure

**Created:**

| Path                                             | Responsibility                                                                     |
| ------------------------------------------------ | ---------------------------------------------------------------------------------- |
| `admin/internal/models/catalogue.go`             | the `CatalogueRow` document, its kind constants, its price lookup, and its seed    |
| `admin/internal/models/entitlements.go`          | the `Entitlement` and `Config` documents, and the per-instance read/upsert helpers |
| `admin/internal/catalogue/catalogue.go`          | row loading, and the fold to `license.Limits` + features                           |
| `admin/internal/catalogue/items.go`              | the fold to Paddle line items, and the reverse fold from an item list              |
| `admin/internal/api/entitlement.go`              | staff and customer entitlement endpoints                                           |
| `server/internal/services/audit_retention.go`    | the audit-log retention sweep                                                      |
| `adminsite/components/PlanConfigurator.tsx`      | deployment/tier/term/servers/features, catalogue-driven, priced                    |
| `adminsite/app/(staff)/staff/catalogue/page.tsx` | the price-ID editor, one table per environment                                     |

**Modified:**

| Path                                                  | Change                                                                                       |
| ----------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `shared/license/license.go`                           | `Limits` gains two fields; `License` gains `SupportLevel`; `FillUnset`; support constants    |
| `shared/license/plans.go`                             | six plans keyed on `(deployment, tier)`; `PlanFor` re-signed; `NormaliseTier`                |
| `shared/cmd/lkctl/main.go`                            | the new `PlanFor` signature and a `--deployment` flag                                        |
| `admin/internal/models/models.go`                     | `Plan` re-shaped; `ReasonEntitlementChange`                                                  |
| `admin/internal/models/plans.go`                      | `SeedPlans` writes six rows; `GetPlan` takes a deployment                                    |
| `admin/internal/models/backfill.go`                   | re-key plans, re-tier self-hosted instances, backfill entitlements                           |
| `admin/internal/db/db.go`                             | drop `plans.tier` unique; add three indexes                                                  |
| `admin/internal/licensing/issue.go`                   | issue from the entitlement; Free limit per deployment                                        |
| `admin/internal/api/customer.go`                      | the Free pre-check gains a deployment; `claimFree`; `renewInstance` handles both deployments |
| `admin/internal/lifecycle/lifecycle.go`               | deletion warnings and reaping stay cloud-only                                                |
| `web/lib/api.ts`                                      | `LicenseInfo` gains two limits, one usage count and the support level                        |
| `admin/internal/api/staff.go`                         | `staffUpdatePlan` re-keyed; catalogue handlers                                               |
| `admin/internal/api/routes.go`                        | the re-keyed plan route and five new routes                                                  |
| `admin/cmd/main.go`                                   | seed the catalogue at boot                                                                   |
| `server/internal/services/licence.go`                 | fill unset limits from the plan base at the decode site                                      |
| `server/internal/services/licence_limits.go`          | `CheckMonitorLimit`; monitors in `LicenseUsage`                                              |
| `server/internal/api/licence.go`                      | monitors in the usage response                                                               |
| `server/internal/api/monitors.go`                     | enforce the monitor cap on create                                                            |
| `server/internal/auth/oidc.go`                        | gate the callback                                                                            |
| `server/cmd/main.go`                                  | start the audit sweeper                                                                      |
| `web/app/(app)/settings/license/page.tsx`             | show monitors, audit retention and support level                                             |
| `adminsite/lib/api.ts`                                | the re-shaped `Plan`, `CatalogueRow`, `Entitlement`, and their calls                         |
| `adminsite/app/(staff)/staff/plans/page.tsx`          | six plans, allowances and support level; prices move out                                     |
| `adminsite/app/(staff)/staff/instances/[id]/page.tsx` | the entitlement configurator                                                                 |
| `adminsite/components/AppBar.tsx`                     | a staff nav entry for Catalogue                                                              |
| `CLAUDE.md`                                           | the collection list, the admin route table, the licensing summary                            |
| `docs/superpowers/specs/README.md`                    | spec 7 status                                                                                |
| `docs/superpowers/plans/2026-07-26-paddle-billing.md` | the preamble, narrowed to what remains                                                       |

---

### Task 1: The licence payload, six plans, and the legacy tier

**Files:**

- Modify: `shared/license/license.go`, `shared/license/plans.go`, `shared/cmd/lkctl/main.go`

**Interfaces:**

- Consumes: nothing from other tasks.
- Produces:
    - `license.Limits.MaxMonitors int`, `license.Limits.AuditRetentionDays int`
    - `func (l Limits) FillUnset(base Limits) Limits`
    - `license.License.SupportLevel string`
    - `license.TierEnterprise`
    - `license.SupportCommunity`, `SupportEmail24x5`, `SupportEmailCall24x7`
    - `func PlanFor(deployment, tier string) (Plan, bool)`
    - `func NormaliseTier(deployment, tier string) (string, string)`
    - `Plan.SupportLevel string`

- [ ] **Step 1: Add the tier, the support levels, and the two limit fields**

In `shared/license/license.go`, add `TierEnterprise` to the tier block and a support-level block after the feature constants:

```go
const (
	TierFree         = "free"
	TierProfessional = "professional"
	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"

	FeatureConsole = "console" // browser SSH/RDP/VNC
	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"
)
```

Replace `Limits`:

```go
// 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"`
	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
}
```

Add `SupportLevel` to `License`, immediately after `Deployment`:

```go
	Deployment   string    `json:"deployment"`
	SupportLevel string    `json:"support_level,omitempty"` // display only
```

- [ ] **Step 2: Replace the seed plan table with six plans**

Replace the whole of `shared/license/plans.go`:

```go
package license

// Plan is the contents of one (deployment, tier) pair at issue time.
//
// 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.
//
// 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
	SupportLevel string
	Limits       Limits
	Features     []string
}

// 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{
	{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{},
	},
	{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{},
	},
	{DeploymentCloud, TierEnterprise}: {
		Tier: TierEnterprise, Name: "Enterprise", Deployment: DeploymentCloud,
		SupportLevel: SupportEmailCall24x7, Limits: baseEnterprise,
		Features:     []string{},
	},
	{DeploymentSelfHosted, TierFree}: {
		Tier: TierFree, Name: "Free", Deployment: DeploymentSelfHosted,
		SupportLevel: SupportCommunity, Limits: baseFree,
		Features:     []string{},
	},
	{DeploymentSelfHosted, TierProfessional}: {
		Tier: TierProfessional, Name: "Professional", Deployment: DeploymentSelfHosted,
		SupportLevel: SupportEmail24x5, Limits: baseProfessional,
		Features:     []string{},
	},
	{DeploymentSelfHosted, TierEnterprise}: {
		Tier: TierEnterprise, Name: "Enterprise", Deployment: DeploymentSelfHosted,
		SupportLevel: SupportEmailCall24x7, Limits: baseEnterprise,
		Features:     []string{},
	},
}

// 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"}
}
```

- [ ] **Step 3: Update lkctl for the new signature**

In `shared/cmd/lkctl/main.go`'s `issue`, add a deployment flag beside the tier flag:

```go
	tier := fs.String("tier", "", "free | professional | enterprise (required)")
	deployment := fs.String("deployment", "cloud", "cloud | self_hosted")
```

Replace the `PlanFor` call and the self-hosted term guard:

```go
	plan, ok := license.PlanFor(*deployment, *tier)
	if !ok {
		fatal("no plan for deployment %q tier %q", *deployment, *tier)
	}
```

Then find the existing guard that reads:

```go
	if plan.Tier == license.TierSelfHosted && *term == "1m" && *expires == "" {
```

and replace its condition with the deployment, since self-hosted is no longer a tier:

```go
	if plan.Deployment == license.DeploymentSelfHosted && *term == "1m" && *expires == "" {
```

Finally, carry the support level into the payload. Find where the `license.License` literal is built in `issue` and add:

```go
		SupportLevel: plan.SupportLevel,
```

- [ ] **Step 4: Confirm every module still builds**

`shared` is imported by `server`, `admin` and `sitesvc`, so all four must be checked. `admin/internal/models/plans.go` calls the old `PlanFor` signature and **will fail here** — that is expected and task 2 fixes it.

```bash
GOWORK=off /tmp/gorun.sh shared go build ./...
```

Expected: no output.

```bash
GOWORK=off /tmp/gorun.sh server go build ./...
```

Expected: no output. The server never calls `PlanFor` yet.

```bash
GOWORK=off /tmp/gorun.sh admin go build ./...
```

Expected: **failure**, `not enough arguments in call to license.PlanFor` in `internal/models/plans.go`. Confirm the error names only that one file; anything else means a caller was missed.

Confirm the legacy mapping is reachable and the table is complete:

```bash
GOWORK=off /tmp/gorun.sh shared go vet ./...
```

Expected: no output.

```bash
grep -c "planKey{" shared/license/plans.go
```

Expected: `7` — one in the type-keyed map literal per plan (6) plus the lookup in `PlanFor`.

- [ ] **Step 5: Commit**

```bash
git add shared/license shared/cmd/lkctl/main.go
git commit -m "feat(license): two deployments times three tiers, and two new limits

Plans are keyed on (deployment, tier) rather than tier alone, which is what
ends Free being cloud-only by construction — there is a self-hosted Free
plan now, so the one-per-account rule has to be enforced per deployment
instead of falling out of the plan table.

tier self_hosted becomes a legacy value no new licence carries.
NormaliseTier maps it to self-hosted Professional, which is what it always
granted, so blobs we cannot re-sign keep working.

Limits.FillUnset exists because a licence signed before a field existed
decodes it as 0, and 0 would read as no monitors and an audit log trimmed
to nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 2: Plans re-keyed, and the catalogue and entitlement documents

**Files:**

- Create: `admin/internal/models/catalogue.go`, `admin/internal/models/entitlements.go`
- Modify: `admin/internal/models/models.go`, `admin/internal/models/plans.go`, `admin/internal/models/backfill.go`, `admin/internal/db/db.go`, `admin/cmd/main.go`

**Interfaces:**

- Consumes: `license.PlanFor(deployment, tier)`, `license.Tiers()`, `license.Deployments()`, `license.NormaliseTier`, `license.Limits.FillUnset`.
- Produces:
    - `models.Plan` with `Deployment`, `Tier`, `BaseLimits`, `BaseFeatures`, `SupportLevel`, `Active`
    - `models.GetPlan(ctx, deployment, tier) (*Plan, error)`
    - `models.SeedPlans(ctx) error` — six rows
    - `models.KindBase`, `KindLimit`, `KindFeature`
    - `models.LimitKeyServers`
    - `models.CatalogueRow` with `PriceID(env, term string) string`
    - `models.SeedCatalogue(ctx) error`
    - `models.CatalogueFor(ctx, deployment, tier) ([]CatalogueRow, error)`
    - `models.Config` with `Servers int`, `Features Features`
    - `models.Entitlement`
    - `models.GetEntitlement(ctx, instanceID) (*Entitlement, error)`
    - `models.ErrNoEntitlement`
    - `models.ReasonEntitlementChange`

- [ ] **Step 1: Re-shape the Plan struct and add the new reason**

In `admin/internal/models/models.go`, add to the issuance-reason block:

```go
	// ReasonEntitlementChange is a mid-term change to what an instance is
	// allowed — servers added, a feature toggled — at the same expiry.
	//
	// It is deliberately NOT ReasonRenewal: a renewal resets relink_count
	// because a new term has begun, and adding a server does not begin one.
	ReasonEntitlementChange = "entitlement_change"
```

Replace the `Plan` struct:

```go
// Plan is the authoritative definition of one (deployment, tier) pair, seeded
// from shared/license.
//
// It lives in the database so tier contents change without a deploy. Every
// issued licence snapshots it, so editing a plan never rewrites an existing
// licence — the same rule as workflow_runs.steps_snapshot.
//
// It holds NO Paddle identifiers. Every price ID lives in `catalogue`, because a
// metered plan is priced by several components and a single map on this row
// cannot express that.
type Plan struct {
	ID         bson.ObjectID `bson:"_id,omitempty" json:"-"`
	Deployment string        `bson:"deployment" json:"deployment"`
	Tier       string        `bson:"tier" json:"tier"`
	Name       string        `bson:"name" json:"name"`
	// BaseLimits is the allowance before anything is bought. The field is named
	// `base_` rather than `limits` because that is a different claim from the one
	// the old field made, and a reader must not assume it is the total.
	BaseLimits   license.Limits `bson:"base_limits" json:"base_limits"`
	BaseFeatures Features       `bson:"base_features" json:"base_features"`
	SupportLevel string         `bson:"support_level" json:"support_level"`
	Active       bool           `bson:"active" json:"active"`
}
```

- [ ] **Step 2: Seed six plans and re-sign GetPlan**

Replace `SeedPlans` and `GetPlan` in `admin/internal/models/plans.go`:

```go
// SeedPlans inserts the six (deployment, tier) rows from shared/license on first
// boot.
//
// It uses $setOnInsert only: once a plan exists, staff edits to allowances,
// features and support level are authoritative and a redeploy must not stamp
// over them.
func SeedPlans(ctx context.Context) error {
	for _, deployment := range license.Deployments() {
		for _, tier := range license.Tiers() {
			p, ok := license.PlanFor(deployment, tier)
			if !ok {
				continue
			}
			_, err := db.Admin("plans").UpdateOne(ctx,
				bson.M{"deployment": deployment, "tier": tier},
				bson.M{"$setOnInsert": bson.M{
					"deployment":    p.Deployment,
					"tier":          p.Tier,
					"name":          p.Name,
					"base_limits":   p.Limits,
					"base_features": Features(p.Features).OrEmpty(),
					"support_level": p.SupportLevel,
					"active":        true,
				}},
				options.UpdateOne().SetUpsert(true))
			if err != nil {
				return err
			}
		}
	}
	return nil
}

// GetPlan reads one pair's authoritative definition.
//
// It normalises the tier first, so a legacy self_hosted licence being reissued
// resolves to the plan that replaced it.
func GetPlan(ctx context.Context, deployment, tier string) (*Plan, error) {
	deployment, tier = license.NormaliseTier(deployment, tier)
	var p Plan
	if err := db.Admin("plans").FindOne(ctx,
		bson.M{"deployment": deployment, "tier": tier}).Decode(&p); err != nil {
		return nil, err
	}
	return &p, nil
}
```

- [ ] **Step 3: Create the catalogue document and its seed**

Create `admin/internal/models/catalogue.go`:

```go
package models

import (
	"context"

	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

// Component kinds.
const (
	// KindBase is the plan's own fee, always quantity 1.
	KindBase = "base"
	// KindLimit raises a named limit by one per unit of quantity.
	KindLimit = "limit"
	// KindFeature is an on/off feature key.
	KindFeature = "feature"
)

// LimitKeyServers is the only metered limit today.
//
// A limit_key is a field name in license.Limits, which is what lets a second
// metered dimension be a catalogue row rather than a code change. There is
// deliberately no block size: with secret-group blocks dropped from the spec it
// would be 1 in every row that will ever exist.
const LimitKeyServers = "max_servers"

// CatalogueRow is one priceable component of one plan.
//
// This is the ONLY place a Paddle price ID appears anywhere in Vantage. An empty
// PriceIDs means the component is free — a feature with no price is a toggle a
// customer may take at no charge, and giving it a price later is a staff edit
// rather than a migration or a deploy.
type CatalogueRow struct {
	ID         bson.ObjectID `bson:"_id,omitempty" json:"-"`
	Kind       string        `bson:"kind" json:"kind"`
	Deployment string        `bson:"deployment" json:"deployment"`
	Tier       string        `bson:"tier" json:"tier"`
	LimitKey   string        `bson:"limit_key,omitempty" json:"limit_key,omitempty"`
	FeatureKey string        `bson:"feature_key,omitempty" json:"feature_key,omitempty"`
	// PriceIDs is environment -> term -> Paddle price ID, e.g.
	// {"sandbox": {"monthly": "pri_…"}, "production": {"annual": "pri_…"}}.
	//
	// Nested by environment rather than kept in two collections, because
	// promoting sandbox to production must be a configuration change and not a
	// data migration. The running PADDLE_ENV picks the inner map.
	PriceIDs map[string]map[string]string `bson:"price_ids,omitempty" json:"price_ids,omitempty"`
}

// PriceID returns the price for one environment and term, or "".
func (r CatalogueRow) PriceID(env, term string) string {
	if r.PriceIDs == nil {
		return ""
	}
	return r.PriceIDs[env][term]
}

// Priced reports whether this component costs anything in an environment.
func (r CatalogueRow) Priced(env string) bool {
	for _, term := range []string{"monthly", "annual"} {
		if r.PriceID(env, term) != "" {
			return true
		}
	}
	return false
}

// SeedCatalogue inserts the sixteen rows the four PAID plans need.
//
// The two Free plans get no rows at all, and that absence is what keeps Free
// outside Paddle: with nothing to price, no checkout can be built for it. Do not
// "fix" this by adding zero-priced Free rows.
//
// $setOnInsert only, for the same reason as SeedPlans: the price IDs are pasted
// in by staff and a redeploy must not blank them.
func SeedCatalogue(ctx context.Context) error {
	paid := []string{license.TierProfessional, license.TierEnterprise}
	for _, deployment := range license.Deployments() {
		for _, tier := range paid {
			rows := []CatalogueRow{
				{Kind: KindBase, Deployment: deployment, Tier: tier},
				{Kind: KindLimit, Deployment: deployment, Tier: tier, LimitKey: LimitKeyServers},
				{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureConsole},
				{Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureOIDC},
			}
			for _, r := range rows {
				filter := bson.M{
					"kind":        r.Kind,
					"deployment":  r.Deployment,
					"tier":        r.Tier,
					"limit_key":   r.LimitKey,
					"feature_key": r.FeatureKey,
				}
				if _, err := db.Admin("catalogue").UpdateOne(ctx, filter,
					bson.M{"$setOnInsert": bson.M{
						"kind":        r.Kind,
						"deployment":  r.Deployment,
						"tier":        r.Tier,
						"limit_key":   r.LimitKey,
						"feature_key": r.FeatureKey,
						"price_ids":   map[string]map[string]string{},
					}},
					options.UpdateOne().SetUpsert(true)); err != nil {
					return err
				}
			}
		}
	}
	return nil
}

// CatalogueFor returns every component of one plan.
func CatalogueFor(ctx context.Context, deployment, tier string) ([]CatalogueRow, error) {
	deployment, tier = license.NormaliseTier(deployment, tier)
	cur, err := db.Admin("catalogue").Find(ctx,
		bson.M{"deployment": deployment, "tier": tier})
	if err != nil {
		return nil, err
	}
	rows := []CatalogueRow{}
	if err := cur.All(ctx, &rows); err != nil {
		return nil, err
	}
	return rows, nil
}

// AllCatalogue returns every row, for the staff editor.
func AllCatalogue(ctx context.Context) ([]CatalogueRow, error) {
	cur, err := db.Admin("catalogue").Find(ctx, bson.M{})
	if err != nil {
		return nil, err
	}
	rows := []CatalogueRow{}
	if err := cur.All(ctx, &rows); err != nil {
		return nil, err
	}
	return rows, nil
}
```

- [ ] **Step 4: Create the entitlement document**

Create `admin/internal/models/entitlements.go`:

```go
package models

import (
	"context"
	"errors"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

// ErrNoEntitlement means the instance has no configuration row.
//
// Callers fall back to the plan's base rather than failing: staff manual
// issuance and any instance predating the backfill legitimately have none.
var ErrNoEntitlement = errors.New("instance has no entitlement")

// Config is one side of an entitlement — a complete statement of what an
// instance is allowed.
//
// Servers is the TOTAL the customer sees, not the number of units billed. The
// billed quantity is Servers minus the plan's base allowance, and it is computed
// where the line items are built rather than stored, so the two can never
// disagree about which of them included the base.
type Config struct {
	Servers  int      `bson:"servers" json:"servers"`
	Features Features `bson:"features" json:"features"`
}

// Entitlement is what one instance's customer configured.
//
// Both the subscription and the licence are derived from it; it is derived from
// nothing. Desired is what they last asked for; Granted is what a payment
// confirmed. A licence is only ever signed from Granted, so an abandoned
// checkout leaves a Desired that reached nothing.
type Entitlement struct {
	ID         bson.ObjectID `bson:"_id,omitempty" json:"-"`
	InstanceID string        `bson:"instance_id" json:"instance_id"`
	AccountID  string        `bson:"account_id" json:"account_id"`
	Deployment string        `bson:"deployment" json:"deployment"`
	Tier       string        `bson:"tier" json:"tier"`
	Term       string        `bson:"term" json:"term"`

	Desired Config `bson:"desired" json:"desired"`
	Granted Config `bson:"granted" json:"granted"`

	// ResolvedLimits is BaseLimits with Granted folded in. It is stored rather
	// than derived on read so the fold lives in exactly one place — deriving it
	// at every read would put the arithmetic in the issuer, the portal and the
	// staff console.
	ResolvedLimits license.Limits `bson:"resolved_limits" json:"resolved_limits"`

	// ScheduledChangeAt is when a pending REDUCTION takes effect. It is set only
	// when Desired grants less than Granted, and it is what lets the portal say
	// "drops to 5 on 12 August" instead of guessing.
	ScheduledChangeAt *time.Time `bson:"scheduled_change_at,omitempty" json:"scheduled_change_at,omitempty"`

	GrantedAt time.Time `bson:"granted_at" json:"granted_at"`
	UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}

// Pending reports whether Desired and Granted disagree.
func (e Entitlement) Pending() bool {
	if e.Desired.Servers != e.Granted.Servers {
		return true
	}
	if len(e.Desired.Features) != len(e.Granted.Features) {
		return true
	}
	have := map[string]bool{}
	for _, f := range e.Granted.Features {
		have[f] = true
	}
	for _, f := range e.Desired.Features {
		if !have[f] {
			return true
		}
	}
	return false
}

// GetEntitlement reads one instance's configuration.
func GetEntitlement(ctx context.Context, instanceID string) (*Entitlement, error) {
	var e Entitlement
	err := db.Admin("entitlements").FindOne(ctx,
		bson.M{"instance_id": instanceID}).Decode(&e)
	if errors.Is(err, mongo.ErrNoDocuments) {
		return nil, ErrNoEntitlement
	}
	if err != nil {
		return nil, err
	}
	return &e, nil
}

// UpsertEntitlement writes an entitlement, creating it if absent.
//
// GrantedAt is only touched when Granted actually changes, which is what makes
// it answer "since when has this instance been allowed this" rather than "when
// was this row last written".
func UpsertEntitlement(ctx context.Context, e Entitlement) error {
	now := time.Now().UTC()
	set := bson.M{
		"account_id":      e.AccountID,
		"deployment":      e.Deployment,
		"tier":            e.Tier,
		"term":            e.Term,
		"desired":         e.Desired,
		"granted":         e.Granted,
		"resolved_limits": e.ResolvedLimits,
		"updated_at":      now,
	}
	if e.ScheduledChangeAt != nil {
		set["scheduled_change_at"] = *e.ScheduledChangeAt
	}
	if !e.GrantedAt.IsZero() {
		set["granted_at"] = e.GrantedAt
	} else {
		set["granted_at"] = now
	}
	update := bson.M{"$set": set}
	if e.ScheduledChangeAt == nil {
		update["$unset"] = bson.M{"scheduled_change_at": ""}
	}
	_, err := db.Admin("entitlements").UpdateOne(ctx,
		bson.M{"instance_id": e.InstanceID},
		mergeSetOnInsert(update, bson.M{"instance_id": e.InstanceID}),
		options.UpdateOne().SetUpsert(true))
	return err
}

// mergeSetOnInsert adds a $setOnInsert clause without clobbering an existing one.
func mergeSetOnInsert(update bson.M, onInsert bson.M) bson.M {
	update["$setOnInsert"] = onInsert
	return update
}
```

- [ ] **Step 5: Move the indexes**

In `admin/internal/db/db.go`, remove `{"plans", "tier"}` from the `unique` slice. Then add, immediately after the `subscriptions` sparse index block:

```go
	// plans was unique on tier alone until spec 7. Mongo will not replace an
	// index implicitly, and the old one would refuse the second row of every
	// tier, so it is dropped by name here. Dropping a missing index is not an
	// error worth failing boot over — a fresh database has never had it.
	if _, err := Admin("plans").Indexes().DropOne(ctx, "tier_unique"); err != nil {
		log.Printf("index plans.tier_unique: not dropped (%v); expected on a fresh database", err)
	}

	for _, u := range []struct {
		coll string
		keys bson.D
		name string
	}{
		{"plans", bson.D{{Key: "deployment", Value: 1}, {Key: "tier", Value: 1}}, "deployment_tier_unique"},
		{"catalogue", bson.D{
			{Key: "deployment", Value: 1}, {Key: "tier", Value: 1}, {Key: "kind", Value: 1},
			{Key: "limit_key", Value: 1}, {Key: "feature_key", Value: 1},
		}, "component_unique"},
		{"entitlements", bson.D{{Key: "instance_id", Value: 1}}, "instance_id_unique"},
	} {
		if _, err := Admin(u.coll).Indexes().CreateOne(ctx, mongo.IndexModel{
			Keys:    u.keys,
			Options: options.Index().SetUnique(true).SetName(u.name),
		}); err != nil {
			return fmt.Errorf("index %s.%s: %w", u.coll, u.name, err)
		}
	}
```

Add `"log"` to that file's imports.

- [ ] **Step 6: Extend the backfill**

In `admin/internal/models/backfill.go`, append three passes to `Backfill` before its final `return nil`, and add `"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"` if not already imported (it is):

```go
	// Pass 3: plans were keyed on tier alone. Give the two cloud tiers their
	// deployment, and turn the self_hosted TIER row into the self-hosted
	// Professional row it always was. Field names move too: limits/features
	// become base_limits/base_features, because "base" is a different claim.
	if _, err := db.Admin("plans").UpdateMany(ctx,
		bson.M{"deployment": bson.M{"$exists": false},
			"tier": bson.M{"$in": bson.A{license.TierFree, license.TierProfessional}}},
		bson.M{"$set": bson.M{"deployment": license.DeploymentCloud}}); err != nil {
		return err
	}
	if _, err := db.Admin("plans").UpdateOne(ctx,
		bson.M{"tier": license.TierSelfHosted},
		bson.M{"$set": bson.M{
			"deployment": license.DeploymentSelfHosted,
			"tier":       license.TierProfessional,
			"name":       "Professional",
		}}); err != nil {
		return err
	}
	if _, err := db.Admin("plans").UpdateMany(ctx,
		bson.M{"limits": bson.M{"$exists": true}},
		bson.M{"$rename": bson.M{"limits": "base_limits", "features": "base_features"}}); err != nil {
		return err
	}
	// Support level is new, so nothing has one. Fill from the seed table rather
	// than guessing: a plan row a human edited keeps every other field.
	for _, deployment := range license.Deployments() {
		for _, tier := range license.Tiers() {
			p, ok := license.PlanFor(deployment, tier)
			if !ok {
				continue
			}
			if _, err := db.Admin("plans").UpdateOne(ctx,
				bson.M{"deployment": deployment, "tier": tier,
					"support_level": bson.M{"$in": bson.A{nil, ""}}},
				bson.M{"$set": bson.M{"support_level": p.SupportLevel}}); err != nil {
				return err
			}
		}
	}

	// Pass 4: instances carrying the self_hosted TIER move to Professional.
	// Their deployment already says self_hosted, so only the tier is wrong.
	res, err = db.Admin("admin_instances").UpdateMany(ctx,
		bson.M{"tier": license.TierSelfHosted},
		bson.M{"$set": bson.M{"tier": license.TierProfessional}})
	if err != nil {
		return err
	}
	if res.ModifiedCount > 0 {
		log.Printf("backfill: re-tiered %d self-hosted instances to professional", res.ModifiedCount)
	}

	// Pass 5: give every instance an entitlement, reconstructed from its current
	// licence. Filtering on the absence of a row is what makes this idempotent,
	// and it means an entitlement a customer has since edited is never
	// overwritten by a stale licence.
	if err := backfillEntitlements(ctx); err != nil {
		return err
	}
```

Then append the helper to the same file:

```go
// backfillEntitlements reconstructs an entitlement per instance from its licence.
//
// An Unlimited max_servers maps back to the plan's BASE allowance rather than to
// a huge number: an unlimited licence bought no server units, so the honest
// reconstruction of "how many did they pay for" is none. This makes a
// pre-metering Professional instance read as 3 servers, which is a REDUCTION in
// what it is allowed. That is deliberate and it is why this is a plan step and
// not a silent fix — see the task's confirmation step.
func backfillEntitlements(ctx context.Context) error {
	cur, err := db.Admin("admin_instances").Find(ctx,
		bson.M{"status": bson.M{"$ne": StatusDeleted}})
	if err != nil {
		return err
	}
	var instances []Instance
	if err := cur.All(ctx, &instances); err != nil {
		return err
	}

	created := 0
	for _, inst := range instances {
		n, err := db.Admin("entitlements").CountDocuments(ctx,
			bson.M{"instance_id": inst.InstanceID})
		if err != nil {
			return err
		}
		if n > 0 {
			continue
		}

		deployment, tier := license.NormaliseTier(inst.Deployment, inst.Tier)
		if tier == "" {
			// An instance awaiting its first licence has no tier. It gets an
			// entitlement when one is issued, not before.
			continue
		}
		plan, err := GetPlan(ctx, deployment, tier)
		if err != nil {
			log.Printf("backfill: instance %s names unknown plan %s/%s; skipped",
				inst.InstanceID, deployment, tier)
			continue
		}

		cfg := Config{Servers: plan.BaseLimits.MaxServers, Features: Features{}}
		if inst.CurrentLicense != "" {
			var lic License
			if err := db.Admin("licenses").FindOne(ctx,
				bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err == nil {
				if lic.Limits.MaxServers != license.Unlimited && lic.Limits.MaxServers > 0 {
					cfg.Servers = lic.Limits.MaxServers
				}
				cfg.Features = lic.Features.OrEmpty()
			}
		}

		limits := plan.BaseLimits
		limits.MaxServers = cfg.Servers
		if err := UpsertEntitlement(ctx, Entitlement{
			InstanceID:     inst.InstanceID,
			AccountID:      inst.AccountID,
			Deployment:     deployment,
			Tier:           tier,
			Term:           defaultTerm(deployment),
			Desired:        cfg,
			Granted:        cfg,
			ResolvedLimits: limits,
		}); err != nil {
			return err
		}
		created++
	}
	if created > 0 {
		log.Printf("backfill: created %d entitlements from current licences", created)
	}
	return nil
}

// defaultTerm is the term to assume for a reconstructed entitlement. Self-hosted
// sells annual only, so there is nothing to guess there.
func defaultTerm(deployment string) string {
	if deployment == license.DeploymentSelfHosted {
		return "annual"
	}
	return "monthly"
}
```

- [ ] **Step 7: Seed the catalogue at boot**

In `admin/cmd/main.go`, find the existing `models.SeedPlans` call and add the catalogue seed directly after it, sharing its error handling style:

```go
	if err := models.SeedCatalogue(ctx); err != nil {
		log.Fatalf("seed catalogue: %v", err)
	}
```

**Order matters:** `SeedPlans` before `SeedCatalogue` before `Backfill`. The backfill's pass 5 calls `GetPlan`, which needs the six rows to exist and to have been re-keyed by pass 3 — and pass 3 runs inside `Backfill`, after both seeds. Confirm the existing call order in `main.go` is seeds first, then `Backfill`; if it is not, move `Backfill` after them.

- [ ] **Step 8: Confirm it builds and the shapes are right**

```bash
GOWORK=off /tmp/gorun.sh admin go build ./...
```

Expected: no output. Task 1's deliberate failure is now resolved.

```bash
GOWORK=off /tmp/gorun.sh admin go vet ./...
```

Expected: no output.

Confirm nothing still reads the deleted Paddle fields on `Plan`:

```bash
grep -rn "PaddlePriceIDs\|PaddleProductID" admin/ adminsite/
```

Expected: hits **only** in `admin/internal/api/staff.go` (task 6 removes them) and `adminsite/` (task 7). Anything in `models/` means the struct edit was incomplete.

- [ ] **Step 9: Verify the migration against a copy of live data**

This pass rewrites plan and instance rows, so do it against a **restored copy**, never live.

```bash
mongorestore --uri "$SCRATCH_ADMIN_URI" --drop --archive=/path/to/admin-dump.archive
```

Run admin against the scratch database (the compose/scratch procedure from plan 3), then:

```bash
mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \
  'db.plans.find({},{deployment:1,tier:1,support_level:1,"base_limits.max_servers":1,_id:0}).toArray()'
```

Expected: **exactly six** rows; `(cloud,free)`, `(cloud,professional)`, `(cloud,enterprise)`, `(self_hosted,free)`, `(self_hosted,professional)`, `(self_hosted,enterprise)`; every one with a non-empty `support_level`; **no row with tier `self_hosted`**; `base_limits.max_servers` of 3, 3, 10, 3, 3, 10.

```bash
mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \
  'db.plans.find({limits:{$exists:true}}).count()'
```

Expected: `0` — the rename left nothing behind.

```bash
mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \
  'db.catalogue.countDocuments({})'
```

Expected: `16`.

```bash
mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \
  'db.catalogue.countDocuments({tier:"free"})'
```

Expected: `0` — Free is priced by nothing, on purpose.

```bash
mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \
  'db.entitlements.find({},{instance_id:1,tier:1,"granted.servers":1,"granted.features":1,_id:0}).toArray()'
```

Expected: one row per non-deleted instance that has a tier. **Read the `granted.servers` values.** Every pre-metering Professional or self-hosted instance will show the plan base (3), because its licence said Unlimited and an unlimited licence bought no units.

**This is the sharp edge, and it needs a human decision before production.** Those instances are allowed fewer servers than they run the moment they are reissued. Existing licences are not reissued by this plan, so nothing breaks today, but the first renewal would cap them. Before deploying to production, list them:

```bash
mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \
  'db.licenses.find({"limits.max_servers":-1,superseded_by:{$exists:false}},{instance_id:1,tier:1,_id:0}).toArray()'
```

For each, count the servers actually registered in the control plane and set `granted.servers` to at least that number by hand through the staff entitlement endpoint (task 6). Record what you set and why. **Do not automate this** — the right number is a commercial question about what each customer is entitled to, not a maximum to be inferred.

Confirm the index swap took:

```bash
mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \
  'db.plans.getIndexes().map(i => i.name)'
```

Expected: includes `deployment_tier_unique`, excludes `tier_unique`.

Restart admin once more and confirm idempotency:

```bash
mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \
  'print(db.plans.countDocuments({}), db.catalogue.countDocuments({}), db.entitlements.countDocuments({}))'
```

Expected: identical numbers to the first run.

- [ ] **Step 10: Commit**

```bash
git add admin/internal/models admin/internal/db/db.go admin/cmd/main.go
git commit -m "feat(admin): plans re-keyed on (deployment, tier), plus catalogue and entitlements

plans holds base allowances and no Paddle identifiers at all — a metered
plan is priced by several components and one map on the plan row cannot
express that, so every price ID moves to the catalogue collection.

entitlements holds desired beside granted, one row per instance. A licence
is only ever signed from granted, so an abandoned checkout leaves a desired
that reached nothing.

The two Free plans get no catalogue rows. That absence is what keeps Free
outside Paddle: with nothing to price, no checkout can be built for it.

plans.tier_unique is dropped by name; Mongo will not replace an index
implicitly and the old one refuses the second row of every tier.

The entitlement backfill maps an Unlimited max_servers back to the plan
base, because an unlimited licence bought no server units. That under-
states what pre-metering customers run, deliberately and visibly, so the
number is set by a human before production rather than inferred.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 3: The catalogue package — both folds

**Files:**

- Create: `admin/internal/catalogue/catalogue.go`, `admin/internal/catalogue/items.go`

**Interfaces:**

- Consumes: `models.CatalogueFor`, `models.CatalogueRow`, `models.Plan`, `models.Config`, `models.KindBase/KindLimit/KindFeature`, `license.Limits`, `license.TermsFor`.
- Produces:
    - `catalogue.Resolve(ctx context.Context, plan *models.Plan, cfg models.Config) (license.Limits, []string, error)`
    - `catalogue.Item` with `PriceID string`, `Quantity int`
    - `catalogue.LineItems(ctx context.Context, env, term string, plan *models.Plan, cfg models.Config) ([]Item, error)`
    - `catalogue.Match` with `Deployment`, `Tier`, `Term string`, `Servers int`, `Features []string`
    - `catalogue.ResolveItems(ctx context.Context, env string, items []Item) (Match, error)`
    - `catalogue.ErrUnknownPrice`, `ErrNoBaseItem`, `ErrTermNotSold`, `ErrUnpriced`

- [ ] **Step 1: Write the fold to a licence**

Create `admin/internal/catalogue/catalogue.go`:

```go
// Package catalogue turns an entitlement into the two things derived from it:
// the limits and features a licence grants, and the Paddle line items a
// subscription is made of.
//
// Both folds live here so the arithmetic exists once. The temptation is to
// compute limits in the issuer and quantities in the checkout, and then the two
// disagree about whether the base allowance is included in the number — which is
// a bug that bills a customer for three servers they were given.
package catalogue

import (
	"context"
	"errors"
	"fmt"

	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)

var (
	// ErrUnknownPrice means an item named a price ID no catalogue row claims.
	//
	// This is always a configuration error and never a customer error: someone
	// bought something at a price we cannot map to a plan. It must fail loudly
	// rather than guess a tier — a guessed tier is a wrong licence with no
	// record of why.
	ErrUnknownPrice = errors.New("no catalogue row claims that price ID")

	// ErrNoBaseItem means no item matched a base row, so the subscription names
	// no plan. Quantities are meaningless without one.
	ErrNoBaseItem = errors.New("no item matches a base price; the subscription names no plan")

	// ErrTermNotSold means a price resolved to a term its deployment does not
	// sell — in practice a self-hosted monthly price.
	ErrTermNotSold = errors.New("that deployment does not sell that term")

	// ErrUnpriced means a component needed for this configuration has no price
	// ID in this environment. Refusing is correct: a checkout that silently
	// drops a paid line item gives away the thing it was meant to charge for.
	ErrUnpriced = errors.New("component has no price in this environment")
)

// Resolve folds a configuration into the limits and features a licence grants.
//
// Limits start at the plan's base and each metered component adds its configured
// amount. Features are the plan's base features plus the configured ones,
// deduplicated and filtered to keys the catalogue actually offers — a stale
// feature key in a stored entitlement must not survive into a signed payload.
func Resolve(ctx context.Context, plan *models.Plan, cfg models.Config) (license.Limits, []string, error) {
	rows, err := models.CatalogueFor(ctx, plan.Deployment, plan.Tier)
	if err != nil {
		return license.Limits{}, nil, err
	}

	limits := plan.BaseLimits
	offered := map[string]bool{}
	for _, r := range rows {
		switch r.Kind {
		case models.KindLimit:
			if err := addLimit(&limits, r.LimitKey, configured(cfg, r.LimitKey), plan.BaseLimits); err != nil {
				return license.Limits{}, nil, err
			}
		case models.KindFeature:
			offered[r.FeatureKey] = true
		}
	}

	seen := map[string]bool{}
	features := []string{}
	for _, f := range plan.BaseFeatures {
		if !seen[f] {
			seen[f] = true
			features = append(features, f)
		}
	}
	for _, f := range cfg.Features {
		if seen[f] || !offered[f] {
			continue
		}
		seen[f] = true
		features = append(features, f)
	}
	return limits, features, nil
}

// configured reads the configured total for one metered limit key.
//
// A switch rather than reflection, so every metered dimension is greppable and
// adding one is a visible edit here as well as a catalogue row.
func configured(cfg models.Config, limitKey string) int {
	switch limitKey {
	case models.LimitKeyServers:
		return cfg.Servers
	default:
		return 0
	}
}

// addLimit sets a metered limit to its configured total.
//
// The configured value is a TOTAL, not an increment, so this assigns rather than
// adds. A base of Unlimited is left alone: nothing can be added to no cap, and a
// plan that meters an already-unlimited dimension is a configuration mistake
// rather than something to compute around.
func addLimit(l *license.Limits, limitKey string, total int, base license.Limits) error {
	switch limitKey {
	case models.LimitKeyServers:
		if base.MaxServers == license.Unlimited {
			return nil
		}
		if total > base.MaxServers {
			l.MaxServers = total
		}
		return nil
	case "":
		return fmt.Errorf("catalogue limit row has no limit_key")
	default:
		return fmt.Errorf("%w: limit_key %q", ErrUnknownPrice, limitKey)
	}
}
```

- [ ] **Step 2: Write the line-item folds, forward and reverse**

Create `admin/internal/catalogue/items.go`:

```go
package catalogue

import (
	"context"
	"fmt"

	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)

// Item is one Paddle line item: a price and how many of it.
type Item struct {
	PriceID  string `json:"price_id"`
	Quantity int    `json:"quantity"`
}

// LineItems builds the subscription items for a configuration.
//
// The base row is always quantity 1. A metered row's quantity is the configured
// TOTAL minus the plan's base allowance, so a Professional customer at exactly
// three servers has a single-item subscription rather than one with a zero
// quantity Paddle would reject. A feature with no price in this environment
// produces no item and is granted free.
func LineItems(ctx context.Context, env, term string, plan *models.Plan, cfg models.Config) ([]Item, error) {
	if !sells(plan.Deployment, term) {
		return nil, fmt.Errorf("%w: %s does not sell %s", ErrTermNotSold, plan.Deployment, term)
	}
	rows, err := models.CatalogueFor(ctx, plan.Deployment, plan.Tier)
	if err != nil {
		return nil, err
	}
	if len(rows) == 0 {
		return nil, fmt.Errorf("%w: %s/%s is priced by nothing",
			ErrUnpriced, plan.Deployment, plan.Tier)
	}

	wanted := map[string]bool{}
	for _, f := range cfg.Features {
		wanted[f] = true
	}

	items := []Item{}
	for _, r := range rows {
		switch r.Kind {
		case models.KindBase:
			id := r.PriceID(env, term)
			if id == "" {
				return nil, fmt.Errorf("%w: base price for %s/%s in %s",
					ErrUnpriced, plan.Deployment, plan.Tier, env)
			}
			items = append(items, Item{PriceID: id, Quantity: 1})

		case models.KindLimit:
			qty := billable(cfg, r.LimitKey, plan.BaseLimits)
			if qty <= 0 {
				continue
			}
			id := r.PriceID(env, term)
			if id == "" {
				return nil, fmt.Errorf("%w: %s price for %s/%s in %s",
					ErrUnpriced, r.LimitKey, plan.Deployment, plan.Tier, env)
			}
			items = append(items, Item{PriceID: id, Quantity: qty})

		case models.KindFeature:
			if !wanted[r.FeatureKey] {
				continue
			}
			id := r.PriceID(env, term)
			if id == "" {
				// Free to toggle. Resolve() still grants it.
				continue
			}
			items = append(items, Item{PriceID: id, Quantity: 1})
		}
	}
	return items, nil
}

// billable is how many UNITS to charge for a metered dimension.
//
// The configured value is the total the customer sees, which includes the base
// allowance they were given. Charging for that base is the single most likely
// bug in this file, so the subtraction lives here and nowhere else.
func billable(cfg models.Config, limitKey string, base license.Limits) int {
	switch limitKey {
	case models.LimitKeyServers:
		if base.MaxServers == license.Unlimited {
			return 0
		}
		return cfg.Servers - base.MaxServers
	default:
		return 0
	}
}

// Match is what an item list says about itself.
type Match struct {
	Deployment string
	Tier       string
	Term       string
	Servers    int
	Features   []string
}

// ResolveItems maps a full item list back to a plan and a configuration.
//
// This replaces a price-ID-to-tier lookup, which cannot work once a subscription
// has several prices. The base item identifies the plan and the term; everything
// else is read relative to it. An item matching nothing fails the whole list.
//
// Only the running environment's IDs are consulted, so a production process
// cannot be talked into resolving a sandbox price by a forged or misrouted
// event.
//
// It is a function of the COMPLETE list, which is what keeps out-of-order
// delivery correct by construction: Paddle sends every item on every
// subscription event, so reading all of them is reading current state rather
// than a transition.
func ResolveItems(ctx context.Context, env string, items []Item) (Match, error) {
	all, err := models.AllCatalogue(ctx)
	if err != nil {
		return Match{}, err
	}

	// Pass 1: find the base item. Until we know the plan, no other item means
	// anything — a quantity of 7 is 7 of what?
	var m Match
	found := false
	for _, it := range items {
		for _, r := range all {
			if r.Kind != models.KindBase {
				continue
			}
			for _, term := range []string{"monthly", "annual"} {
				if r.PriceID(env, term) != it.PriceID || it.PriceID == "" {
					continue
				}
				if found {
					return Match{}, fmt.Errorf(
						"item list names two plans: %s/%s and %s/%s",
						m.Deployment, m.Tier, r.Deployment, r.Tier)
				}
				m.Deployment, m.Tier, m.Term = r.Deployment, r.Tier, term
				found = true
			}
		}
	}
	if !found {
		return Match{}, ErrNoBaseItem
	}
	if !sells(m.Deployment, m.Term) {
		return Match{}, fmt.Errorf("%w: price resolves to %s %s; remove it from the catalogue",
			ErrTermNotSold, m.Deployment, m.Term)
	}

	plan, err := models.GetPlan(ctx, m.Deployment, m.Tier)
	if err != nil {
		return Match{}, fmt.Errorf("item list names plan %s/%s, which does not exist: %w",
			m.Deployment, m.Tier, err)
	}
	m.Servers = plan.BaseLimits.MaxServers
	m.Features = []string{}

	// Pass 2: everything else, relative to that plan. An item matching no row of
	// this plan is a configuration error even if it matches some other plan's
	// row — mixing two plans in one subscription is not a thing we sell.
	rows, err := models.CatalogueFor(ctx, m.Deployment, m.Tier)
	if err != nil {
		return Match{}, err
	}
	for _, it := range items {
		matched := false
		for _, r := range rows {
			if r.PriceID(env, m.Term) != it.PriceID {
				continue
			}
			matched = true
			switch r.Kind {
			case models.KindBase:
				// Already handled.
			case models.KindLimit:
				if r.LimitKey == models.LimitKeyServers {
					m.Servers = plan.BaseLimits.MaxServers + it.Quantity
				}
			case models.KindFeature:
				m.Features = append(m.Features, r.FeatureKey)
			}
		}
		if !matched {
			return Match{}, fmt.Errorf("%w: %s (environment %s, plan %s/%s)",
				ErrUnknownPrice, it.PriceID, env, m.Deployment, m.Tier)
		}
	}
	return m, nil
}

// sells reports whether a deployment offers a term.
func sells(deployment, term string) bool {
	for _, t := range license.TermsFor(deployment) {
		if t == term {
			return true
		}
	}
	return false
}
```

- [ ] **Step 3: Confirm it compiles and the folds agree**

```bash
GOWORK=off /tmp/gorun.sh admin go build ./...
```

Expected: no output.

```bash
GOWORK=off /tmp/gorun.sh admin go vet ./...
```

Expected: no output.

The base allowance must be included in exactly one of the two folds. Confirm the subtraction exists once:

```bash
grep -rn "BaseLimits.MaxServers" admin/internal/catalogue/
```

Expected: four hits — `addLimit`'s Unlimited guard and comparison in `catalogue.go`, `billable`'s guard and subtraction in `items.go`, plus the two reconstruction lines in `ResolveItems`. There must be **no** subtraction anywhere outside `billable`.

```bash
grep -rn "Servers - \|Servers-" admin/ --include=*.go
```

Expected: exactly one hit, in `billable`.

- [ ] **Step 4: Commit**

```bash
git add admin/internal/catalogue
git commit -m "feat(admin): the catalogue folds, entitlement to licence and to line items

Both folds live in one package because the base allowance has to be
included in exactly one of them. Computing limits in the issuer and
quantities in the checkout is how a customer gets billed for the three
servers their plan gave them; the subtraction now exists once, in billable.

ResolveItems replaces a price-to-tier lookup, which cannot work once a
subscription has several prices. The base item names the plan and the term
and everything else is read relative to it. It reads the complete item
list, which is what keeps out-of-order webhook delivery correct by
construction rather than by special case.

An item matching no row fails the whole list. A guessed tier is a wrong
licence with no record of why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 4: Issue from the entitlement

**Files:**

- Modify: `admin/internal/licensing/issue.go`, `admin/internal/api/customer.go`

**Interfaces:**

- Consumes: `models.GetEntitlement`, `models.ErrNoEntitlement`, `models.GetPlan(ctx, deployment, tier)`, `catalogue.Resolve`, `license.NormaliseTier`.
- Produces:
    - `licensing.Issue` snapshotting the entitlement
    - `licensing.ErrFreeLimit` scoped per deployment

- [ ] **Step 1: Resolve the plan by deployment and issue from the entitlement**

In `admin/internal/licensing/issue.go`, replace the plan lookup and the payload/record construction. Find:

```go
	plan, err := models.GetPlan(ctx, in.Tier)
	if err != nil {
		return nil, ErrUnknownTier
	}
```

and replace with:

```go
	// The plan is looked up by the INSTANCE's deployment, not by a caller's
	// guess. That is what makes the deployment comparison below a consistency
	// check rather than the thing that decides which plan applies.
	plan, err := models.GetPlan(ctx, inst.Deployment, in.Tier)
	if err != nil {
		return nil, ErrUnknownTier
	}
```

Then, after the `checkFreeLimit` block and before `now := time.Now().UTC()`, insert the entitlement resolution:

```go
	// What this licence grants comes from the instance's entitlement, not from
	// the plan. The plan is only the base.
	//
	// An instance with no entitlement gets the plan's base, which covers staff
	// manual issuance and anything predating the backfill. Falling back is
	// deliberate: refusing here would make a missing row an outage rather than a
	// default.
	limits, features := plan.BaseLimits, []string(plan.BaseFeatures.OrEmpty())
	ent, entErr := models.GetEntitlement(ctx, inst.InstanceID)
	switch {
	case entErr == nil:
		// Granted, never Desired. A configuration nobody has paid for must not
		// reach a signed payload.
		limits, features, err = catalogue.Resolve(ctx, plan, ent.Granted)
		if err != nil {
			return nil, fmt.Errorf("resolve entitlement: %w", err)
		}
	case errors.Is(entErr, models.ErrNoEntitlement):
		log.Printf("licensing: instance %s has no entitlement; issuing plan base",
			inst.InstanceID)
	default:
		return nil, fmt.Errorf("read entitlement: %w", entErr)
	}
```

Replace the two places that read `plan.Limits` and `plan.Features` — the `license.License` payload literal and the `models.License` record literal — so both read the resolved values:

```go
		// Snapshotted, not referenced: editing a plan or an entitlement tomorrow
		// must not change what this licence grants.
		Limits:       limits,
		Features:     features,
		SupportLevel: plan.SupportLevel,
```

and in the `models.License` literal:

```go
		Limits:   limits,
		Features: models.Features(features).OrEmpty(),
```

Add `"log"` and `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"` to the imports.

- [ ] **Step 2: Scope the Free limit per deployment**

Still in `issue.go`, replace `checkFreeLimit` and its call. The call becomes:

```go
	if plan.Tier == license.TierFree {
		if err := checkFreeLimit(ctx, inst.AccountID, inst.Deployment, inst.InstanceID); err != nil {
			return nil, err
		}
	}
```

And the function:

```go
// checkFreeLimit enforces one Free instance per account PER DEPLOYMENT.
//
// It used to be one per account, which was sufficient while Free existed only on
// cloud. With a self-hosted Free plan, an account-wide count would refuse a
// self-hosted Free instance to anyone holding a cloud one, and tell them about a
// limit they have not reached.
//
// Cancelled instances do not count: a customer who cancelled their Free instance
// is allowed another one.
func checkFreeLimit(ctx context.Context, accountID, deployment, exceptInstanceID string) error {
	n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{
		"account_id":  accountID,
		"deployment":  deployment,
		"tier":        license.TierFree,
		"status":      bson.M{"$ne": models.StatusCancelled},
		"instance_id": bson.M{"$ne": exceptInstanceID},
	})
	if err != nil {
		return err
	}
	if n > 0 {
		return ErrFreeLimit
	}
	return nil
}
```

And make the error name the deployment, since one account can now legitimately hold two Free instances:

```go
	ErrFreeLimit = errors.New("this account already has a Free instance of that deployment type")
```

- [ ] **Step 3: Scope the friendly pre-check the same way**

In `admin/internal/api/customer.go`'s `createInstance`, the pre-check must agree with the issuer or it will refuse things the issuer would allow. Add the deployment to its filter:

```go
	// Pre-check the Free rule so we never create an instance we then cannot
	// licence. licensing.Issue enforces it too; this is the friendly refusal.
	//
	// Scoped to cloud because that is what this endpoint creates. It MUST match
	// checkFreeLimit's scoping — a pre-check stricter than the issuer refuses
	// something that would have worked.
	n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{
		"account_id": s.AccountID,
		"deployment": license.DeploymentCloud,
		"tier":       license.TierFree,
		"status":     bson.M{"$ne": models.StatusCancelled},
	})
```

And its message:

```go
	if n > 0 {
		c.JSON(http.StatusConflict, gin.H{
			"error": "this account already has a Free cloud instance"})
		return
	}
```

- [ ] **Step 4: Confirm the snapshot actually comes from the entitlement**

```bash
GOWORK=off /tmp/gorun.sh admin go build ./...
```

Expected: no output.

Confirm nothing reads the plan's limits into a licence any more:

```bash
grep -n "plan.Limits\|plan.Features" admin/internal/licensing/issue.go
```

Expected: no output. Both were replaced; `plan.BaseLimits` and `plan.BaseFeatures` appear only in the fallback assignment.

Confirm `Desired` is never read by the issuer:

```bash
grep -rn "\.Desired" admin/internal/licensing/
```

Expected: no output. Issuing from `Desired` would grant something nobody paid for, and this grep is the guard.

Against a scratch database with admin running, set an entitlement by hand and issue:

```bash
mongosh "$SCRATCH_ADMIN_URI" --quiet --eval '
  db.entitlements.updateOne({instance_id:"<a cloud professional instance>"},
    {$set:{"granted.servers":9,"granted.features":["console"],
           "desired.servers":9,"desired.features":["console"]}})'
```

Then issue through the staff endpoint and read the licence back:

```bash
curl -s -b /tmp/staffcj -X POST \
  https://vantage-hq.hostxtra.co.uk/api/staff/instances/<id>/issue \
  -H 'Content-Type: application/json' -d '{"tier":"professional","term":"monthly"}'

mongosh "$SCRATCH_ADMIN_URI" --quiet --eval '
  db.licenses.find({instance_id:"<id>"},{"limits.max_servers":1,features:1,tier:1,_id:0})
    .sort({issued_at:-1}).limit(1).toArray()'
```

Expected: `limits.max_servers: 9`, `features: ["console"]`. If it reports `3`, the fallback is being taken — check the entitlement's `instance_id` matches.

Now confirm the Free rule per deployment. With an account holding a cloud Free instance, issue a Free licence against a self-hosted instance of the same account:
Expected: success. Before this task it would have been refused with `ErrFreeLimit`.

Then attempt a second cloud Free instance for that account:
Expected: refused, `409`, "already has a Free cloud instance".

- [ ] **Step 5: Commit**

```bash
git add admin/internal/licensing/issue.go admin/internal/api/customer.go
git commit -m "feat(admin): issue from the entitlement, and Free per deployment

A licence now snapshots the instance's entitlement rather than its plan.
The plan is only the base; the entitlement is what the customer configured
and paid for. An instance without one gets the base, so a missing row is a
default rather than an outage.

Granted, never Desired. Issuing from Desired would sign a configuration
nobody has paid for, and a grep for .Desired under licensing/ is the guard.

The Free limit is now per account AND deployment. Account-wide was
sufficient only while Free was cloud-only; with a self-hosted Free plan it
would refuse a self-hosted instance to anyone holding a cloud one and cite
a limit they had not reached. The friendly pre-check in createInstance is
scoped to match, because a pre-check stricter than the issuer refuses what
would have worked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 5: The control plane enforces every column

**Files:**

- Create: `server/internal/services/audit_retention.go`
- Modify: `server/internal/services/licence.go`, `server/internal/services/licence_limits.go`, `server/internal/api/licence.go`, `server/internal/api/monitors.go`, `server/internal/auth/oidc.go`, `server/cmd/main.go`

**Interfaces:**

- Consumes: `license.PlanFor`, `license.NormaliseTier`, `license.Limits.FillUnset`, `services.GetLicenseState`, `services.DeploymentMode`.
- Produces:
    - `services.CheckMonitorLimit(instanceID string) error`
    - `services.LicenseUsage` returning a fourth count
    - `services.StartAuditSweeper()`

- [ ] **Step 1: Fill unset limits at the single decode site**

In `server/internal/services/licence.go`, `stateFromResult` is the one place a payload becomes a `LicenseState`. Replace it:

```go
func stateFromResult(res license.Result, source string) LicenseState {
	feats := map[string]bool{}
	for _, f := range res.License.Features {
		feats[f] = true
	}

	// A licence signed before max_monitors and audit_retention_days existed
	// decodes them as 0, which would read as "no monitors" and "trim the audit
	// log to nothing". Fill from the seed plan for the tier the licence names.
	//
	// This is the only decode site, which is why the fill belongs here rather
	// than at each of the places that reads a limit.
	limits := res.License.Limits
	deployment, tier := license.NormaliseTier(res.License.Deployment, res.License.Tier)
	if base, ok := license.PlanFor(deployment, tier); ok {
		limits = limits.FillUnset(base.Limits)
	}

	s := LicenseState{
		Status:       res.State,
		Reason:       res.Reason,
		Tier:         res.License.Tier,
		SupportLevel: res.License.SupportLevel,
		Limits:       limits,
		Features:     feats,
		Source:       source,
	}
	if !res.License.ExpiresAt.IsZero() {
		exp := res.License.ExpiresAt
		s.ExpiresAt = &exp
	}
	return s
}
```

Add `SupportLevel` to the `LicenseState` struct, after `Tier`:

```go
	Tier         string        `json:"tier,omitempty"`
	SupportLevel string        `json:"support_level,omitempty"`
```

- [ ] **Step 2: Add the monitor cap**

In `server/internal/services/licence_limits.go`, add after `CheckChannelLimit`:

```go
// CheckMonitorLimit refuses a new monitor when the instance is at its cap.
//
// Counts live rows only, like every other check here. An instance already over
// its cap keeps every monitor it has and they keep executing — the licence
// expiry story is that monitoring never stops, so truncating here would
// contradict it.
func CheckMonitorLimit(instanceID string) error {
	st := GetLicenseState(instanceID)
	ctx, cancel := limitCtx()
	defer cancel()

	n, err := db.Col("monitors").CountDocuments(ctx, bson.M{"instance_id": instanceID})
	if err != nil {
		return err
	}
	if !license.WithinLimit(int(n), st.Limits.MaxMonitors) {
		return &LimitError{Limit: "max_monitors", Current: int(n), Max: st.Limits.MaxMonitors}
	}
	return nil
}
```

Replace `LicenseUsage` so it reports monitors too:

```go
// LicenseUsage reports current counts, so the UI can say "12 of 3 servers"
// honestly when an instance is over its cap rather than pretending.
func LicenseUsage(instanceID string) (servers, monitors, secretGroups, channels int) {
	ctx, cancel := limitCtx()
	defer cancel()

	if n, err := db.Col("servers").CountDocuments(ctx, bson.M{"instance_id": instanceID}); err == nil {
		servers = int(n)
	}
	if n, err := db.Col("monitors").CountDocuments(ctx, bson.M{"instance_id": instanceID}); err == nil {
		monitors = int(n)
	}
	var groups []string
	if err := db.Col("secrets").Distinct(ctx, "group",
		bson.M{"instance_id": instanceID}).Decode(&groups); err == nil {
		secretGroups = len(groups)
	}
	if n, err := db.Col("notification_channels").CountDocuments(ctx,
		bson.M{"instance_id": instanceID}); err == nil {
		channels = int(n)
	}
	return
}
```

- [ ] **Step 3: Enforce it on create, and report it**

In `server/internal/api/monitors.go`'s `createMonitor`, add the check after validation and before the service call:

```go
	if err := services.CheckMonitorLimit(auth.InstanceID(c)); err != nil {
		if limitStatus(c, err) {
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
```

In `server/internal/api/licence.go`, add monitors to the usage response and the two new fields to the licence response:

```go
type licenceUsageResponse struct {
	Servers      int `json:"servers"`
	Monitors     int `json:"monitors"`
	SecretGroups int `json:"secret_groups"`
	Channels     int `json:"channels"`
}
```

Add to `licenceResponse`, after `Tier`:

```go
	SupportLevel  string               `json:"support_level,omitempty"`
```

Then in `getLicence`, update the destructuring and both literals:

```go
	servers, monitors, groups, channels := services.LicenseUsage(instanceID)
```

and wherever `licenceUsageResponse` is constructed, add `Monitors: monitors,`; wherever `licenceResponse` is constructed, add `SupportLevel: st.SupportLevel,`.

- [ ] **Step 4: Gate the OIDC callback**

In `server/internal/auth/oidc.go`'s `HandleOIDCCallback`, add the check immediately after `ConsumeStateInstance` and before `providerForInstance`:

```go
	// The start handler checks this too, but an ungated callback is the half
	// that matters: a start that refuses is a dead end, while a callback that
	// completes signs somebody in. A licence that lapsed mid-flow stops the
	// exchange here rather than after it.
	//
	// Resolved from the consumed state rather than from the host, because on
	// this route the instance is whatever the state said and nobody is signed
	// in yet.
	if !services.GetLicenseState(instanceID).Feature("oidc") {
		c.Redirect(http.StatusFound, "/login?error=oidc_unavailable")
		return
	}
```

- [ ] **Step 5: Write the audit retention sweep**

Create `server/internal/services/audit_retention.go`:

```go
package services

import (
	"context"
	"log"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
	"go.mongodb.org/mongo-driver/v2/bson"
)

// StartAuditSweeper trims audit logs past their licensed retention.
//
// An immediate pass then daily, following StartLogSweeper's shape. Daily rather
// than hourly because the unit of retention is a day: sweeping twenty-four times
// to delete the same nothing is load without a purpose.
func StartAuditSweeper() {
	go func() {
		sweepAuditLogs()
		t := time.NewTicker(24 * time.Hour)
		defer t.Stop()
		for range t.C {
			sweepAuditLogs()
		}
	}()
}

// sweepAuditLogs deletes entries older than each instance's licensed retention.
//
// This is the only part of this subsystem that deletes customer data, so it is
// deliberately conservative in three ways.
//
// It reads the CURRENT licence each run rather than caching a value, so raising
// a customer's retention takes effect on the next sweep instead of whenever a
// process restarts.
//
// It skips an instance whose licence is not valid. A lapsed instance must not
// have its history trimmed on the expired term's allowance — expiry degrades to
// read-only, and deleting more of somebody's audit trail is not read-only.
//
// It skips Unlimited and any non-positive value. A licence that decodes as zero
// has already been filled from the plan base at the decode site, so a zero here
// means something is wrong and doing nothing is the right response to that.
func sweepAuditLogs() {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
	defer cancel()

	var ids []string
	if err := db.Col("instances").Distinct(ctx, "instance_id", bson.M{}).Decode(&ids); err != nil {
		log.Printf("audit sweep: list instances: %v", err)
		return
	}

	for _, id := range ids {
		st := GetLicenseState(id)
		if !st.Active() {
			continue
		}
		days := st.Limits.AuditRetentionDays
		if days == license.Unlimited || days <= 0 {
			continue
		}
		cutoff := time.Now().UTC().AddDate(0, 0, -days)
		res, err := db.Col("audit_logs").DeleteMany(ctx, bson.M{
			"instance_id": id,
			"created_at":  bson.M{"$lt": cutoff},
		})
		if err != nil {
			log.Printf("audit sweep: instance %s: %v", id, err)
			continue
		}
		if res.DeletedCount > 0 {
			log.Printf("audit sweep: instance %s: removed %d entries older than %d days",
				id, res.DeletedCount, days)
		}
	}
}
```

**Confirm the timestamp field name before running this.** The sweep filters on `created_at`; check the audit model actually uses it:

```bash
grep -n "bson:\"" server/internal/models/*.go | grep -i audit
```

If the field is named differently, use that name — a filter on a field that does not exist deletes **every** row, which is why this check is a step rather than an assumption.

- [ ] **Step 6: Start the sweeper**

In `server/cmd/main.go`, beside the existing `services.StartLogSweeper()` call:

```go
	services.StartAuditSweeper()
```

- [ ] **Step 7: Confirm enforcement**

```bash
GOWORK=off /tmp/gorun.sh server go build ./...
```

Expected: no output. If `LicenseUsage` callers were missed the compiler names them — it gained a return value specifically so they cannot be missed silently.

```bash
GOWORK=off /tmp/gorun.sh server go vet ./...
```

Expected: no output.

Against a running server with a Free licence (3 monitors), create four monitors:

```bash
for i in 1 2 3 4; do
  curl -s -o /dev/null -w "%{http_code}\n" -b /tmp/cj -X POST localhost:8080/api/monitors \
    -H 'Content-Type: application/json' \
    -d "{\"name\":\"m$i\",\"type\":\"http\",\"target\":\"https://example.com\"}"
done
```

Expected: `201 201 201 403`.

```bash
curl -s -b /tmp/cj -X POST localhost:8080/api/monitors \
  -H 'Content-Type: application/json' \
  -d '{"name":"m5","type":"http","target":"https://example.com"}' | python -m json.tool
```

Expected: `{"error": "limit_exceeded", "limit": "max_monitors", "current": 3, "max": 3}`.

Confirm the usage report:

```bash
curl -s -b /tmp/cj localhost:8080/api/license | python -m json.tool
```

Expected: `usage.monitors: 3`, `limits.max_monitors: 3`, `limits.audit_retention_days: 30`, and a `support_level` of `community`.

Confirm the fill works on an old blob. Take a licence signed before this change (any existing one) and store it:
Expected: `limits.max_monitors` reports `3` for Free rather than `0`, and `audit_retention_days` reports `30` rather than `0`. **A `0` here means the fill is not running** — check `NormaliseTier` is being applied to the licence's own deployment and tier.

Confirm the OIDC callback refuses. On an instance whose licence lacks `oidc`:

```bash
curl -si "localhost:8080/auth/oidc/callback?state=anything&code=anything" | head -3
```

Expected: `400` "invalid state" — the state check runs first, which is correct. To exercise the gate itself, start a real SSO flow on an instance that HAS the feature, then remove the feature from its licence before completing the callback:
Expected: `302` to `/login?error=oidc_unavailable`, and **no session cookie set**.

Confirm the sweep. Insert an old audit row and run the server:

```bash
mongosh "$SCRATCH_CONTROL_URI" --quiet --eval '
  db.audit_logs.insertOne({instance_id:"<id>", action:"test.old",
    created_at: new Date(Date.now() - 1000*60*60*24*400)})'
```

With a Free licence (30 days), restart the server and check the log line, then:

```bash
mongosh "$SCRATCH_CONTROL_URI" --quiet --eval \
  'db.audit_logs.countDocuments({instance_id:"<id>", action:"test.old"})'
```

Expected: `0`, and a log line naming the count.

Now the conservatism. Let the licence expire (or store an expired one), insert another old row, restart:
Expected: the row **survives**, and no sweep line for that instance. A lapsed instance is not swept.

- [ ] **Step 8: Commit**

```bash
git add server/internal/services server/internal/api server/internal/auth/oidc.go server/cmd/main.go
git commit -m "feat(server): enforce the monitor cap, audit retention, and the OIDC callback

Three of the pricing table's columns were enforced by nothing.

CheckMonitorLimit joins the three existing checks with the same shape:
refuse a new one at the cap, never truncate what exists. LicenseUsage
gained a return value so no caller could miss it silently.

The audit sweep is the only thing here that deletes customer data, so it
reads the current licence each run, skips Unlimited, and skips any instance
whose licence is not valid — expiry degrades to read-only, and trimming
more of somebody's history is not read-only.

HandleOIDCStart already checked the feature; HandleOIDCCallback did not,
and the unguarded half is the one that completes a sign-in.

Unset limits are filled from the seed plan at stateFromResult, the single
decode site. A licence signed before max_monitors existed decodes it as 0,
and 0 would mean no monitors and an audit log trimmed to nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 6: Admin endpoints for plans, the catalogue and entitlements

**Files:**

- Create: `admin/internal/api/entitlement.go`
- Modify: `admin/internal/api/staff.go`, `admin/internal/api/routes.go`

**Interfaces:**

- Consumes: `models.AllCatalogue`, `models.GetPlan`, `models.GetEntitlement`, `models.UpsertEntitlement`, `catalogue.Resolve`, `ownedInstance`, `auth.Current`, `audit.Write`.
- Produces:
    - `PUT /api/staff/plans/:deployment/:tier`
    - `GET /api/staff/catalogue`
    - `PUT /api/staff/catalogue`
    - `GET /api/staff/instances/:id/entitlement`
    - `PUT /api/staff/instances/:id/entitlement`
    - `GET /api/instances/:id/entitlement`

- [ ] **Step 1: Re-key the plan update and drop the Paddle fields**

In `admin/internal/api/staff.go`, replace `staffUpdatePlan`:

```go
// staffUpdatePlan changes what a (deployment, tier) pair grants FROM NOW ON.
// Existing licences snapshotted their plan at issue time and are unaffected —
// the same rule as workflow_runs.steps_snapshot.
//
// It writes no Paddle identifiers: those live in the catalogue, because a
// metered plan is priced by several components.
func staffUpdatePlan(c *gin.Context) {
	var body models.Plan
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid plan"})
		return
	}
	deployment, tier := c.Param("deployment"), c.Param("tier")
	set := bson.M{
		"name":          body.Name,
		"base_limits":   body.BaseLimits,
		"base_features": body.BaseFeatures.OrEmpty(),
		"support_level": body.SupportLevel,
		"active":        body.Active,
	}
	res, err := db.Admin("plans").UpdateOne(c.Request.Context(),
		bson.M{"deployment": deployment, "tier": tier}, bson.M{"$set": set})
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	if res.MatchedCount == 0 {
		c.JSON(http.StatusNotFound, gin.H{"error": "no such plan"})
		return
	}
	audit.Write(c.Request.Context(), models.AuditEntry{
		Actor:  staffActor(c),
		Action: "plan.updated",
		Target: deployment + "/" + tier,
		Detail: fmt.Sprintf("servers=%d monitors=%d support=%s active=%t",
			body.BaseLimits.MaxServers, body.BaseLimits.MaxMonitors,
			body.SupportLevel, body.Active),
	})
	c.JSON(http.StatusOK, gin.H{"updated": true})
}
```

Use whatever the file's existing staff-actor helper is called for `staffActor(c)` — check the neighbouring handlers and match. Add `"fmt"` and the `audit` import if absent.

- [ ] **Step 2: Add the catalogue handlers**

Append to `admin/internal/api/staff.go`:

```go
func staffListCatalogue(c *gin.Context) {
	rows, err := models.AllCatalogue(c.Request.Context())
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, rows)
}

// staffUpdateCatalogue sets the price IDs on one component.
//
// The component is addressed by its natural key rather than by an ObjectID, so
// the staff UI never has to hold a Mongo identifier and a seeded row can be
// updated the moment it exists. Only price IDs are writable: a row's kind, plan
// and key are seeded by SeedCatalogue, and letting a form invent a limit_key
// would let it invent a limit nothing enforces.
func staffUpdateCatalogue(c *gin.Context) {
	var body struct {
		Kind       string                       `json:"kind"`
		Deployment string                       `json:"deployment"`
		Tier       string                       `json:"tier"`
		LimitKey   string                       `json:"limit_key"`
		FeatureKey string                       `json:"feature_key"`
		PriceIDs   map[string]map[string]string `json:"price_ids"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid component"})
		return
	}

	// Refuse a price on a term the deployment does not sell. Storing one would
	// mean a resolved self-hosted monthly price later, which the resolver treats
	// as a configuration error — better to refuse it at the point somebody
	// pastes it, while they are looking at the screen.
	for env, byTerm := range body.PriceIDs {
		for term, id := range byTerm {
			if id == "" {
				continue
			}
			if !termSold(body.Deployment, term) {
				c.JSON(http.StatusBadRequest, gin.H{
					"error": fmt.Sprintf("%s does not sell %s (environment %s)",
						body.Deployment, term, env)})
				return
			}
		}
	}

	filter := bson.M{
		"kind":        body.Kind,
		"deployment":  body.Deployment,
		"tier":        body.Tier,
		"limit_key":   body.LimitKey,
		"feature_key": body.FeatureKey,
	}
	res, err := db.Admin("catalogue").UpdateOne(c.Request.Context(), filter,
		bson.M{"$set": bson.M{"price_ids": body.PriceIDs}})
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	if res.MatchedCount == 0 {
		c.JSON(http.StatusNotFound, gin.H{"error": "no such component"})
		return
	}
	audit.Write(c.Request.Context(), models.AuditEntry{
		Actor:  staffActor(c),
		Action: "catalogue.updated",
		Target: body.Deployment + "/" + body.Tier + "/" + body.Kind,
		Detail: body.LimitKey + body.FeatureKey,
	})
	c.JSON(http.StatusOK, gin.H{"updated": true})
}

func termSold(deployment, term string) bool {
	for _, t := range license.TermsFor(deployment) {
		if t == term {
			return true
		}
	}
	return false
}
```

- [ ] **Step 3: Write the entitlement handlers**

Create `admin/internal/api/entitlement.go`:

```go
package api

import (
	"errors"
	"fmt"
	"net/http"
	"time"

	"github.com/gin-gonic/gin"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
	"go.mongodb.org/mongo-driver/v2/bson"
)

// entitlementBody is what a caller may set.
//
// Only Desired is writable. Granted is what a payment confirmed, and letting a
// form set it would let the portal grant itself a licence — which is the one
// thing this whole split exists to prevent. Staff promote Granted explicitly
// through a separate flag, because staff issuing a licence to somebody who has
// not paid is a real operation with a real reason, and it should be one they
// took on purpose and left an audit row for.
type entitlementBody struct {
	Tier     string   `json:"tier"`
	Term     string   `json:"term"`
	Servers  int      `json:"servers"`
	Features []string `json:"features"`
	// Grant promotes Desired into Granted in the same write. Staff only.
	Grant bool `json:"grant"`
}

// getEntitlement serves the customer's own view of one instance's configuration.
func getEntitlement(c *gin.Context) {
	inst, ok := ownedInstance(c)
	if !ok {
		return
	}
	ent, err := models.GetEntitlement(c.Request.Context(), inst.InstanceID)
	if errors.Is(err, models.ErrNoEntitlement) {
		c.JSON(http.StatusNotFound, gin.H{"error": "no entitlement"})
		return
	}
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"entitlement": ent, "pending": ent.Pending()})
}

func staffGetEntitlement(c *gin.Context) {
	var inst models.Instance
	if err := db.Admin("admin_instances").FindOne(c.Request.Context(),
		bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "no such instance"})
		return
	}
	ent, err := models.GetEntitlement(c.Request.Context(), inst.InstanceID)
	if errors.Is(err, models.ErrNoEntitlement) {
		c.JSON(http.StatusNotFound, gin.H{"error": "no entitlement"})
		return
	}
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{"entitlement": ent, "pending": ent.Pending()})
}

// staffSetEntitlement writes an instance's configuration.
//
// This is the endpoint that makes metering usable before Paddle exists: staff
// configure, then issue. It does NOT issue — recording what an instance is
// allowed and signing a licence for it stay separate, so a bad configuration is
// a row to correct rather than a licence to supersede.
func staffSetEntitlement(c *gin.Context) {
	ctx := c.Request.Context()
	var body entitlementBody
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid entitlement"})
		return
	}

	var inst models.Instance
	if err := db.Admin("admin_instances").FindOne(ctx,
		bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "no such instance"})
		return
	}

	tier := body.Tier
	if tier == "" {
		tier = inst.Tier
	}
	plan, err := models.GetPlan(ctx, inst.Deployment, tier)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": fmt.Sprintf("no plan for %s/%s", inst.Deployment, tier)})
		return
	}
	if !termSold(inst.Deployment, body.Term) {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": fmt.Sprintf("%s does not sell %s", inst.Deployment, body.Term)})
		return
	}
	if body.Servers < plan.BaseLimits.MaxServers &&
		plan.BaseLimits.MaxServers != -1 {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": fmt.Sprintf("%s includes %d servers; cannot configure fewer",
				plan.Name, plan.BaseLimits.MaxServers)})
		return
	}

	desired := models.Config{
		Servers:  body.Servers,
		Features: models.Features(body.Features).OrEmpty(),
	}

	// Start from whatever is already granted, so writing a desired change never
	// silently alters what the instance is currently allowed.
	granted := desired
	existing, err := models.GetEntitlement(ctx, inst.InstanceID)
	switch {
	case err == nil:
		if !body.Grant {
			granted = existing.Granted
		}
	case errors.Is(err, models.ErrNoEntitlement):
		// First write. There is nothing granted to preserve, so desired becomes
		// granted — an instance with an entitlement nobody has granted would
		// fall back to the plan base at issue time and confuse everyone.
	default:
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	// Only the limits are stored. Features are NOT snapshotted onto the
	// entitlement: they live in Granted.Features, and Issue resolves them again
	// against the catalogue at signing time. Storing a second copy here would
	// give two answers to "which features does this instance have".
	limits, _, err := catalogue.Resolve(ctx, plan, granted)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	ent := models.Entitlement{
		InstanceID:     inst.InstanceID,
		AccountID:      inst.AccountID,
		Deployment:     inst.Deployment,
		Tier:           tier,
		Term:           body.Term,
		Desired:        desired,
		Granted:        granted,
		ResolvedLimits: limits,
	}
	// A reduction is a fact about the future, so it carries a date. There is no
	// billing period to read yet — plan 5 sets this from the subscription — so
	// staff-set reductions are marked as pending without one.
	if desired.Servers < granted.Servers {
		now := time.Now().UTC()
		ent.ScheduledChangeAt = &now
	}
	if existing != nil {
		ent.GrantedAt = existing.GrantedAt
	}
	if body.Grant {
		ent.GrantedAt = time.Now().UTC()
	}

	if err := models.UpsertEntitlement(ctx, ent); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	audit.Write(ctx, models.AuditEntry{
		Actor:     staffActor(c),
		Action:    "entitlement.updated",
		AccountID: inst.AccountID,
		Target:    inst.InstanceID,
		Detail: fmt.Sprintf("tier=%s term=%s desired_servers=%d granted_servers=%d granted=%t",
			tier, body.Term, desired.Servers, granted.Servers, body.Grant),
	})
	c.JSON(http.StatusOK, gin.H{"entitlement": ent, "pending": ent.Pending()})
}
```

- [ ] **Step 4: Mount the routes**

In `admin/internal/api/routes.go`, replace the plan route and add the rest.

Replace:

```go
		staff.PUT("/plans/:tier", staffUpdatePlan)
```

with:

```go
		// Plans are keyed on the pair now, so the path is too. A single :tier
		// segment could name three rows.
		staff.PUT("/plans/:deployment/:tier", staffUpdatePlan)

		staff.GET("/catalogue", staffListCatalogue)
		staff.PUT("/catalogue", staffUpdateCatalogue)

		staff.GET("/instances/:id/entitlement", staffGetEntitlement)
		staff.PUT("/instances/:id/entitlement", staffSetEntitlement)
```

And in the customer group, beside the other instance reads:

```go
		cust.GET("/instances/:id/entitlement", getEntitlement)
```

**Reading is open to any signed-in member**, matching the other instance reads. There is no customer _write_ here at all — that is plan 5's `PUT /api/instances/:id/entitlement`, which needs a Paddle call to mean anything.

- [ ] **Step 5: Confirm the endpoints**

```bash
GOWORK=off /tmp/gorun.sh admin go build ./...
```

Expected: no output.

```bash
GOWORK=off /tmp/gorun.sh admin go vet ./...
```

Expected: no output.

With admin running against scratch databases and a staff session in `/tmp/staffcj`:

```bash
curl -s -b /tmp/staffcj localhost:8083/api/staff/catalogue | python -m json.tool | head -30
```

Expected: sixteen rows, every `price_ids` an empty object.

Paste a sandbox price onto the cloud Professional base:

```bash
curl -s -b /tmp/staffcj -X PUT localhost:8083/api/staff/catalogue \
  -H 'Content-Type: application/json' -d '{
    "kind":"base","deployment":"cloud","tier":"professional",
    "price_ids":{"sandbox":{"monthly":"pri_test_base_m"}}}'
```

Expected: `{"updated":true}`.

Now the refusal that matters — a self-hosted monthly price:

```bash
curl -s -b /tmp/staffcj -X PUT localhost:8083/api/staff/catalogue \
  -H 'Content-Type: application/json' -d '{
    "kind":"base","deployment":"self_hosted","tier":"professional",
    "price_ids":{"sandbox":{"monthly":"pri_nope"}}}'
```

Expected: `400`, "self_hosted does not sell monthly". Confirm nothing was written:

```bash
mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \
  'db.catalogue.findOne({deployment:"self_hosted",tier:"professional",kind:"base"}).price_ids'
```

Expected: `{}` or no `monthly` key.

Set an entitlement:

```bash
curl -s -b /tmp/staffcj -X PUT localhost:8083/api/staff/instances/<id>/entitlement \
  -H 'Content-Type: application/json' \
  -d '{"tier":"professional","term":"monthly","servers":9,"features":["console"],"grant":true}' \
  | python -m json.tool
```

Expected: `resolved_limits.max_servers: 9`, `granted.servers: 9`, `pending: false`.

Now a reduction without granting it:

```bash
curl -s -b /tmp/staffcj -X PUT localhost:8083/api/staff/instances/<id>/entitlement \
  -H 'Content-Type: application/json' \
  -d '{"tier":"professional","term":"monthly","servers":5,"features":["console"]}' \
  | python -m json.tool
```

Expected: `desired.servers: 5`, **`granted.servers: 9`**, `pending: true`, `scheduled_change_at` set, and `resolved_limits.max_servers: 9`. A reduction must not lower what is granted.

Reissue and confirm the licence still says 9:

```bash
curl -s -b /tmp/staffcj -X POST localhost:8083/api/staff/instances/<id>/issue \
  -H 'Content-Type: application/json' -d '{"tier":"professional","term":"monthly"}'
mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \
  'db.licenses.find({instance_id:"<id>"},{"limits.max_servers":1,_id:0}).sort({issued_at:-1}).limit(1).toArray()'
```

Expected: `9`. **This is the single most important check in the task** — it proves a pending reduction cannot shorten what a customer currently has.

Confirm a below-base configuration is refused:

```bash
curl -s -b /tmp/staffcj -X PUT localhost:8083/api/staff/instances/<id>/entitlement \
  -H 'Content-Type: application/json' -d '{"tier":"professional","term":"monthly","servers":1}'
```

Expected: `400`, "Professional includes 3 servers; cannot configure fewer".

Confirm the customer read is scoped. As a customer of another account:

```bash
curl -s -o /dev/null -w "%{http_code}\n" -b /tmp/othercj \
  localhost:8083/api/instances/<id>/entitlement
```

Expected: `404`, never `403`.

- [ ] **Step 6: Commit**

```bash
git add admin/internal/api
git commit -m "feat(admin): staff endpoints for plans, the catalogue and entitlements

This is what makes metering usable before Paddle exists: staff configure an
entitlement, then issue against it — the position spec 3 left licensing in.

Only Desired is writable by a body. Granted is what a payment confirmed,
and a form that could set it would be a form that grants itself a licence.
Staff promote it with an explicit flag that leaves an audit row, because
granting somebody something they have not paid for is a real operation with
a real reason.

A reduction never lowers Granted, so reissuing against a pending reduction
still signs the larger cap. That is verified rather than asserted.

A self-hosted monthly price is refused where it is pasted, not where it is
later resolved — the resolver treating it as a configuration error is no
help to somebody who has already closed the screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 7: The staff plans and catalogue screens

**Files:**

- Create: `adminsite/app/(staff)/staff/catalogue/page.tsx`
- Modify: `adminsite/lib/api.ts`, `adminsite/app/(staff)/staff/plans/page.tsx`, `adminsite/components/AppBar.tsx`

**Interfaces:**

- Consumes: `GET/PUT /api/staff/plans/:deployment/:tier`, `GET/PUT /api/staff/catalogue`.
- Produces:
    - types `Limits` (five fields), `Plan`, `CatalogueRow`, `Term`, `Deployment`
    - `api.staff.plans()`, `api.staff.updatePlan(deployment, tier, plan)`
    - `api.staff.catalogue()`, `api.staff.updateCatalogue(row)`

- [ ] **Step 1: Update the types and calls**

In `adminsite/lib/api.ts`, replace the `Limits` and `Plan` types and add the rest:

```ts
export type Term = "monthly" | "annual";
export type Deployment = "cloud" | "self_hosted";

export interface Limits {
    max_servers: number;
    max_monitors: number;
    max_secret_groups: number;
    max_channels: number;
    audit_retention_days: number;
}

export interface Plan {
    deployment: Deployment;
    tier: Tier;
    name: string;
    /* The allowance BEFORE anything is bought. Not the total — a metered
     * dimension adds to it. */
    base_limits: Limits;
    base_features: string[];
    support_level: string;
    active: boolean;
}

export interface CatalogueRow {
    kind: "base" | "limit" | "feature";
    deployment: Deployment;
    tier: Tier;
    limit_key?: string;
    feature_key?: string;
    /* environment -> term -> Paddle price ID. The running PADDLE_ENV picks the
     * inner map; both environments are stored so promotion is a config change
     * rather than a data migration. */
    price_ids?: Record<string, Partial<Record<Term, string>>>;
}

export interface EntitlementConfig {
    servers: number;
    features: string[];
}

export interface Entitlement {
    instance_id: string;
    account_id: string;
    deployment: Deployment;
    tier: Tier;
    term: Term;
    desired: EntitlementConfig;
    granted: EntitlementConfig;
    resolved_limits: Limits;
    scheduled_change_at?: string;
    granted_at: string;
    updated_at: string;
}
```

Add `Tier` to include `"enterprise"` wherever it is declared, and **remove `"self_hosted"` from it** — it is a legacy payload value, not something a UI offers:

```ts
export type Tier = "free" | "professional" | "enterprise";
```

Then the calls, following the file's existing `staff` object shape:

```ts
    plans: () => get<Plan[]>("/api/staff/plans"),
    updatePlan: (deployment: Deployment, tier: Tier, plan: Plan) =>
        put<{ updated: boolean }>(
            `/api/staff/plans/${deployment}/${tier}`, plan),
    catalogue: () => get<CatalogueRow[]>("/api/staff/catalogue"),
    updateCatalogue: (row: CatalogueRow) =>
        put<{ updated: boolean }>("/api/staff/catalogue", row),
    entitlement: (id: string) =>
        get<{ entitlement: Entitlement; pending: boolean }>(
            `/api/staff/instances/${id}/entitlement`),
    setEntitlement: (
        id: string,
        body: { tier: Tier; term: Term; servers: number; features: string[]; grant?: boolean },
    ) =>
        put<{ entitlement: Entitlement; pending: boolean }>(
            `/api/staff/instances/${id}/entitlement`, body),
```

Use the file's own `get`/`put` helpers with their existing names; if it uses a single `req()`, follow that instead.

- [ ] **Step 2: Rework the plans screen for six rows**

In `adminsite/app/(staff)/staff/plans/page.tsx`, the screen now lists six plans grouped by deployment, edits allowances and support level, and **no longer touches price IDs at all** — those move to the catalogue screen.

Remove the existing `PriceEditor` component and the two demo buttons ("Cap servers at 7", "Remove OIDC") entirely. Replace the plan card body with an allowance form:

```tsx
const SUPPORT_LEVELS = [
    { value: "community", label: "Community" },
    { value: "email_24_5", label: "Email, 24/5" },
    { value: "email_call_24_7", label: "Email + call, 24/7" },
] as const;

const LIMIT_FIELDS = [
    { key: "max_servers", label: "Servers" },
    { key: "max_monitors", label: "Monitors" },
    { key: "max_secret_groups", label: "Secret groups" },
    { key: "max_channels", label: "Channels" },
    { key: "audit_retention_days", label: "Audit history (days)" },
] as const;

/*
 * -1 is Unlimited everywhere in the licence payload, so the form takes it
 * literally rather than inventing a checkbox. A staff screen that hides the
 * sentinel is a staff screen where nobody can tell whether a plan says
 * unlimited or nothing at all.
 */
function AllowanceForm({ plan, onSave, saving }: { plan: Plan; onSave: (next: Plan) => void; saving: boolean }) {
    const [draft, setDraft] = useState<Plan>(plan);
    const dirty = JSON.stringify(draft) !== JSON.stringify(plan);

    return (
        <div className="space-y-3">
            <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
                {LIMIT_FIELDS.map((f) => (
                    <label key={f.key} className="block">
                        <span className="mb-1 block text-[0.78rem] text-ink-3">{f.label}</span>
                        <input
                            type="number"
                            value={draft.base_limits[f.key]}
                            onChange={(e) =>
                                setDraft({
                                    ...draft,
                                    base_limits: {
                                        ...draft.base_limits,
                                        [f.key]: Number(e.target.value),
                                    },
                                })
                            }
                            className="w-full rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
                        />
                        <span className="mt-0.5 block text-[0.72rem] text-ink-3">−1 is unlimited</span>
                    </label>
                ))}
                <label className="block">
                    <span className="mb-1 block text-[0.78rem] text-ink-3">Support level</span>
                    <select
                        value={draft.support_level}
                        onChange={(e) => setDraft({ ...draft, support_level: e.target.value })}
                        className="w-full rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
                    >
                        {SUPPORT_LEVELS.map((s) => (
                            <option key={s.value} value={s.value}>
                                {s.label}
                            </option>
                        ))}
                    </select>
                </label>
            </div>

            <label className="flex items-center gap-2 text-[0.85rem] text-ink-2">
                <input type="checkbox" checked={draft.active} onChange={(e) => setDraft({ ...draft, active: e.target.checked })} />
                Offered to customers
            </label>

            <p className="text-[0.78rem] text-ink-3">Changes apply to licences issued from now on. Existing licences snapshotted their plan and are unaffected.</p>

            <button type="button" disabled={!dirty || saving} onClick={() => onSave(draft)} className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40">
                {saving ? "Saving…" : "Save allowances"}
            </button>
        </div>
    );
}
```

Group the list by deployment, so six rows read as two families of three rather than a flat list of six:

```tsx
{
    (["cloud", "self_hosted"] as const).map((deployment) => (
        <section key={deployment} className="space-y-3">
            <h2 className="text-[0.95rem] font-medium text-ink">{deployment === "cloud" ? "Cloud" : "Self-Hosted"}</h2>
            {plans
                .filter((p) => p.deployment === deployment)
                .map((p) => (
                    <article key={`${p.deployment}/${p.tier}`} className="rounded-lg border border-rule bg-panel p-4">
                        <header className="mb-3 flex items-baseline justify-between gap-3">
                            <h3 className="text-[0.9rem] font-medium text-ink">{p.name}</h3>
                            <span className="font-mono text-[0.75rem] text-ink-3">
                                {p.deployment}/{p.tier}
                            </span>
                        </header>
                        <AllowanceForm plan={p} saving={saving === `${p.deployment}/${p.tier}`} onSave={(next) => save(p.deployment, p.tier, next)} />
                    </article>
                ))}
        </section>
    ));
}
```

Wire `save` through TanStack Query's mutation following the file's existing pattern, invalidating the plans query on success.

- [ ] **Step 3: Build the catalogue screen**

Create `adminsite/app/(staff)/staff/catalogue/page.tsx`. Price IDs are the one thing a human pastes in from another system, so both environments are editable side by side — seeing them together is what makes "did I paste the sandbox one" answerable.

```tsx
"use client";

import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import PageHeader from "@/components/PageHeader";
import PageFrame from "@/components/PageFrame";
import { api, type CatalogueRow, type Term } from "@/lib/api";

const ENVS = ["sandbox", "production"] as const;

/* Self-hosted sells annual only, so the monthly cell is not rendered for it
 * rather than rendered and rejected. The backend refuses one either way; this is
 * so nobody types into a field that cannot be saved. */
function termsFor(deployment: string): Term[] {
    return deployment === "self_hosted" ? ["annual"] : ["monthly", "annual"];
}

function componentLabel(r: CatalogueRow): string {
    if (r.kind === "base") return "Base fee";
    if (r.kind === "limit") return `Per ${r.limit_key?.replace("max_", "")}`;
    return `Feature: ${r.feature_key}`;
}

function rowKey(r: CatalogueRow): string {
    return [r.deployment, r.tier, r.kind, r.limit_key ?? "", r.feature_key ?? ""].join("/");
}

export default function CataloguePage() {
    const qc = useQueryClient();
    const { data: rows = [], isLoading } = useQuery({
        queryKey: ["staff", "catalogue"],
        queryFn: api.staff.catalogue,
    });
    const [drafts, setDrafts] = useState<Record<string, CatalogueRow["price_ids"]>>({});

    const save = useMutation({
        mutationFn: (r: CatalogueRow) => api.staff.updateCatalogue(r),
        onSuccess: () => qc.invalidateQueries({ queryKey: ["staff", "catalogue"] }),
    });

    const groups = Array.from(new Set(rows.map((r) => `${r.deployment}/${r.tier}`)));

    return (
        <PageFrame
            header={<PageHeader title="Catalogue" back={{ href: "/staff", label: "Operations" }} subtitle="Every priceable component. This is the only place a Paddle price ID lives." />}
            rail={
                <aside className="space-y-3 text-[0.82rem] text-ink-2">
                    <p>A component with no price ID is free. A feature with no price is a toggle a customer may take at no charge; giving it a price here is all it takes to start charging for it.</p>
                    <p>Free is priced by nothing and has no rows. That absence is what keeps it outside Paddle.</p>
                    <p>Changing a price affects the next checkout only. It cannot touch an issued licence.</p>
                </aside>
            }
        >
            {isLoading ? (
                <p className="text-[0.85rem] text-ink-3">Loading…</p>
            ) : (
                <div className="space-y-6">
                    {groups.map((g) => {
                        const [deployment, tier] = g.split("/");
                        const terms = termsFor(deployment);
                        return (
                            <section key={g} className="space-y-2">
                                <h2 className="text-[0.95rem] font-medium text-ink">
                                    {deployment === "cloud" ? "Cloud" : "Self-Hosted"} {tier}
                                </h2>
                                <div className="overflow-x-auto">
                                    <table className="w-full min-w-[42rem] border-collapse text-[0.82rem]">
                                        <thead>
                                            <tr className="border-b border-rule text-left text-ink-3">
                                                <th className="py-2 pr-3 font-normal">Component</th>
                                                {ENVS.map((env) =>
                                                    terms.map((t) => (
                                                        <th key={`${env}-${t}`} className="py-2 pr-3 font-normal">
                                                            {env} / {t}
                                                        </th>
                                                    )),
                                                )}
                                                <th className="py-2 font-normal" />
                                            </tr>
                                        </thead>
                                        <tbody>
                                            {rows
                                                .filter((r) => r.deployment === deployment && r.tier === tier)
                                                .map((r) => {
                                                    const k = rowKey(r);
                                                    const ids = drafts[k] ?? r.price_ids ?? {};
                                                    const dirty = JSON.stringify(ids) !== JSON.stringify(r.price_ids ?? {});
                                                    return (
                                                        <tr key={k} className="border-b border-rule/60">
                                                            <td className="py-2 pr-3 text-ink">{componentLabel(r)}</td>
                                                            {ENVS.map((env) =>
                                                                terms.map((t) => (
                                                                    <td key={`${env}-${t}`} className="py-2 pr-3">
                                                                        <input
                                                                            value={ids[env]?.[t] ?? ""}
                                                                            placeholder="pri_…"
                                                                            onChange={(e) =>
                                                                                setDrafts({
                                                                                    ...drafts,
                                                                                    [k]: {
                                                                                        ...ids,
                                                                                        [env]: {
                                                                                            ...(ids[env] ?? {}),
                                                                                            [t]: e.target.value,
                                                                                        },
                                                                                    },
                                                                                })
                                                                            }
                                                                            className="w-40 rounded border border-rule bg-panel px-2 py-1 font-mono text-[0.78rem] text-ink"
                                                                        />
                                                                    </td>
                                                                )),
                                                            )}
                                                            <td className="py-2">
                                                                <button
                                                                    type="button"
                                                                    disabled={!dirty || save.isPending}
                                                                    onClick={() =>
                                                                        save.mutate({
                                                                            ...r,
                                                                            price_ids: ids,
                                                                        })
                                                                    }
                                                                    className="rounded border border-accent/50 px-2.5 py-1 text-[0.78rem] text-accent disabled:opacity-40"
                                                                >
                                                                    Save
                                                                </button>
                                                            </td>
                                                        </tr>
                                                    );
                                                })}
                                        </tbody>
                                    </table>
                                </div>
                            </section>
                        );
                    })}
                </div>
            )}
        </PageFrame>
    );
}
```

Match `PageHeader` and `PageFrame`'s actual prop names — read one existing staff page and follow it exactly rather than trusting the names above.

- [ ] **Step 4: Add the nav entry**

In `adminsite/components/AppBar.tsx`, add Catalogue to the staff nav list beside Plans. Active state is derived from `usePathname` — do not hardcode it.

- [ ] **Step 5: Confirm it builds and carries no hex**

```bash
sh /tmp/npmrun.sh adminsite npm run build
```

Expected: a successful build with no type errors. A `Tier` still including `"self_hosted"` somewhere will surface here.

```bash
grep -rn "#[0-9a-fA-F]\{3,6\}" adminsite/app/\(staff\)/staff/catalogue/ adminsite/app/\(staff\)/staff/plans/
```

Expected: no output. Tokens only, in every app.

```bash
grep -rn "pri_" adminsite/ --include=*.tsx --include=*.ts | grep -v "pri_…"
```

Expected: no output — the only literal is the placeholder text.

Then in a browser, against the running scratch stack: open **Plans**, confirm six cards in two groups, change Professional cloud's monitors to `50`, save, reload and confirm it stuck. Open **Catalogue**, confirm self-hosted rows show **no monthly column**, paste a sandbox base price on cloud Professional, save, reload and confirm it stuck.

- [ ] **Step 6: Commit**

```bash
git add adminsite/lib/api.ts adminsite/app/\(staff\)/staff/plans adminsite/app/\(staff\)/staff/catalogue adminsite/components/AppBar.tsx
git commit -m "feat(adminsite): six plans, and the catalogue as its own screen

Plans edits allowances and support level; prices moved out entirely,
because a metered plan is priced by several components and a nested map on
a plan card could not show that.

The catalogue screen puts both environments side by side on purpose:
promoting to production is pasting production IDs, and seeing them together
is what makes \"did I paste the sandbox one\" answerable.

Self-hosted rows render no monthly cell rather than rendering one the
backend refuses. -1 is shown literally, because a staff screen that hides
the Unlimited sentinel is one where nobody can tell unlimited from unset.

Tier drops self_hosted: it is a legacy payload value, not something a UI
offers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 8: The configurator, mounted for staff

**Files:**

- Create: `adminsite/components/PlanConfigurator.tsx`
- Modify: `adminsite/app/(staff)/staff/instances/[id]/page.tsx`

**Interfaces:**

- Consumes: `api.staff.plans()`, `api.staff.catalogue()`, `api.staff.entitlement(id)`, `api.staff.setEntitlement(id, body)`, types `Plan`, `CatalogueRow`, `Entitlement`.
- Produces: `<PlanConfigurator deployment tier term servers features onChange catalogue plans />`

**This component is the one plan 5 reuses** for the customer purchase flow. Keep it free of staff-only concerns: it takes a configuration and reports changes, and it neither saves nor knows who is using it. The `grant` flag, the save button and the audit trail belong to the page, not to this.

- [ ] **Step 1: Write the configurator**

Create `adminsite/components/PlanConfigurator.tsx`:

```tsx
"use client";

import { useMemo } from "react";
import type { CatalogueRow, Deployment, Plan, Term, Tier } from "@/lib/api";

export interface PlanChoice {
    tier: Tier;
    term: Term;
    servers: number;
    features: string[];
}

/* Self-hosted sells annual only. The reason is in shared/license: an offline
 * licence cannot be revoked, so the term length IS the revocation window. */
function termsFor(deployment: Deployment): Term[] {
    return deployment === "self_hosted" ? ["annual"] : ["monthly", "annual"];
}

/*
 * PlanConfigurator is the whole of "what is this instance allowed", driven
 * entirely by the plans and catalogue it is handed.
 *
 * A feature appears because a catalogue row offers it, and shows a price because
 * that row has one. Nothing here is hardcoded per tier, which is what lets a new
 * paid add-on ship as a staff edit rather than a frontend release.
 *
 * It saves nothing and knows nothing about who is using it. Staff mount it to
 * set an entitlement; the customer purchase flow mounts the same component and
 * hands it a checkout.
 */
export default function PlanConfigurator({
    deployment,
    value,
    plans,
    catalogue,
    onChange,
    disabled,
}: {
    deployment: Deployment;
    value: PlanChoice;
    plans: Plan[];
    catalogue: CatalogueRow[];
    onChange: (next: PlanChoice) => void;
    disabled?: boolean;
}) {
    const available = useMemo(() => plans.filter((p) => p.deployment === deployment && p.active), [plans, deployment]);
    const plan = available.find((p) => p.tier === value.tier);
    const rows = useMemo(() => catalogue.filter((r) => r.deployment === deployment && r.tier === value.tier), [catalogue, deployment, value.tier]);
    const featureRows = rows.filter((r) => r.kind === "feature");
    const base = plan?.base_limits.max_servers ?? 0;
    const extra = Math.max(0, value.servers - base);

    const priceOf = (r: CatalogueRow) => r.price_ids?.sandbox?.[value.term] ?? r.price_ids?.production?.[value.term] ?? "";

    return (
        <div className="space-y-4">
            <fieldset className="space-y-1.5">
                <legend className="text-[0.78rem] text-ink-3">Tier</legend>
                <div className="flex flex-wrap gap-2">
                    {available.map((p) => (
                        <button
                            key={p.tier}
                            type="button"
                            disabled={disabled}
                            onClick={() =>
                                onChange({
                                    ...value,
                                    tier: p.tier,
                                    /* Moving tier moves the floor, so clamp up
                                     * rather than leaving an invalid count the
                                     * backend would refuse. */
                                    servers: Math.max(value.servers, p.base_limits.max_servers),
                                })
                            }
                            className={`rounded border px-3 py-1.5 text-[0.85rem] ${p.tier === value.tier ? "border-accent text-accent" : "border-rule text-ink-2"}`}
                        >
                            {p.name}
                        </button>
                    ))}
                </div>
            </fieldset>

            <fieldset className="space-y-1.5">
                <legend className="text-[0.78rem] text-ink-3">Term</legend>
                <div className="flex flex-wrap gap-2">
                    {termsFor(deployment).map((t) => (
                        <button
                            key={t}
                            type="button"
                            disabled={disabled}
                            onClick={() => onChange({ ...value, term: t })}
                            className={`rounded border px-3 py-1.5 text-[0.85rem] ${t === value.term ? "border-accent text-accent" : "border-rule text-ink-2"}`}
                        >
                            {t === "monthly" ? "Monthly" : "Annual"}
                        </button>
                    ))}
                </div>
                {deployment === "self_hosted" && <p className="text-[0.72rem] text-ink-3">Self-hosted is annual only.</p>}
            </fieldset>

            <label className="block">
                <span className="mb-1 block text-[0.78rem] text-ink-3">Servers</span>
                <input
                    type="number"
                    min={base}
                    value={value.servers}
                    disabled={disabled}
                    onChange={(e) => onChange({ ...value, servers: Number(e.target.value) })}
                    className="w-28 rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
                />
                <span className="ml-2 text-[0.78rem] text-ink-3">
                    {base} included{extra > 0 ? `, ${extra} extra` : ""}
                </span>
            </label>

            {featureRows.length > 0 && (
                <fieldset className="space-y-1.5">
                    <legend className="text-[0.78rem] text-ink-3">Features</legend>
                    {featureRows.map((r) => {
                        const key = r.feature_key!;
                        const on = value.features.includes(key);
                        const priced = priceOf(r) !== "";
                        return (
                            <label key={key} className="flex items-center gap-2 text-[0.85rem] text-ink-2">
                                <input
                                    type="checkbox"
                                    checked={on}
                                    disabled={disabled}
                                    onChange={(e) =>
                                        onChange({
                                            ...value,
                                            features: e.target.checked ? [...value.features, key] : value.features.filter((f) => f !== key),
                                        })
                                    }
                                />
                                <span>{key === "console" ? "Browser console" : "Single sign-on"}</span>
                                <span className="text-[0.72rem] text-ink-3">{priced ? "paid add-on" : "included"}</span>
                            </label>
                        );
                    })}
                </fieldset>
            )}
        </div>
    );
}
```

- [ ] **Step 2: Mount it on the staff instance screen**

In `adminsite/app/(staff)/staff/instances/[id]/page.tsx`, add an Entitlement section. It needs three queries and one mutation, and it must show `granted` versus `desired` when they differ — a pending reduction is a fact about the account, not only about Paddle.

```tsx
function EntitlementSection({ instanceId, deployment }: { instanceId: string; deployment: Deployment }) {
    const qc = useQueryClient();
    const { data: plans = [] } = useQuery({
        queryKey: ["staff", "plans"],
        queryFn: api.staff.plans,
    });
    const { data: catalogue = [] } = useQuery({
        queryKey: ["staff", "catalogue"],
        queryFn: api.staff.catalogue,
    });
    const { data } = useQuery({
        queryKey: ["staff", "entitlement", instanceId],
        queryFn: () => api.staff.entitlement(instanceId),
        retry: false,
    });

    const ent = data?.entitlement;
    const [draft, setDraft] = useState<PlanChoice | null>(null);
    const choice: PlanChoice =
        draft ??
        (ent
            ? {
                  tier: ent.tier,
                  term: ent.term,
                  servers: ent.desired.servers,
                  features: ent.desired.features ?? [],
              }
            : { tier: "professional", term: deployment === "self_hosted" ? "annual" : "monthly", servers: 3, features: [] });

    const save = useMutation({
        mutationFn: (grant: boolean) => api.staff.setEntitlement(instanceId, { ...choice, grant }),
        onSuccess: () => {
            setDraft(null);
            qc.invalidateQueries({ queryKey: ["staff", "entitlement", instanceId] });
        },
    });

    return (
        <section className="rounded-lg border border-rule bg-panel p-4">
            <header className="mb-3">
                <h2 className="text-[0.95rem] font-medium text-ink">Entitlement</h2>
                <p className="text-[0.78rem] text-ink-3">
                    What this instance is allowed. A licence is signed from <em>granted</em>, never from <em>desired</em>.
                </p>
            </header>

            {ent && data?.pending && (
                <p className="mb-3 rounded border border-warn/50 bg-panel-2 px-2.5 py-2 text-[0.82rem] text-ink-2">
                    Pending change — currently granted {ent.granted.servers} servers, configured for {ent.desired.servers}
                    {ent.scheduled_change_at ? `, effective ${new Date(ent.scheduled_change_at).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}` : ""}.
                </p>
            )}

            <PlanConfigurator deployment={deployment} value={choice} plans={plans} catalogue={catalogue} onChange={setDraft} disabled={save.isPending} />

            <div className="mt-4 flex flex-wrap gap-2">
                <button type="button" disabled={save.isPending} onClick={() => save.mutate(false)} className="rounded border border-rule px-3 py-1.5 text-[0.85rem] text-ink-2 disabled:opacity-40">
                    Save as configured
                </button>
                <button
                    type="button"
                    disabled={save.isPending}
                    onClick={() => save.mutate(true)}
                    className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40"
                >
                    Save and grant
                </button>
            </div>
            <p className="mt-2 text-[0.72rem] text-ink-3">Granting takes effect on the next licence issued. It does not issue one.</p>
            {save.error && <p className="mt-2 text-[0.82rem] text-expired">{String((save.error as Error).message)}</p>}
        </section>
    );
}
```

Mount it in the page body beside the existing licence-history section, passing the instance's own deployment. Use the token names the app actually defines — check `adminsite/app/globals.css` for `--warn` and `--expired` and use whatever is there.

- [ ] **Step 3: Confirm the flow end to end in the browser**

```bash
sh /tmp/npmrun.sh adminsite npm run build
```

Expected: a successful build.

```bash
grep -rn "#[0-9a-fA-F]\{3,6\}" adminsite/components/PlanConfigurator.tsx
```

Expected: no output.

Against the running scratch stack, on a cloud Professional instance:

1. Open the staff instance page. Confirm the Entitlement section shows the configurator with the tier and server count the backfill created.
2. Raise servers to `9`, tick **Browser console**, click **Save and grant**. Confirm the section reloads showing no pending banner.
3. Issue a licence from the same page. Confirm the new licence row reports `max_servers: 9` and features `["console"]`.
4. Lower servers to `5` and click **Save as configured**. Confirm the pending banner appears naming both numbers, and that issuing again **still produces a 9-server licence**.
5. Switch tier to Enterprise. Confirm the server count clamps up to `10` rather than staying at `5`.
6. Open a self-hosted instance. Confirm the Term control offers **annual only** and says so.

- [ ] **Step 4: Commit**

```bash
git add adminsite/components/PlanConfigurator.tsx adminsite/app/\(staff\)/staff/instances
git commit -m "feat(adminsite): the plan configurator, mounted for staff first

One component, catalogue-driven: a feature appears because a row offers it
and shows a price because that row has one, so a new paid add-on ships as a
staff edit rather than a frontend release.

It saves nothing and knows nothing about who is using it. Plan 5 mounts the
same component in the customer purchase flow and hands it a checkout; this
is deliberately not a customer screen, because a configurator with no
checkout behind it is worse than none.

The staff page shows granted against desired when they differ. A pending
reduction is a fact about the account, not only about Paddle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 9: Free self-hosted — claiming it, and renewing it

**Files:**

- Modify: `admin/internal/api/customer.go`, `admin/internal/api/routes.go`, `admin/internal/lifecycle/lifecycle.go`

**Interfaces:**

- Consumes: `ownedInstance`, `licensing.Issue`, `licensing.ErrFreeLimit`, `models.GetEntitlement`, `models.UpsertEntitlement`, `license.TermsFor`, `inject.Deliver`, `mail.SendLicense`.
- Produces:
    - `POST /api/instances/:id/claim-free`
    - `renewInstance` handling both deployments

Free self-hosted is new in spec 7 and has no path today. `POST /api/instances` creates cloud instances only; `POST /api/instances/link` links a self-hosted UUID but issues nothing. This task closes both ends: claiming Free on a linked install, and renewing it annually.

**This is the only place in Vantage where an instance is licensed without either a payment or a staff action.** It is bounded by the per-deployment Free limit from task 4 and by nothing else, which is why the guard is checked twice below.

- [ ] **Step 1: Claim Free on a linked self-hosted instance**

Append to `admin/internal/api/customer.go`:

```go
// claimFree issues a Free licence on a linked self-hosted instance.
//
// The link step creates the row; this gives it a licence. They are separate
// because linking is about identity — proving which install is yours — and
// claiming is about entitlement, and a customer who links an install and then
// changes their mind should not have consumed their one Free allowance.
//
// Free is outside Paddle entirely, so there is no checkout, no subscription and
// nothing to reconcile. licensing.Issue's own checkFreeLimit is the real guard;
// the count here exists to refuse politely before anything is written.
func claimFree(c *gin.Context) {
	inst, ok := ownedInstance(c, c.Param("id"))
	if !ok {
		return
	}
	ctx := c.Request.Context()

	// Cloud Free is claimed at creation by POST /api/instances. Allowing it here
	// too would be a second way to reach the same state, with its own bugs.
	if inst.Deployment != license.DeploymentSelfHosted {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": "cloud instances get their Free licence when they are created"})
		return
	}
	if inst.CurrentLicense != "" {
		c.JSON(http.StatusConflict, gin.H{
			"error": "this instance already has a licence"})
		return
	}

	plan, err := models.GetPlan(ctx, license.DeploymentSelfHosted, license.TierFree)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "no Free plan configured"})
		return
	}
	if !plan.Active {
		c.JSON(http.StatusForbidden, gin.H{
			"error": "Free self-hosted is not currently offered"})
		return
	}

	// The entitlement is written BEFORE the licence, so Issue snapshots it rather
	// than falling back to the plan base. They are the same numbers today, but
	// the ordering is what makes that a coincidence rather than a dependency.
	if err := models.UpsertEntitlement(ctx, models.Entitlement{
		InstanceID:     inst.InstanceID,
		AccountID:      inst.AccountID,
		Deployment:     license.DeploymentSelfHosted,
		Tier:           license.TierFree,
		Term:           "annual",
		Desired:        models.Config{Servers: plan.BaseLimits.MaxServers, Features: models.Features{}},
		Granted:        models.Config{Servers: plan.BaseLimits.MaxServers, Features: models.Features{}},
		ResolvedLimits: plan.BaseLimits,
	}); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	s := auth.Current(c)
	lic, err := licensing.Issue(ctx, licensing.IssueInput{
		InstanceID: inst.InstanceID,
		Tier:       license.TierFree,
		// Annual, and not a choice. Self-hosted sells annual only because the
		// term length is the revocation window for an offline licence.
		Term:     "annual",
		Reason:   models.ReasonNew,
		IssuedBy: s.Email,
	})
	if err != nil {
		status := http.StatusBadRequest
		if errors.Is(err, licensing.ErrFreeLimit) {
			status = http.StatusConflict
		}
		c.JSON(status, gin.H{"error": err.Error()})
		return
	}

	deliver(c, inst, lic)
	audit.Write(ctx, models.AuditEntry{
		Actor: s.Email, Action: "instance.claimed_free", AccountID: s.AccountID,
		Target: inst.InstanceID, Detail: "self-hosted Free, annual", IP: c.ClientIP()})
	c.JSON(http.StatusCreated, lic)
}
```

`deliver(c, inst, lic)` is the existing session-aware delivery helper used by `relinkInstance` — for a self-hosted instance it emails the blob and the customer can also download it. Confirm its signature matches before using it.

- [ ] **Step 2: Renew either deployment**

`renewInstance` hardcodes `Term: "monthly"` and calls `inject.Deliver`, both of which are wrong for a self-hosted Free instance: it would issue a one-month licence and inject it into a control-plane row that does not exist.

In `admin/internal/api/customer.go`'s `renewInstance`, replace the `licensing.Issue` call and the delivery that follows it:

```go
	// Free renews on its deployment's only term: monthly for cloud, annual for
	// self-hosted. Reading it from TermsFor rather than hardcoding is what stops
	// a self-hosted instance being handed a one-month licence.
	terms := license.TermsFor(inst.Deployment)
	term := terms[len(terms)-1]

	lic, err := licensing.Issue(ctx, licensing.IssueInput{
		InstanceID: inst.InstanceID,
		Tier:       license.TierFree,
		Term:       term,
		Reason:     models.ReasonRenewal,
		IssuedBy:   "self-serve",
	})
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	// Cloud is injected; self-hosted is delivered to the customer, because their
	// database is theirs and we cannot write to it.
	deliver(c, inst, lic)
```

For cloud, `deliver` must still inject. Read the helper first: if it only emails, keep the deployment branch explicit instead:

```go
	if inst.Deployment == license.DeploymentCloud {
		inject.Deliver(ctx, lic)
	} else {
		deliver(c, inst, lic)
	}
```

Use whichever of these two matches what `deliver` actually does. **Do not guess** — a cloud renewal that stops injecting leaves a paying instance serving its old licence until the reconciler catches up.

- [ ] **Step 3: Keep the reaper away from self-hosted**

The lifecycle sweep and the control-plane reaper exist for Free **cloud** instances. A self-hosted install is not ours to delete — we cannot see it, and its database is the customer's.

In `admin/internal/lifecycle/lifecycle.go`, confirm every sweep that leads to deletion or a deletion warning filters on `deployment: cloud`. If any filters only on `tier: free`, add the deployment:

```bash
grep -n "TierFree\|DeploymentCloud\|deployment" admin/internal/lifecycle/lifecycle.go
```

Expected after the edit: every query naming `tier: free` also names `deployment: cloud`. A self-hosted Free instance must still receive **expiry** notices — those are useful — but never a deletion warning, and never be reaped.

- [ ] **Step 4: Mount the route**

In `admin/internal/api/routes.go`, in the customer group beside `renew`:

```go
		cust.POST("/instances/:id/claim-free",
			auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
			claimFree)
```

Claiming consumes the account's one Free self-hosted allowance, so it is a mutation and sits behind the same owner/admin guard as the other mutations. Match the guard helper's actual name from the neighbouring routes.

- [ ] **Step 5: Confirm the Free self-hosted lifecycle**

```bash
GOWORK=off /tmp/gorun.sh admin go build ./...
```

Expected: no output.

Against scratch databases, as a customer with no self-hosted instance:

```bash
# link an install
curl -s -b /tmp/cj -X POST localhost:8083/api/instances/link \
  -H 'Content-Type: application/json' \
  -d '{"instance_id":"'$(uuidgen)'","name":"my box"}' | python -m json.tool
```

Expected: `201`, status `awaiting_link` or `active` per the existing behaviour, and **no licence**.

```bash
curl -s -b /tmp/cj -X POST localhost:8083/api/instances/<id>/claim-free | python -m json.tool
```

Expected: `201`, a licence whose `expires_at` is about **one year and three days** away — a year for the annual term plus `GracePeriod`. A one-month expiry means the term is still hardcoded.

```bash
mongosh "$SCRATCH_ADMIN_URI" --quiet --eval \
  'db.licenses.find({instance_id:"<id>"},{tier:1,deployment:1,"limits.max_servers":1,"limits.max_monitors":1,_id:0}).toArray()'
```

Expected: `tier: "free"`, `deployment: "self_hosted"`, `max_servers: 3`, `max_monitors: 3`.

Claim again:
Expected: `409`, "already has a licence".

Now the allowance. Link a second install and claim:
Expected: `409` from `ErrFreeLimit`, naming the deployment type.

Confirm a cloud Free instance does **not** block it. With the same account holding a cloud Free instance, the claim above must still have succeeded — if it did not, task 4's per-deployment scoping is not in effect.

Confirm the claim is refused on a cloud instance:

```bash
curl -s -b /tmp/cj -X POST localhost:8083/api/instances/<cloud id>/claim-free
```

Expected: `400`, "cloud instances get their Free licence when they are created".

Renewal. Set the licence's expiry inside the window and renew:

```bash
mongosh "$SCRATCH_ADMIN_URI" --quiet --eval '
  db.licenses.updateOne({license_id:"<id>"},
    {$set:{expires_at: new Date(Date.now() + 1000*60*60*24*3)}})'

curl -s -b /tmp/cj -X POST localhost:8083/api/instances/<id>/renew | python -m json.tool
```

Expected: a new licence, `reason: renewal`, expiry about a year and three days out, the previous one carrying `superseded_by`.

Confirm the cloud Free renewal still injects. Renew a cloud Free instance and check the control-plane document:

```bash
mongosh "$SCRATCH_CONTROL_URI" --quiet --eval \
  'db.instances.findOne({instance_id:"<cloud id>"},{license_tier:1,license_expiry:1,_id:0})'
```

Expected: `license_expiry` matching the **new** licence. **This is the regression to watch** — the delivery branch is the only thing standing between a working cloud renewal and one that silently stops injecting.

Confirm the reaper's scope:

```bash
grep -n "TierFree" admin/internal/lifecycle/lifecycle.go
```

Expected: every hit that leads to a deletion notice or a reap is accompanied by `DeploymentCloud` on the same query.

- [ ] **Step 6: Commit**

```bash
git add admin/internal/api/customer.go admin/internal/api/routes.go admin/internal/lifecycle/lifecycle.go
git commit -m "feat(admin): claim and renew Free self-hosted

Free exists in both deployments now, and self-hosted had no path to it:
POST /api/instances creates cloud only, and link issues nothing.

Linking and claiming stay separate. Linking proves which install is yours;
claiming spends your one Free allowance — and somebody who links an install
and changes their mind should not have spent it.

renewInstance read its term from TermsFor rather than hardcoding monthly,
which is what stops a self-hosted instance being handed a one-month licence
when self-hosted sells annual only. Delivery branches on deployment: cloud
is injected, self-hosted is delivered to the customer, because their
database is theirs.

The reaper stays cloud-only. A self-hosted install is not ours to delete —
we cannot see it. It still gets expiry notices, never deletion warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 10: The licence page, the documentation, and plan 5's preamble

**Files:**

- Modify: `web/app/(app)/settings/license/page.tsx`, `CLAUDE.md`, `docs/superpowers/specs/README.md`, `docs/superpowers/plans/2026-07-26-paddle-billing.md`

**Interfaces:**

- Consumes: `GET /api/license` with `support_level`, `usage.monitors`, `limits.max_monitors`, `limits.audit_retention_days`.
- Produces: no code interfaces.

- [ ] **Step 1: Extend the licence types**

In `web/lib/api.ts`, find `LicenseInfo` and extend its limits and usage shapes, and add the support level:

```ts
    support_level?: string;
```

```ts
limits: {
    max_servers: number;
    max_monitors: number;
    max_secret_groups: number;
    max_channels: number;
    audit_retention_days: number;
}
usage: {
    servers: number;
    monitors: number;
    secret_groups: number;
    channels: number;
}
```

Match the file's existing declaration style — if the two shapes are named interfaces rather than inline, edit those instead.

- [ ] **Step 2: Add the monitor meter and the retention fact**

`web/app/(app)/settings/license/page.tsx` already has an `Allowance` component that draws a labelled meter and handles `UNLIMITED` by drawing no bar. Monitors are a count with a usage figure, so they use it. Add the row after Servers, at line 227:

```tsx
                        <Allowance label="Servers" used={license.usage.servers} limit={license.limits.max_servers} />
                        <Allowance label="Monitors" used={license.usage.monitors} limit={license.limits.max_monitors} />
                        <Allowance label="Secret groups" used={license.usage.secret_groups} limit={license.limits.max_secret_groups} />
                        <Allowance label="Notification channels" used={license.usage.channels} limit={license.limits.max_channels} />
```

Audit history is **not** an allowance and must not use that component: there is no "used" figure, and a meter with a made-up numerator would be a lie drawn as a bar. Add a plain fact card beside it, matching `Allowance`'s own card treatment so the row still reads as one system:

```tsx
/*
 * Retention is a duration, not a cap, so it gets a fact rather than a meter.
 * Allowance's bar needs a numerator and there isn't one — how much of a
 * retention window have you "used"?
 */
function Retention({ days }: { days: number }) {
    const forever = days === UNLIMITED;
    return (
        <Card padding={false} className="p-4">
            <p className="font-mono text-[0.62rem] uppercase tracking-[0.14em] text-text-tertiary">Audit history</p>
            <p className="mt-2 text-2xl font-extrabold tracking-[-0.03em] tabular-nums text-text-primary">
                {forever ? "Kept" : days}
                <span className="ml-1.5 text-sm font-medium tracking-normal text-text-secondary">{forever ? "indefinitely" : "days"}</span>
            </p>
            <p className="mt-3 font-mono text-[0.62rem] uppercase tracking-[0.14em] text-text-secondary">{forever ? "Never trimmed" : "Older entries removed daily"}</p>
        </Card>
    );
}
```

Then render it after the four allowances:

```tsx
<Retention days={license.limits.audit_retention_days} />
```

- [ ] **Step 3: Show the support level**

Add the map above the page component:

```tsx
/*
 * The three stable identifiers, in prose. An unknown or empty value renders
 * NOTHING rather than a fallback: a licence signed before support_level existed
 * has no support level, and inventing one would promise something we have not
 * sold.
 */
const SUPPORT_LABELS: Record<string, string> = {
    community: "Community support",
    email_24_5: "Email support, 24/5",
    email_call_24_7: "Email and phone support, 24/7",
};
```

Then beside wherever the tier is displayed in the licence summary:

```tsx
{
    license.support_level && SUPPORT_LABELS[license.support_level] && <p className="text-sm text-text-secondary">{SUPPORT_LABELS[license.support_level]}</p>;
}
```

Place it in the summary block that already shows the tier and expiry, following that block's own spacing and text tokens rather than the classes above if they differ.

- [ ] **Step 2: Update CLAUDE.md**

Three edits.

The collection list under "MongoDB Collections" — add admin's two new collections to the admin sentence:

```
Admin's own database is separate and holds `accounts` · `admin_instances` ·
`licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `plans` ·
`staff_users` · `customer_users` · `instance_members` · `admin_audit`.
```

(Remove the duplicate `plans` if you introduce one — read the line before editing it.)

Add a paragraph after that list:

```
`plans` is keyed on `(deployment, tier)` — six rows, two deployments times three
tiers — and holds base allowances only. **Every Paddle price ID lives in
`catalogue`**, one row per priceable component (`base`, `limit`, `feature`),
because a metered plan is priced by several prices and one map on a plan row
cannot express that. `entitlements` holds one row per instance with `desired`
beside `granted`: the checkout is built from `desired`, a licence is only ever
signed from `granted`, and an abandoned checkout therefore leaves a `desired`
that reached nothing. The two Free plans have **no catalogue rows at all**, which
is what keeps Free outside Paddle.
```

The admin route table — add the new routes to the staff and customer blocks:

```
GET,PUT /plans/:deployment/:tier · GET,PUT /catalogue
GET,PUT /instances/:id/entitlement
```

and in the customer block:

```
GET  /instances/:id/entitlement
```

The tier description in the licensing section — replace any claim that Free is cloud-only by construction:

```
Free exists in both deployments, so it is no longer cloud-only by construction.
The one-Free-per-account rule is enforced per account **and deployment**, in
`licensing.checkFreeLimit` and in `createInstance`'s friendly pre-check — the two
must stay scoped identically, because a pre-check stricter than the issuer
refuses what would have worked.
```

- [ ] **Step 3: Update the spec index**

In `docs/superpowers/specs/README.md`, mark spec 7 shipped and note what it left:

```
| 7 | [metered-licensing](2026-07-26-metered-licensing-design.md) | [plan](../plans/2026-07-26-metered-licensing.md) | **shipped** — staff can configure and issue any of the six plans; no customer can buy one until 5 lands |
```

- [ ] **Step 4: Narrow plan 5's preamble to what remains**

In `docs/superpowers/plans/2026-07-26-paddle-billing.md`, the `⚠ REVISED BY SPEC 7` block currently describes work that is now done. Rewrite its opening so a reader knows spec 7 has landed:

```markdown
## ⚠ REVISED BY SPEC 7 — WHICH HAS SHIPPED

Spec 7 landed on <date>. The data model this plan assumed no longer exists:
`plans` is keyed on `(deployment, tier)`, every price ID lives in `catalogue`,
and `entitlements` holds what a licence is signed from. `admin/internal/catalogue`
already provides both folds, including `ResolveItems`, which is what replaces this
plan's price-ID-to-tier lookup.

**Re-derive tasks 1, 3, 4, 5, 7, 8 and 10 against the shipped code before
executing them.** Tasks 2, 6 and 9 stand as written.
```

Leave the per-task table in place — it is still the map of what changes — but update each row's wording from future to past tense where spec 7 did the work.

- [ ] **Step 5: Confirm the docs match the code**

```bash
sh /tmp/npmrun.sh web npm run build
```

Expected: a successful build.

```bash
grep -n "catalogue\|entitlements" CLAUDE.md | head
```

Expected: the collection list and the new paragraph.

```bash
grep -n "cloud-only by construction" CLAUDE.md
```

Expected: no output, or only inside the replacement paragraph explaining that it no longer holds.

```bash
grep -rn "PaddlePriceIDs\|PaddleProductID\|GetPlan(ctx, in.Tier)\|PlanFor(\*tier)" admin/ shared/ adminsite/
```

Expected: no output. Every reference to the old shapes is gone.

Confirm the route table matches the router:

```bash
grep -n "staff.PUT\|staff.GET\|cust.GET" admin/internal/api/routes.go | grep -c "plans\|catalogue\|entitlement"
```

Expected: `6`.

- [ ] **Step 6: Commit**

```bash
git add web/app/\(app\)/settings/license CLAUDE.md docs/superpowers
git commit -m "docs(licensing): the metered model, and what plan 5 has left to do

The licence page shows monitors, audit history and support level. -1 reads
as Unlimited for customers and stays literal on the staff screen, because
the two audiences need different things from the same number. A licence
with no support level renders nothing rather than a fallback — inventing
one would be a claim we cannot keep.

CLAUDE.md gains the catalogue and entitlements collections and loses the
claim that Free is cloud-only by construction, which spec 7 ended.

Plan 5's preamble now reads as history rather than warning, and names the
seven tasks to re-derive against the shipped code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

## Done when

- `plans` holds exactly six rows keyed on `(deployment, tier)`, and no row has tier `self_hosted`.
- `catalogue` holds sixteen rows and **none** for either Free plan.
- Every non-deleted instance with a tier has an entitlement, and restarting admin creates no more.
- A staff-set entitlement of 9 servers plus `console` produces a licence with `max_servers: 9` and `features: ["console"]`.
- A pending **reduction** leaves `granted` alone, and reissuing against it still signs the larger cap.
- An account holding a cloud Free instance can be issued a self-hosted Free licence, and still cannot hold two cloud Free instances.
- A customer can link a self-hosted install, claim Free on it, and receive a licence expiring in **a year and three days** — not a month.
- Renewing a cloud Free instance still writes the new expiry into the control-plane document.
- No query in `lifecycle.go` that warns about or performs deletion matches a self-hosted instance.
- The fourth monitor on a Free instance is refused with `{"error":"limit_exceeded","limit":"max_monitors"}`.
- `audit_logs` older than the licensed retention are deleted; an `Unlimited` licence's and a **lapsed** instance's are not.
- `/auth/oidc/callback` redirects to `/login?error=oidc_unavailable` on an instance whose licence lacks `oidc`, and sets no session.
- A licence signed before `max_monitors` existed reports the plan's base rather than `0`.
- `grep -rn "pri_" admin/ adminsite/ shared/ --include=*.go --include=*.ts --include=*.tsx` returns nothing but placeholder text.
- A self-hosted monthly price is refused at the point it is pasted.
- `grep -rn "\.Desired" admin/internal/licensing/` returns nothing.
- Both frontends build, and neither new screen carries a hex colour.

**Not proven by this plan:** anything involving Paddle. There is no client, no webhook and no checkout here by design — `LineItems` and `ResolveItems` are written and compiled but exercised only by hand, because nothing calls them until plan 5 does. Their first real exercise is plan 5's sandbox pass, and `ResolveItems` in particular deserves a deliberate test against a real multi-item subscription before it is trusted with a licence.

Also not proven: that the entitlement backfill's server counts are commercially correct. It maps `Unlimited` to the plan base, which understates every pre-metering customer. Task 2 step 9 requires a human to set those by hand before production, and nothing in the code can check that judgement.

## Deployment order

1. Deploy `server` first. It tolerates old licences (`FillUnset`) and new ones, so it can lead. Confirm `GET /api/license` reports the two new limits on an existing instance before going further.
2. Deploy `admin`. Boot runs `SeedPlans`, `SeedCatalogue` and `Backfill` in that order, which re-keys plans and creates the entitlements. **Take a database dump first** — passes 3 and 4 rewrite rows.
3. Review the entitlement server counts (task 2, step 9) and correct them by hand. Nothing new can be issued correctly until this is done.
4. Deploy `adminsite`. Paste sandbox price IDs into the catalogue screen if plan 5 is next.
5. `web` last, since it only displays fields the server already serves.

## Not in this plan

The Paddle client, webhooks, checkout, the outbound subscription update, and the customer's purchase configurator — all of which are plan 5's, and all of which now have a data model to build on. Also excluded, per the spec's non-goals: paid feature add-ons (the model supports one; no feature has a price), metered channels, monitors or secret groups (a catalogue row away, deliberately not taken), usage-based billing, refunds, Enterprise contract terms and POs, and anything that revokes or shortens a licence.
