# Cloud Instance Creation — Phase 2: Creation, Free Lifecycle and Reclaim

> **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:** Let a verified HQ customer create their own Free cloud instance from the portal, licence it automatically, remind them to renew, and reclaim it if they never do.

**Architecture:** Admin gains a second, deliberately narrow write path into the control plane (`cloudprov`) that creates instances and users — `inject` keeps owning exactly three licence fields and is not touched. The Free licence runs a month plus the existing three-day grace. Reclaim is split across services on purpose: admin sends the notices because it knows the billing address, and the **control plane** performs the delete because it knows what an instance is made of.

**Tech Stack:** Go 1.26, gin, MongoDB driver v2.8.0, Next.js 16, TanStack Query, `shared/provision`, `shared/license`.

## Global Constraints

- **No automated Go tests.** This repo has no Go test suite. Verification is by compiler, `grep`, and running built images against scratch databases. Every "confirm" step 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. The wrapper already exists at `/tmp/gorun.sh`:
    ```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 "$@"
    ```
- **`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".
- **Admin's control-plane writes are confined to `cloudprov`.** It writes `instances` and `users` and nothing else. `inject` still writes exactly `license_blob`, `license_tier`, `license_expiry` on `instances`. Do not widen `inject`.
- **The reaper defaults OFF.** `FREE_INSTANCE_REAP_AFTER` empty means never delete. It is set only in `deploy/docker-compose.site.yml`, never in `deploy/docker-compose.yml`.
- **Free is one per account** and cloud-only by construction — `licensing.Issue` already refuses a deployment mismatch. Do not add a tier flag.
- **Customer endpoints answer 404, never 403,** for another account's resource. Every handler naming an instance goes through `ownedInstance`.
- Paddle is **out of scope**. Accounts have an empty `PaddleCustomerID`.
- Account roles, invitations and instance membership are **phase 3**. In phase 2 an account has exactly one user, so no role checks are needed anywhere.

## Context this plan inherits

Phase 1 shipped (commits `da3afca`..`5f35b57`, on `main`, **not yet pushed**):

- `users` is unique on `(instance_id, email)`, not on `email` alone. The same address may hold a user in several instances.
- Every lookup by email is scoped by instance. `services.GetUserByEmail` no longer exists.
- `shared/models.User` has `HQUserID` (`hq_user_id`) and the constants `AuthLocal`, `AuthOIDC`, `AuthHQ`. **Nothing writes them yet — this phase is the first writer**, setting `AuthHQ` and `HQUserID` on the instance owner it creates.
- `admin/internal/auth/cloud.go` is deleted; `/auth/login` points at `HandleCustomerLogin`. Every customer authenticates against `customer_users`.
- `admin` already has account-first signup at `POST /auth/signup` (`admin/internal/auth/customer.go`) creating an `accounts` row plus an unverified `customer_users` row plus a verification email. **This phase adds no signup code** — it repoints the marketing form at it.

Spec: [`docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md`](../specs/2026-07-26-cloud-instance-creation-design.md), phase 2.

---

## File Structure

**Created:**

| Path                                                    | Responsibility                                               |
| ------------------------------------------------------- | ------------------------------------------------------------ |
| `admin/internal/cloudprov/cloudprov.go`                 | admin's ONLY instance/user write path into the control plane |
| `admin/internal/lifecycle/lifecycle.go`                 | lapse sweep and the four renewal notices                     |
| `server/internal/services/reap.go`                      | the Free-instance purge and its scheduler                    |
| `adminsite/app/(customer)/instances/new/page.tsx`       | create-instance form                                         |
| `adminsite/app/(customer)/instances/new/CreateForm.tsx` | the client component                                         |
| `site/components/AccountForm.tsx`                       | replaces `InstanceForm.tsx`                                  |

**Modified:**

| Path                                                                                   | Change                                                                 |
| -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `admin/internal/db/db.go`                                                              | `ControlDB()` accessor                                                 |
| `admin/internal/config/config.go`                                                      | `AppLoginURL` from `APP_LOGIN_URL`                                     |
| `admin/internal/models/models.go`                                                      | `StatusDeleted`, `NoticesSent` on `Instance`                           |
| `admin/internal/mail/mail.go`                                                          | four lifecycle emails                                                  |
| `admin/internal/api/customer.go`                                                       | `createInstance`, `renewInstance`                                      |
| `admin/internal/api/routes.go`                                                         | the two new customer routes                                            |
| `admin/internal/inject/inject.go`                                                      | reconciler marks vanished instances `deleted`                          |
| `admin/cmd/main.go`                                                                    | start the lifecycle sweeper                                            |
| `server/cmd/main.go`                                                                   | start the reaper                                                       |
| `adminsite/lib/api.ts`                                                                 | `createInstance`, `renewInstance`, `"deleted"` status                  |
| `adminsite/components/InstanceCard.tsx`                                                | renew action, deletion countdown, monthly-aware bar                    |
| `adminsite/app/(customer)/page.tsx`                                                    | create-instance call to action                                         |
| `site/app/start/page.tsx`                                                              | account-signup copy                                                    |
| `site/lib/submit.ts`                                                                   | `submitSignup` posts to admin                                          |
| `sitesvc/internal/api/*`, `sitesvc/internal/store/store.go`, `sitesvc/internal/models` | signup and verify removed                                              |
| `deploy/docker-compose.site.yml`                                                       | `APP_LOGIN_URL`, `FREE_INSTANCE_REAP_AFTER`, `ADMIN_API_URL` on `site` |
| `.gitea/workflows/server-deploy.yml`                                                   | `ADMIN_API_URL` build arg for `site`                                   |
| `CLAUDE.md`                                                                            | the boundary paragraph, sitesvc's table, the env tables                |

**Deleted:** `sitesvc/internal/api/signup.go`, `site/components/InstanceForm.tsx`.

---

### Task 1: `cloudprov` — admin's instance and user write path

**Files:**

- Create: `admin/internal/cloudprov/cloudprov.go`
- Modify: `admin/internal/db/db.go`, `admin/internal/config/config.go`

**Interfaces:**

- Consumes: `shared/provision.CreateInstance`, `CreateUserWithHash`, `RollbackInstance`; `shared/models`.
- Produces:
    - `db.ControlDB() *mongo.Database`
    - `config.Config.AppLoginURL string`
    - `cloudprov.CreateInstance(ctx, name, ownerEmail, ownerPasswordHash, hqUserID string) (*sharedmodels.Instance, error)`
    - `cloudprov.DeleteUser(ctx, instanceID, userID string) error`
    - `cloudprov.RollbackInstance(ctx, instanceID string) error`

- [ ] **Step 1: Add the `ControlDB` accessor**

In `admin/internal/db/db.go`, after `func Control(name string) *mongo.Collection`, add:

```go
// ControlDB exposes the control-plane database itself, because shared/provision
// takes a database rather than a collection.
//
// It is used by cloudprov and nothing else. Reach for Control(name) unless you
// are calling into shared/provision.
func ControlDB() *mongo.Database { return controlDB }
```

Then update the package doc comment at the top of the file. Replace the sentence beginning "Control() is the control plane's database" with:

```go
// Control() is the control plane's database. Admin's access to it is narrow and
// lives in exactly two packages: inject writes three licence fields on
// `instances`, and cloudprov creates and rolls back `instances` and `users` when
// a customer provisions a cloud instance. Nothing else may write there, and a
// third write path is a design change rather than a refactor.
```

- [ ] **Step 2: Add `APP_LOGIN_URL` to config**

In `admin/internal/config/config.go`, add `AppLoginURL string` to the `Config` struct after `PublicURL`, and in `Load()` add to the struct literal:

```go
		AppLoginURL: os.Getenv("APP_LOGIN_URL"),
```

It is **not** added to the required-variables map: an admin with no `APP_LOGIN_URL` still works, it just omits the sign-in link from the instance-ready email. Boot-failing on a cosmetic value would be worse than the missing link.

- [ ] **Step 3: Write `cloudprov`**

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

```go
// Package cloudprov provisions cloud instances in the control plane.
//
// This is admin's second and final write path into the control-plane database,
// alongside inject. It writes `instances` and `users` and nothing else. A third
// write target, or a write to any other collection from here, is a design change
// and not a refactor — see the spec's "Admin's control-plane write boundary".
//
// Every function here is called from a customer request, so each one leaves the
// control plane in a consistent state or not at all: the caller unwinds in
// reverse order on failure, and RollbackInstance refuses to delete an instance
// that has users.
package cloudprov

import (
	"context"
	"fmt"

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

// CreateInstance creates a control-plane instance and its owner.
//
// The owner's password hash is COPIED from the HQ account rather than shared.
// Changing the password on either side does not propagate, and they diverge from
// that moment — accepted deliberately, because propagating a hash across two
// services' databases is a worse problem than two passwords that started equal.
//
// On owner-insert failure the instance is rolled back, so a failed provision
// never leaves a slug permanently occupied by an instance nobody owns.
func CreateInstance(ctx context.Context, name, ownerEmail, ownerPasswordHash, hqUserID string) (*sharedmodels.Instance, error) {
	inst, err := provision.CreateInstance(ctx, db.ControlDB(), name)
	if err != nil {
		return nil, err
	}

	u, err := provision.CreateUserWithHash(ctx, db.ControlDB(), inst.InstanceID,
		ownerEmail, ownerPasswordHash, sharedmodels.RoleOwner, sharedmodels.AuthHQ)
	if err != nil {
		if rbErr := provision.RollbackInstance(ctx, db.ControlDB(), inst.InstanceID); rbErr != nil {
			return nil, fmt.Errorf("create owner: %w (and rollback failed: %v)", err, rbErr)
		}
		return nil, err
	}

	// hq_user_id is what phase 3 uses to find every row projected from one HQ
	// user when its password changes. Set at creation so the owner is not a
	// special case later.
	if _, err := db.Control("users").UpdateOne(ctx,
		bson.M{"user_id": u.UserID},
		bson.M{"$set": bson.M{"hq_user_id": hqUserID}}); err != nil {
		return nil, fmt.Errorf("set hq_user_id: %w", err)
	}

	return inst, nil
}

// DeleteUser removes one control-plane user. Used only to unwind a failed
// provision.
func DeleteUser(ctx context.Context, instanceID, userID string) error {
	_, err := db.Control("users").DeleteOne(ctx,
		bson.M{"instance_id": instanceID, "user_id": userID})
	return err
}

// RollbackInstance deletes an instance that has no users.
func RollbackInstance(ctx context.Context, instanceID string) error {
	return provision.RollbackInstance(ctx, db.ControlDB(), instanceID)
}

// OwnerUserID returns the control-plane user_id of an instance's owner, so a
// caller can unwind a partial provision without re-deriving it.
func OwnerUserID(ctx context.Context, instanceID string) (string, error) {
	var u sharedmodels.User
	err := db.Control("users").FindOne(ctx, bson.M{
		"instance_id": instanceID,
		"role":        sharedmodels.RoleOwner,
	}).Decode(&u)
	if err != nil {
		return "", err
	}
	return u.UserID, nil
}
```

- [ ] **Step 4: Confirm it compiles**

Run:

```sh
sh /tmp/gorun.sh admin go build ./...
```

Expected: no output.

- [ ] **Step 5: Confirm the write boundary holds**

Run:

```sh
grep -rn 'db.Control(' --include=*.go admin/ | grep -v '_test'
```

Expected: matches only in `admin/internal/inject/inject.go`, `admin/internal/cloudprov/cloudprov.go`, and read-only uses in `admin/internal/api/staff.go` and `admin/internal/licensing/link.go`. Any **write** (`UpdateOne`, `InsertOne`, `DeleteOne`) outside `inject` and `cloudprov` is a boundary violation — report it rather than fixing it silently.

- [ ] **Step 6: Commit**

```bash
git add admin/internal/cloudprov/ admin/internal/db/db.go admin/internal/config/config.go
git commit -m "feat(admin): cloudprov, the instance provisioning write path

Admin's second and final write path into the control plane. It creates
instances and users and nothing else; inject still owns exactly three
licence fields and is untouched.

The owner's password hash is copied from the HQ account, not shared. The
two diverge on the next password change, which is accepted: propagating a
hash across two databases is worse than two passwords that started equal.

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

---

### Task 2: Create an instance

**Files:**

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

**Interfaces:**

- Consumes: `cloudprov` from Task 1; `licensing.Issue`, `licensing.ErrFreeLimit`; `inject.Deliver`; `auth.Current`.
- Produces:
    - `models.StatusDeleted = "deleted"`
    - `models.Instance.NoticesSent []string` — bson `notices_sent,omitempty`
    - `mail.SendInstanceReady(to, instanceName, loginURL string, expires time.Time) error`
    - `POST /api/instances`

- [ ] **Step 1: Add the model fields**

In `admin/internal/models/models.go`, add to the instance-status constants:

```go
	// StatusDeleted marks an instance the control plane has reaped. The row is
	// kept because the licence history references it and support questions
	// outlive the instance.
	StatusDeleted = "deleted"
```

And to the `Instance` struct, after `InjectFailedAt`:

```go
	// NoticesSent holds the lifecycle notice keys already emailed for the
	// CURRENT term ("expiring", "expired", "delete_7", "delete_1"). Renewal
	// clears it, so the next term starts the sequence again. It is what stops a
	// restart re-sending a notice.
	NoticesSent []string `bson:"notices_sent,omitempty" json:"notices_sent,omitempty"`
```

- [ ] **Step 2: Add the instance-ready email**

In `admin/internal/mail/mail.go`, add:

```go
// SendInstanceReady tells a customer their cloud instance exists, where it is,
// and when its licence runs out.
//
// The expiry is stated here rather than only in a later reminder: a Free licence
// that quietly expires in a month is a surprise, and the first email is the one
// people keep.
func SendInstanceReady(to, instanceName, loginURL string, expires time.Time) error {
	body := fmt.Sprintf("%s is ready.\n\n", instanceName)
	if loginURL != "" {
		body += "Sign in here:\n\n" + loginURL + "\n\n"
	}
	body += fmt.Sprintf(
		"Your Free licence runs until %s. We will email you before then so you can renew it in one click.\n\n"+
			"Sign in with the same email address and password you use for your Vantage account. "+
			"Changing one does not change the other.\n",
		expires.Format("2 January 2006"))
	return send(to, instanceName+" is ready", body)
}
```

Add `"time"` to the imports.

- [ ] **Step 3: Add the handler**

In `admin/internal/api/customer.go`, add:

```go
// createInstance provisions a Free cloud instance for the calling account.
//
// The ordering matters and each step unwinds the previous one. Licence issuance
// and email are deliberately NOT allowed to fail the request: the instance
// exists and the customer can sign in, they see the licence banner, and staff
// can issue by hand. Rolling back an instance the customer can already see would
// be worse than shipping it unlicensed.
func createInstance(c *gin.Context) {
	var body struct {
		Name string `json:"name"`
	}
	if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
		return
	}
	name := strings.TrimSpace(body.Name)
	ctx := c.Request.Context()
	s := auth.Current(c)

	// 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.
	n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{
		"account_id": s.AccountID,
		"tier":       license.TierFree,
		"status":     bson.M{"$ne": models.StatusCancelled},
	})
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "could not check your account"})
		return
	}
	if n > 0 {
		c.JSON(http.StatusConflict, gin.H{
			"error": "this account already has a Free instance"})
		return
	}

	var cu models.CustomerUser
	if err := db.Admin("customer_users").FindOne(ctx,
		bson.M{"user_id": s.UserID}).Decode(&cu); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read your account"})
		return
	}

	inst, err := cloudprov.CreateInstance(ctx, name, cu.Email, cu.PasswordHash, cu.UserID)
	if err != nil {
		if errors.Is(err, provision.ErrEmailTaken) {
			// users.email is unique per instance, so this means the address
			// already owns a user in an instance we are not creating — a legacy
			// cloud tenant. Staff have to attach that one by hand.
			c.JSON(http.StatusConflict, gin.H{
				"error": "that email address already belongs to an existing Vantage instance; contact support@hostxtra.co.uk and we will link it to your account"})
			return
		}
		if errors.Is(err, provision.ErrNameRejected) {
			c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
			return
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"})
		return
	}

	rec := models.Instance{
		InstanceID: inst.InstanceID,
		AccountID:  s.AccountID,
		Name:       inst.Name,
		Slug:       inst.Slug,
		Deployment: license.DeploymentCloud,
		Status:     models.StatusActive,
		CreatedAt:  time.Now().UTC(),
	}
	if _, err := db.Admin("admin_instances").InsertOne(ctx, rec); err != nil {
		// Unwind in reverse: the owner first, because RollbackInstance refuses
		// an instance that still has users.
		if uid, e := cloudprov.OwnerUserID(ctx, inst.InstanceID); e == nil {
			_ = cloudprov.DeleteUser(ctx, inst.InstanceID, uid)
		}
		if e := cloudprov.RollbackInstance(ctx, inst.InstanceID); e != nil {
			log.Printf("createInstance: rollback of %s failed: %v", inst.InstanceID, e)
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"})
		return
	}

	audit.Write(ctx, models.AuditEntry{
		Actor: s.Email, Action: "instance.created", AccountID: s.AccountID,
		Target: inst.InstanceID, Detail: "slug=" + inst.Slug, IP: c.ClientIP()})

	// Past this point nothing fails the request.
	lic, err := licensing.Issue(ctx, licensing.IssueInput{
		InstanceID: inst.InstanceID,
		Tier:       license.TierFree,
		Term:       "monthly",
		Reason:     models.ReasonNew,
		IssuedBy:   "self-serve",
	})
	if err != nil {
		log.Printf("ISSUE FAILED for new instance %s: %v", inst.InstanceID, err)
		c.JSON(http.StatusCreated, rec)
		return
	}
	inject.Deliver(ctx, lic)

	if mail.Enabled() {
		if err := mail.SendInstanceReady(s.Email, inst.Name,
			loginURLFor(inst.Slug), lic.ExpiresAt); err != nil {
			log.Printf("createInstance: instance-ready email to %s: %v", s.Email, err)
		}
	}

	rec.Tier = lic.Tier
	rec.CurrentLicense = lic.LicenseID
	c.JSON(http.StatusCreated, rec)
}

// loginURLFor fills the {slug} template in APP_LOGIN_URL. An empty template
// yields an empty string, and the email simply omits the link.
func loginURLFor(slug string) string {
	if appLoginURL == "" {
		return ""
	}
	return strings.ReplaceAll(appLoginURL, "{slug}", url.PathEscape(slug))
}

// appLoginURL is set once at boot from config.
var appLoginURL string

// SetAppLoginURL is called from main.
func SetAppLoginURL(v string) { appLoginURL = v }
```

Add the imports this needs to `customer.go`: `"log"`, `"net/url"`, `"strings"`, `"time"`, `"go.mongodb.org/mongo-driver/v2/bson"`, `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"`, `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"`, `"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"`. `db`, `models`, `licensing`, `inject`, `mail`, `auth` and `license` are already imported.

- [ ] **Step 4: Wire the route and the config**

In `admin/internal/api/routes.go`, inside the `cust` group, after `cust.GET("/account", getAccount)`:

```go
		cust.POST("/instances", createInstance)
```

In `admin/cmd/main.go`, after the config is loaded and before the HTTP server starts, add:

```go
	api.SetAppLoginURL(cfg.AppLoginURL)
```

Import `admin/internal/api` there if it is not already imported (it will be, for `api.Routes`).

- [ ] **Step 5: Confirm it compiles**

Run:

```sh
sh /tmp/gorun.sh admin go build ./...
```

Expected: no output.

- [ ] **Step 6: Commit**

```bash
git add admin/
git commit -m "feat(admin): POST /api/instances creates a Free cloud instance

Provisions the control-plane instance and its owner, records the
admin_instances row, issues and injects a Free licence, and emails the
customer where it is and when it expires.

Licence issuance and email cannot fail the request. The instance exists
and the customer can sign in; rolling back something they can already see
would be worse than shipping it unlicensed for staff to fix.

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

---

### Task 3: Renew a Free instance

**Files:**

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

**Interfaces:**

- Consumes: `ownedInstance`, `licensing.Issue`, `inject.Deliver`.
- Produces:
    - `mail.SendRenewed(to, instanceName string, expires time.Time) error`
    - `POST /api/instances/:id/renew`
    - `RenewWindow = 7 * 24 * time.Hour` in `admin/internal/models/models.go`

- [ ] **Step 1: Add the renewal window constant**

In `admin/internal/models/models.go`, after `GracePeriod`:

```go
// RenewWindow is how long before expiry a Free instance may be renewed.
//
// Renewal stays available after expiry too, right up until the reaper takes the
// instance, so the same button rescues a lapsed instance instead of needing a
// second mechanism.
const RenewWindow = 7 * 24 * time.Hour
```

- [ ] **Step 2: Add the renewed email**

In `admin/internal/mail/mail.go`:

```go
// SendRenewed confirms a renewal and states the new date.
func SendRenewed(to, instanceName string, expires time.Time) error {
	return send(to, instanceName+" renewed",
		fmt.Sprintf("%s is renewed.\n\nYour Free licence now runs until %s.\n",
			instanceName, expires.Format("2 January 2006")))
}
```

- [ ] **Step 3: Add the handler**

In `admin/internal/api/customer.go`:

```go
// renewInstance extends a Free licence by another term.
//
// Renewal is manual on purpose: it is the entire reclaim signal. An instance
// nobody renews is an instance nobody is using, and that is what makes the
// reaper safe to run at all.
func renewInstance(c *gin.Context) {
	inst, ok := ownedInstance(c, c.Param("id"))
	if !ok {
		return
	}
	if inst.Tier != license.TierFree {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": "only Free instances renew here; paid plans renew through billing"})
		return
	}

	ctx := c.Request.Context()

	var current models.License
	if err := db.Admin("licenses").FindOne(ctx,
		bson.M{"license_id": inst.CurrentLicense}).Decode(&current); err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"})
		return
	}
	if time.Now().UTC().Before(current.ExpiresAt.Add(-models.RenewWindow)) {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": fmt.Sprintf("this licence is not due yet; you can renew from %s",
				current.ExpiresAt.Add(-models.RenewWindow).Format("2 January 2006"))})
		return
	}

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

	// Clear the notice log so the next term starts the sequence again. Issue has
	// already set status back to active.
	if _, err := db.Admin("admin_instances").UpdateOne(ctx,
		bson.M{"instance_id": inst.InstanceID},
		bson.M{"$unset": bson.M{"notices_sent": ""}}); err != nil {
		log.Printf("renewInstance: clear notices for %s: %v", inst.InstanceID, err)
	}

	s := auth.Current(c)
	audit.Write(ctx, models.AuditEntry{
		Actor: s.Email, Action: "instance.renewed", AccountID: s.AccountID,
		Target: inst.InstanceID, IP: c.ClientIP()})

	if mail.Enabled() {
		if err := mail.SendRenewed(s.Email, inst.Name, lic.ExpiresAt); err != nil {
			log.Printf("renewInstance: renewed email to %s: %v", s.Email, err)
		}
	}

	c.JSON(http.StatusOK, lic)
}
```

Add `"fmt"` to `customer.go`'s imports.

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

In `admin/internal/api/routes.go`, inside the `cust` group, after `cust.POST("/instances/:id/relink", relinkInstance)`:

```go
		cust.POST("/instances/:id/renew", renewInstance)
```

- [ ] **Step 5: Confirm it compiles**

Run:

```sh
sh /tmp/gorun.sh admin go build ./...
```

Expected: no output.

- [ ] **Step 6: Commit**

```bash
git add admin/
git commit -m "feat(admin): renew a Free instance from the portal

Available from seven days before expiry and, deliberately, at any point
after it up to deletion, so the same button rescues a lapsed instance.

Renewal is manual because it is the entire reclaim signal: an instance
nobody renews is one nobody is using, which is what makes reaping safe.

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

---

### Task 4: The lapse sweep and the four notices

**Files:**

- Create: `admin/internal/lifecycle/lifecycle.go`
- Modify: `admin/internal/mail/mail.go`, `admin/cmd/main.go`

**Interfaces:**

- Consumes: `models.Instance`, `models.License`, `mail`.
- Produces:
    - `mail.SendExpiring`, `mail.SendExpired`, `mail.SendDeletionWarning`
    - `lifecycle.Run(ctx) error`, `lifecycle.Start(ctx, reapAfter time.Duration)`

The notice schedule, all relative to the licence's `ExpiresAt` (which already includes the three-day grace):

| Key        | Sent when              | Says                         |
| ---------- | ---------------------- | ---------------------------- |
| `expiring` | 7 days before expiry   | renew, one click             |
| `expired`  | at expiry              | read-only; deleted in N days |
| `delete_7` | 7 days before deletion | deleted in 7 days            |
| `delete_1` | 1 day before deletion  | deleted tomorrow             |

- [ ] **Step 1: Add the three emails**

In `admin/internal/mail/mail.go`:

```go
// SendExpiring is the renew-now nudge, seven days out.
func SendExpiring(to, instanceName, portalURL string, expires time.Time) error {
	return send(to, instanceName+" expires on "+expires.Format("2 January"),
		fmt.Sprintf("%s's Free licence runs out on %s.\n\n"+
			"Renew it in one click:\n\n%s\n\n"+
			"If you do nothing, the instance keeps running but stops accepting changes.\n",
			instanceName, expires.Format("2 January 2006"), portalURL))
}

// SendExpired states plainly what has stopped and what happens next.
//
// It names the deletion date rather than a vague warning: the whole point of the
// sequence is that nobody loses an instance without having been told a date.
func SendExpired(to, instanceName, portalURL string, deleteOn time.Time) error {
	return send(to, instanceName+" is now read-only",
		fmt.Sprintf("%s's Free licence has expired.\n\n"+
			"Your servers and monitors keep running and your agents keep their keys, "+
			"but changes are disabled.\n\n"+
			"Renew it here:\n\n%s\n\n"+
			"If it is not renewed, the instance and everything in it will be deleted on %s.\n",
			instanceName, portalURL, deleteOn.Format("2 January 2006")))
}

// SendDeletionWarning is the final countdown, sent at seven days and one day.
func SendDeletionWarning(to, instanceName, portalURL string, deleteOn time.Time, daysLeft int) error {
	when := fmt.Sprintf("in %d days", daysLeft)
	if daysLeft <= 1 {
		when = "tomorrow"
	}
	return send(to, instanceName+" will be deleted "+when,
		fmt.Sprintf("%s and everything in it will be deleted %s, on %s.\n\n"+
			"This cannot be undone. Renew it here to keep it:\n\n%s\n",
			instanceName, when, deleteOn.Format("2 January 2006"), portalURL))
}
```

- [ ] **Step 2: Write the lifecycle sweeper**

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

```go
// Package lifecycle marks lapsed Free instances and sends the renewal notices.
//
// It sends; it never deletes. Deletion belongs to the control plane, which is
// the only service that knows what an instance is made of. The two are kept
// apart on purpose: a bug here sends a wrong email, a bug there loses data.
package lifecycle

import (
	"context"
	"log"
	"slices"
	"time"

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

// Interval is how often the sweep runs. Hourly is far finer than the daily
// granularity of the notices, which means a notice goes out within an hour of
// becoming due rather than up to a day late.
const Interval = time.Hour

// Notice keys, recorded on the instance so a restart cannot re-send one.
const (
	noticeExpiring = "expiring"
	noticeExpired  = "expired"
	noticeDelete7  = "delete_7"
	noticeDelete1  = "delete_1"
)

// portalURL is the customer portal address used in notice emails.
var portalURL string

// SetPortalURL is called once at boot.
func SetPortalURL(v string) { portalURL = v }

// reapAfter mirrors the control plane's FREE_INSTANCE_REAP_AFTER so the emails
// can name the real deletion date. Zero means the reaper is off, and the
// deletion notices are then suppressed — promising a deletion that will never
// happen would be a lie, and a scarier one than saying nothing.
var reapAfter time.Duration

// Run performs one sweep: mark lapsed instances, then send whatever notices are
// due. Errors on one instance never stop the others.
func Run(ctx context.Context) error {
	now := time.Now().UTC()

	cur, err := db.Admin("admin_instances").Find(ctx, bson.M{
		"deployment": license.DeploymentCloud,
		"tier":       license.TierFree,
		"status":     bson.M{"$in": []string{models.StatusActive, models.StatusLapsed}},
	})
	if err != nil {
		return err
	}
	var instances []models.Instance
	if err := cur.All(ctx, &instances); err != nil {
		return err
	}

	for _, inst := range instances {
		var lic models.License
		if err := db.Admin("licenses").FindOne(ctx,
			bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err != nil {
			continue // no licence yet; nothing to expire
		}

		// Flip active -> lapsed once the licence is past its expiry.
		if now.After(lic.ExpiresAt) && inst.Status == models.StatusActive {
			if _, err := db.Admin("admin_instances").UpdateOne(ctx,
				bson.M{"instance_id": inst.InstanceID},
				bson.M{"$set": bson.M{"status": models.StatusLapsed}}); err != nil {
				log.Printf("lifecycle: mark %s lapsed: %v", inst.InstanceID, err)
			}
		}

		due := dueNotice(now, lic.ExpiresAt, inst.NoticesSent)
		if due == "" {
			continue
		}
		if err := sendNotice(ctx, inst, lic, due); err != nil {
			log.Printf("lifecycle: notice %s for %s: %v", due, inst.InstanceID, err)
			continue
		}
		if _, err := db.Admin("admin_instances").UpdateOne(ctx,
			bson.M{"instance_id": inst.InstanceID},
			bson.M{"$addToSet": bson.M{"notices_sent": due}}); err != nil {
			log.Printf("lifecycle: record notice %s for %s: %v", due, inst.InstanceID, err)
		}
	}
	return nil
}

// dueNotice returns the most urgent unsent notice, or "".
//
// Most urgent first, so an instance that was missed for a week — because admin
// was down — sends the one that matters now rather than working through a
// backlog of stale warnings.
func dueNotice(now, expires time.Time, sent []string) string {
	deleteOn := expires.Add(reapAfter)

	if reapAfter > 0 {
		if now.After(deleteOn.Add(-24*time.Hour)) && !slices.Contains(sent, noticeDelete1) {
			return noticeDelete1
		}
		if now.After(deleteOn.Add(-7*24*time.Hour)) && !slices.Contains(sent, noticeDelete7) {
			return noticeDelete7
		}
	}
	if now.After(expires) && !slices.Contains(sent, noticeExpired) {
		return noticeExpired
	}
	if now.After(expires.Add(-models.RenewWindow)) && !slices.Contains(sent, noticeExpiring) {
		return noticeExpiring
	}
	return ""
}

func sendNotice(ctx context.Context, inst models.Instance, lic models.License, key string) error {
	if !mail.Enabled() {
		return nil
	}
	var acct models.Account
	if err := db.Admin("accounts").FindOne(ctx,
		bson.M{"account_id": inst.AccountID}).Decode(&acct); err != nil {
		return err
	}
	to := acct.BillingEmail
	deleteOn := lic.ExpiresAt.Add(reapAfter)

	switch key {
	case noticeExpiring:
		return mail.SendExpiring(to, inst.Name, portalURL, lic.ExpiresAt)
	case noticeExpired:
		return mail.SendExpired(to, inst.Name, portalURL, deleteOn)
	case noticeDelete7:
		return mail.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 7)
	case noticeDelete1:
		return mail.SendDeletionWarning(to, inst.Name, portalURL, deleteOn, 1)
	}
	return nil
}

// Start runs the sweep on a ticker until ctx is cancelled.
//
// reapAfterDur must match the control plane's FREE_INSTANCE_REAP_AFTER. If they
// disagree, the emails name a date the reaper does not honour — so they are
// documented as a pair in CLAUDE.md and set together in the compose file.
func Start(ctx context.Context, reapAfterDur time.Duration) {
	reapAfter = reapAfterDur
	go func() {
		runOnce(ctx)
		t := time.NewTicker(Interval)
		defer t.Stop()
		for {
			select {
			case <-ctx.Done():
				return
			case <-t.C:
				runOnce(ctx)
			}
		}
	}()
}

func runOnce(ctx context.Context) {
	runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
	defer cancel()
	if err := Run(runCtx); err != nil {
		log.Printf("lifecycle: %v", err)
	}
}
```

- [ ] **Step 3: Start it at boot**

In `admin/internal/config/config.go`, add `ReapAfter time.Duration` to `Config`, and in `Load()`, after the origins loop:

```go
	// Mirrors the control plane's FREE_INSTANCE_REAP_AFTER so notice emails can
	// name the real deletion date. An unparseable value is refused rather than
	// silently treated as "off": a typo here would quietly stop every deletion
	// warning while the control plane still deletes.
	if v := os.Getenv("FREE_INSTANCE_REAP_AFTER"); v != "" {
		d, err := time.ParseDuration(v)
		if err != nil {
			return Config{}, fmt.Errorf("FREE_INSTANCE_REAP_AFTER %q: %w", v, err)
		}
		c.ReapAfter = d
	}
```

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

In `admin/cmd/main.go`, next to `inject.StartReconciler(ctx)`:

```go
	lifecycle.SetPortalURL(cfg.PublicURL)
	lifecycle.Start(ctx, cfg.ReapAfter)
```

- [ ] **Step 4: Confirm it compiles**

Run:

```sh
sh /tmp/gorun.sh admin go build ./...
```

Expected: no output.

- [ ] **Step 5: Commit**

```bash
git add admin/
git commit -m "feat(admin): lapse sweep and the four renewal notices

Hourly sweep marks expired Free instances lapsed and sends at most one
notice per instance per pass, most urgent first, recorded on the document
so a restart cannot re-send.

Deletion warnings are suppressed when the reaper is off. Promising a
deletion that will never happen is a lie, and a scarier one than silence.

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

---

### Task 5: The reaper

**Files:**

- Create: `server/internal/services/reap.go`
- Modify: `server/cmd/main.go`

**Interfaces:**

- Consumes: `db.Col`, `db.Database`.
- Produces: `services.ReapFreeInstances(ctx) (checked, purged int, err error)`, `services.StartReaper(ctx)`, `services.PurgeInstance(ctx, instanceID string) (map[string]int64, error)`.

This is the only irreversible path in the system. Read the eligibility rule twice.

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

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

```go
package services

import (
	"context"
	"fmt"
	"log"
	"os"
	"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"
)

// ReapInterval is how often eligibility is re-checked. Deletion is measured in
// days, so an hour is ample and keeps the query cheap.
const ReapInterval = time.Hour

// instanceScopedCollections is every collection carrying instance_id.
//
// This list is the reason the reaper lives in the control plane rather than in
// admin: it is knowledge of what an instance is made of, and it belongs in the
// codebase that defines these documents. A collection added without being added
// here leaks rows that outlive their instance.
//
// `instances` is deliberately absent — it is keyed by instance_id, not scoped by
// it, and is deleted last so a crash mid-purge leaves an instance that will be
// retried rather than orphaned rows with no instance.
var instanceScopedCollections = []string{
	"assignments",
	"audit_logs",
	"console_sessions",
	"incidents",
	"instance_oidc",
	"keys",
	"monitor_rollups",
	"monitors",
	"notification_channels",
	"secrets",
	"servers",
	"settings",
	"users",
	"workflow_runs",
	"workflow_steps",
	"workflows",
}

// reapAfter reads FREE_INSTANCE_REAP_AFTER.
//
// An empty or unparseable value returns 0, which disables the reaper. Defaulting
// OFF is the whole safety design: a deployment that never heard of this variable
// must never delete a customer's instance.
func reapAfter() time.Duration {
	v := os.Getenv("FREE_INSTANCE_REAP_AFTER")
	if v == "" {
		return 0
	}
	d, err := time.ParseDuration(v)
	if err != nil {
		log.Printf("reaper: FREE_INSTANCE_REAP_AFTER %q is not a duration; reaper stays OFF", v)
		return 0
	}
	if d <= 0 {
		return 0
	}
	return d
}

// PurgeInstance deletes an instance and every document scoped to it.
//
// Idempotent: re-running over a half-deleted instance completes it. The instance
// document goes last, so an interrupted purge is retried on the next sweep
// instead of leaving rows behind with nothing pointing at them.
func PurgeInstance(ctx context.Context, instanceID string) (map[string]int64, error) {
	counts := map[string]int64{}
	for _, name := range instanceScopedCollections {
		res, err := db.Col(name).DeleteMany(ctx, bson.M{"instance_id": instanceID})
		if err != nil {
			return counts, fmt.Errorf("purge %s: %w", name, err)
		}
		if res.DeletedCount > 0 {
			counts[name] = res.DeletedCount
		}
	}
	res, err := db.Col("instances").DeleteOne(ctx, bson.M{"instance_id": instanceID})
	if err != nil {
		return counts, fmt.Errorf("purge instances: %w", err)
	}
	if res.DeletedCount > 0 {
		counts["instances"] = res.DeletedCount
	}
	return counts, nil
}

// ReapFreeInstances deletes Free cloud instances whose licence expired longer
// ago than the configured window.
//
// Eligibility requires ALL of:
//   - license_tier == "free"   — a paid instance is never eligible
//   - license_expiry present   — an instance that was never licensed, or whose
//     issuance failed, has no expiry and is never eligible whatever its age
//   - license_expiry older than now minus the window
//
// Every one of those is a positive assertion. Nothing is eligible by default,
// which is what makes a missing or stale field fail safe.
func ReapFreeInstances(ctx context.Context) (checked, purged int, err error) {
	window := reapAfter()
	if window == 0 {
		return 0, 0, nil
	}
	cutoff := time.Now().UTC().Add(-window)

	cur, err := db.Col("instances").Find(ctx, bson.M{
		"license_tier":   license.TierFree,
		"license_expiry": bson.M{"$ne": nil, "$lt": cutoff},
	})
	if err != nil {
		return 0, 0, err
	}
	var doomed []struct {
		InstanceID string    `bson:"instance_id"`
		Name       string    `bson:"name"`
		Slug       string    `bson:"slug"`
		Expiry     time.Time `bson:"license_expiry"`
	}
	if err := cur.All(ctx, &doomed); err != nil {
		return 0, 0, err
	}

	for _, d := range doomed {
		checked++

		// Logged BEFORE the delete. Afterwards there is nothing left to
		// describe, and "why did this instance vanish" is the only question
		// anyone will ever ask about this code.
		//
		// The process log is the durable record, not the audit row: the purge
		// deletes this instance's audit_logs along with everything else, so an
		// audit entry written here would delete itself moments later. It is
		// written anyway, because an operator reading audit during the window
		// should see it coming.
		log.Printf("REAPING instance %s (%s, slug=%s) — Free licence expired %s, past the %s window",
			d.InstanceID, d.Name, d.Slug, d.Expiry.Format(time.RFC3339), window)
		LogEvent(d.InstanceID, "instance.reaped", "system", "", "",
			fmt.Sprintf("free licence expired %s, window %s", d.Expiry.Format(time.RFC3339), window))

		counts, err := PurgeInstance(ctx, d.InstanceID)
		if err != nil {
			log.Printf("reaper: purge of %s failed after %v: %v", d.InstanceID, counts, err)
			continue
		}
		purged++
		log.Printf("reaped instance %s: %v", d.InstanceID, counts)
	}
	return checked, purged, nil
}

// StartReaper sweeps once at boot, then on a ticker until ctx is cancelled, and
// logs loudly which mode it is in.
//
// The pass at boot follows inject.StartReconciler's precedent and earns its keep
// the same way: it makes a restart a supported way to force a sweep, which is
// the only way this code can be exercised on demand — the ticker is hourly and
// deletion is measured in days.
func StartReaper(ctx context.Context) {
	window := reapAfter()
	if window == 0 {
		log.Printf("reaper: DISABLED (FREE_INSTANCE_REAP_AFTER is unset or zero)")
		return
	}
	log.Printf("reaper: ENABLED — Free instances are deleted %s after their licence expires", window)

	go func() {
		reapOnce(ctx)

		t := time.NewTicker(ReapInterval)
		defer t.Stop()
		for {
			select {
			case <-ctx.Done():
				return
			case <-t.C:
				reapOnce(ctx)
			}
		}
	}()
}

func reapOnce(ctx context.Context) {
	runCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
	defer cancel()

	checked, purged, err := ReapFreeInstances(runCtx)
	if err != nil {
		log.Printf("reaper: %v", err)
		return
	}
	if purged > 0 {
		log.Printf("reaper: checked %d, purged %d", checked, purged)
	}
}
```

**Note on the audit write:** `services.LogEvent(instanceID, eventType, actor, serverID, keyID, details string)` is the real helper in `server/internal/services/audit.go:14`. It returns nothing, so there is no error to handle — and its row is deleted by the purge moments later anyway. The durable record is the `log.Printf` line above it, which names the instance, slug, expiry and window. Do not invent a new audit helper, and do not try to preserve an audit row for a deleted instance; admin's `admin_audit` is where cross-instance history lives.

- [ ] **Step 2: Start it at boot**

In `server/cmd/main.go`, next to `monitorsched.Start(context.Background())`:

```go
	services.StartReaper(context.Background())
```

- [ ] **Step 3: Confirm it compiles**

Run:

```sh
sh /tmp/gorun.sh server go build ./...
```

Expected: no output.

- [ ] **Step 4: Confirm the collection list is complete**

Run:

```sh
grep -rho 'db\.Col("[a-z_]*"' server/internal/ | sed 's/db.Col("//;s/"//' | sort -u
```

Expected output, exactly:

```
assignments
audit_logs
console_sessions
incidents
instance_oidc
instances
keys
migrations
monitor_rollups
monitors
notification_channels
orgs
secrets
servers
settings
users
workflow_runs
workflow_steps
workflows
```

Every name there must appear in `instanceScopedCollections` except three: `instances` (deleted last, by `instance_id`), `migrations` (has no `instance_id`) and `orgs` (the pre-rename collection, empty after migration 0004). If the real output contains a name not in that list, add it to `instanceScopedCollections` and say so in your report.

- [ ] **Step 5: Commit**

```bash
git add server/
git commit -m "feat(server): reap Free instances whose licence lapsed

The control plane owns deletion because it is the only service that knows
what an instance is made of; mirroring that collection list into admin
would drift, and a drift here deletes the wrong rows.

Defaults OFF. Eligibility is three positive assertions — Free tier, an
expiry that exists, and an expiry past the window — so a missing or stale
field is never eligible. The instance document is deleted last, making an
interrupted purge retryable rather than orphaning rows.

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

---

### Task 6: The reconciler marks reaped instances

**Files:**

- Modify: `admin/internal/inject/inject.go`

**Interfaces:**

- Consumes: `models.StatusDeleted` from Task 2.
- Produces: nothing new.

The reaper deletes the control-plane instance. Admin's `admin_instances` row must stop claiming it is active, or the lifecycle sweep keeps emailing about an instance that no longer exists.

- [ ] **Step 1: Mark vanished instances deleted**

In `admin/internal/inject/inject.go`, inside `Reconcile`'s loop, replace:

```go
		var remote sharedmodels.Instance
		if err := db.Control("instances").FindOne(ctx,
			bson.M{"instance_id": inst.InstanceID}).Decode(&remote); err != nil {
			log.Printf("reconcile: no control-plane instance %s: %v", inst.InstanceID, err)
			continue
		}
```

with:

```go
		var remote sharedmodels.Instance
		if err := db.Control("instances").FindOne(ctx,
			bson.M{"instance_id": inst.InstanceID}).Decode(&remote); err != nil {
			// The control plane's reaper deletes lapsed Free instances. Record
			// that here rather than re-logging it every fifteen minutes forever,
			// and so the lifecycle sweep stops emailing about it.
			if errors.Is(err, mongo.ErrNoDocuments) {
				if _, uErr := db.Admin("admin_instances").UpdateOne(ctx,
					bson.M{"instance_id": inst.InstanceID},
					bson.M{"$set": bson.M{"status": models.StatusDeleted}}); uErr != nil {
					log.Printf("reconcile: mark %s deleted: %v", inst.InstanceID, uErr)
				} else {
					log.Printf("reconcile: instance %s is gone from the control plane; marked deleted", inst.InstanceID)
				}
				continue
			}
			log.Printf("reconcile: no control-plane instance %s: %v", inst.InstanceID, err)
			continue
		}
```

Add `"errors"` and `"go.mongodb.org/mongo-driver/v2/mongo"` to the imports.

- [ ] **Step 2: Confirm it compiles**

Run:

```sh
sh /tmp/gorun.sh admin go build ./...
```

Expected: no output.

- [ ] **Step 3: Commit**

```bash
git add admin/internal/inject/inject.go
git commit -m "feat(admin): reconciler marks reaped instances deleted

Without this the row stays active forever, the reconciler re-logs the
same miss every fifteen minutes, and the lifecycle sweep keeps emailing
about an instance that no longer exists.

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

---

### Task 7: The HQ portal — create and renew

**Files:**

- Create: `adminsite/app/(customer)/instances/new/page.tsx`, `adminsite/app/(customer)/instances/new/CreateForm.tsx`
- Modify: `adminsite/lib/api.ts`, `adminsite/components/InstanceCard.tsx`, `adminsite/app/(customer)/page.tsx`

**Interfaces:**

- Consumes: `POST /api/instances` (Task 2), `POST /api/instances/:id/renew` (Task 3).
- Produces: `api.createInstance(name)`, `api.renewInstance(id)`.

Design rules from `CLAUDE.md` that bind this task: `adminsite` uses only `var(--…)` Tailwind tokens — **no component may carry a hex value**. Licence state never reads by colour alone; every pill carries a shape and a text label. Light is the default theme and must stay so.

- [ ] **Step 1: Extend the API client**

In `adminsite/lib/api.ts`:

- Change `InstanceStatus` to include `"deleted"`:
    ```ts
    export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled" | "deleted";
    ```
- Add `notices_sent?: string[];` to the `Instance` interface.
- Add to the `api` object, after `link`:

    ```ts
      createInstance: (name: string) => post<Instance>("/api/instances", { name }),
      renewInstance: (id: string) => post<License>(`/api/instances/${id}/renew`, {}),
    ```

- [ ] **Step 2: Write the create form**

Create `adminsite/app/(customer)/instances/new/CreateForm.tsx`:

```tsx
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ApiError, api } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";

function slugify(value: string) {
    return value
        .toLowerCase()
        .trim()
        .replace(/[^a-z0-9]+/g, "-")
        .replace(/^-|-$/g, "");
}

export function CreateForm() {
    const [name, setName] = useState("");
    const [error, setError] = useState<string | null>(null);
    const router = useRouter();
    const qc = useQueryClient();

    const create = useMutation({
        mutationFn: () => api.createInstance(name.trim()),
        onSuccess: async () => {
            await qc.invalidateQueries({ queryKey: ["account"] });
            router.push("/");
        },
        onError: (e) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
    });

    const slug = slugify(name);

    return (
        <form
            className="grid max-w-md gap-4"
            onSubmit={(e) => {
                e.preventDefault();
                setError(null);
                if (name.trim()) create.mutate();
            }}
        >
            <Field
                label="Instance name"
                value={name}
                onChange={(e) => setName(e.target.value)}
                placeholder="Northgate Systems"
                required
                error={error ?? undefined}
                hint={`${slug || "your-instance"}.vantage.hostxtra.co.uk`}
            />

            <p className="text-[0.82rem] text-ink-2">You sign in to it with this same email address and password. Changing one does not change the other afterwards.</p>

            <Button type="submit" disabled={create.isPending || !name.trim()}>
                {create.isPending ? "Creating…" : "Create instance"}
            </Button>
        </form>
    );
}
```

The real signatures, already checked — do not change these components:

- `Field` takes `React.InputHTMLAttributes<HTMLInputElement>` plus `label: string`, `hint?: React.ReactNode`, `error?: string`. **It renders its own `<input>` and takes no children**, which is why the slug preview goes in `hint`.
- `Button` takes `React.ButtonHTMLAttributes<HTMLButtonElement>` plus `variant?: "solid" | "line"`, and already carries its own classes. Do not pass a `className` of your own.

- [ ] **Step 3: Write the page**

Create `adminsite/app/(customer)/instances/new/page.tsx`:

```tsx
import type { Metadata } from "next";
import { CreateForm } from "./CreateForm";

export const metadata: Metadata = { title: "New instance" };

export default function NewInstancePage() {
    return (
        <div className="grid gap-6">
            <header className="grid gap-2">
                <h1 className="text-3xl">Create a free instance</h1>
                <p className="max-w-prose text-ink-2">
                    An instance owns its servers, keys, workflows, monitors and secrets. Nothing inside it is visible to any other instance. Free covers three servers, and the licence runs for a month
                    at a time — we email you before it needs renewing.
                </p>
            </header>
            <CreateForm />
        </div>
    );
}
```

- [ ] **Step 4: Add the call to action**

In `adminsite/app/(customer)/page.tsx`, replace the "No instances yet" block's contents with a version that offers the action. Keep the existing wrapper classes; change the inner markup to:

```tsx
<div className="grid max-w-xl gap-3 rounded border border-rule bg-panel p-5">
    <h2 className="text-xl">No instances yet</h2>
    <p className="text-ink-2">
        Create a free cloud instance and we host it, with your licence applied automatically. Or buy a self-hosted licence, install Vantage on your own server, and link it here to get your licence
        file.
    </p>
    <Link href="/instances/new" className="justify-self-start rounded bg-accent px-4 py-2 text-[0.9rem] font-semibold text-accent-ink">
        Create a free instance
    </Link>
</div>
```

And below the instances grid, when the account has instances but no Free cloud one, add:

```tsx
{
    data.instances.length > 0 && !data.instances.some((i) => i.tier === "free" && i.status !== "cancelled" && i.status !== "deleted") && (
        <Link href="/instances/new" className="justify-self-start text-[0.82rem] font-semibold text-accent underline">
            Create a free instance
        </Link>
    );
}
```

- [ ] **Step 5: Renew action and deletion countdown on the card**

In `adminsite/components/InstanceCard.tsx`:

- Make it a client component: add `"use client";` as the first line, and import `useMutation`, `useQueryClient` from `@tanstack/react-query` and `api` from `@/lib/api`.
- The progress bar currently divides by 365, which renders a 30-day Free licence as a 8% sliver. Make the denominator depend on the tier:
    ```tsx
    const termDays = instance.tier === "free" ? 30 : 365;
    ```
    and use `(days / termDays) * 100` in the width calculation.
- Add, after the `state === "expired"` paragraph, a deletion countdown driven by props rather than colour:
    ```tsx
    {
        state === "expired" && deleteInDays !== null && (
            <p className="text-[0.82rem] font-semibold text-expired">
                {deleteInDays <= 0 ? "Scheduled for deletion." : `Deleted in ${deleteInDays} ${deleteInDays === 1 ? "day" : "days"} unless renewed.`}
            </p>
        );
    }
    ```
    Compute `deleteInDays` from a new optional prop `reapAfterDays?: number`: `license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null`. When the prop is absent, render nothing — the UI must not invent a deletion date the backend has not promised.
- Add a Renew button for Free instances inside the window:
    ```tsx
    const qc = useQueryClient();
    const renew = useMutation({
        mutationFn: () => api.renewInstance(instance.instance_id),
        onSuccess: () => qc.invalidateQueries({ queryKey: ["account"] }),
    });
    const canRenew = instance.tier === "free" && license !== undefined && days <= 7;
    ```
    and render it beside the existing link when `canRenew`:
    ```tsx
    {
        canRenew && (
            <button type="button" onClick={() => renew.mutate()} disabled={renew.isPending} className="justify-self-start rounded bg-accent px-3 py-1.5 text-[0.82rem] font-semibold text-accent-ink">
                {renew.isPending ? "Renewing…" : "Renew"}
            </button>
        );
    }
    ```

`daysRemaining` already exists in `adminsite/lib/format.ts`; check its exact signature before use.

- [ ] **Step 6: Confirm it builds**

Run:

```sh
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)/adminsite":/app -w /app node:26-alpine \
  sh -c "npm ci --silent && npm run build"
```

Expected: a successful Next.js build. Type errors here are real — fix them rather than loosening types.

- [ ] **Step 7: Confirm no hex colours were introduced**

Run:

```sh
grep -rn "#[0-9a-fA-F]\{3,6\}" adminsite/components/ adminsite/app/ --include=*.tsx
```

Expected: no matches. `CLAUDE.md` requires `adminsite` components to reference `var(--…)` tokens only.

- [ ] **Step 8: Commit**

```bash
git add adminsite/
git commit -m "feat(adminsite): create and renew a free instance

Adds the create form with a live slug preview, a renew action inside the
seven-day window, and a deletion countdown that renders only when the
backend has actually promised a date.

The progress bar denominator now follows the tier; a 30-day Free licence
was rendering as an 8% sliver against the hardcoded 365.

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

---

### Task 8: The marketing form creates an account

**Files:**

- Create: `site/components/AccountForm.tsx`
- Delete: `site/components/InstanceForm.tsx`
- Modify: `site/lib/submit.ts`, `site/app/start/page.tsx`

**Interfaces:**

- Consumes: `POST /auth/signup` on admin (already exists; unchanged by this phase).
- Produces: `submitAccountSignup(fields)` in `site/lib/submit.ts`.

The form now creates an HQ **account**, not an instance. The live slug preview must go: there is no instance at this point, and showing `your-instance.vantage.hostxtra.co.uk` would promise something the submission does not create.

- [ ] **Step 1: Point signup at admin**

In `site/lib/submit.ts`, add near `SITE_API`:

```ts
const ADMIN_API = (process.env.NEXT_PUBLIC_ADMIN_API_URL ?? "").replace(/\/$/, "");
```

Replace the whole `submitSignup` function with:

```ts
/*
 * Account signup posts to the admin service, not sitesvc. The two form targets
 * are deliberately separate variables rather than one base URL: contact and
 * signup are owned by different services, and an implied shared host is how they
 * silently end up pointing at the wrong one.
 */
export async function submitAccountSignup(fields: { name: string; email: string; password: string; website: string }): Promise<SubmitResult> {
    if (!ADMIN_API) {
        return {
            state: "error",
            message: `Signup is not connected yet. Email ${FALLBACK_ADDRESS} and we will set you up.`,
        };
    }
    return post(`${ADMIN_API}/auth/signup`, fields);
}
```

- [ ] **Step 2: Write the account form**

Create `site/components/AccountForm.tsx` by adapting `site/components/InstanceForm.tsx`:

- Read `site/components/InstanceForm.tsx` first and keep its markup conventions, class names, `Honeypot` usage, error rendering and `MIN_PASSWORD` check.
- Rename the component to `AccountForm`.
- Replace the `instance_name` field with a `name` field labelled **"Your organisation"**, placeholder `Northgate Systems`.
- Delete the `slug` state, the `slugify` helper and the `<span className="hostline">` preview entirely.
- Call `submitAccountSignup` with `{ name, email, password, website }`.
- Change the success panel text to:
    ```
    Check your email.
    We sent a confirmation link. Open it and your Vantage account is ready — then you
    can create your first instance from the portal. The link works once and expires in
    24 hours.
    ```

Then delete `site/components/InstanceForm.tsx`.

- [ ] **Step 3: Update the start page**

In `site/app/start/page.tsx`:

- Import `AccountForm` instead of `InstanceForm` and render it.
- Change the `<h1>` to `Create your account.`
- Change the lede to:
    ```
    Your account is where instances, licences and billing live. Confirm your email and
    you can create a free instance straight away — three servers, hosted by us.
    ```
- Change the "What happens next" specs to four steps, in this order:
    1. **FIRST — Confirm your email.** "We send a link that works once. Your account is created when you open it, not before."
    2. **THEN — Create your instance.** "One click in the portal. It gets its own subdomain and a free licence, and you are its owner."
    3. **THEN — Add a key and a server.** "Paste your public key, then run the install command as root. It expires in an hour and works once."
    4. **THEN — Watch it register.** "The server moves from pending to active on first sync, usually inside 30 seconds."
- Update `metadata.description` to describe an account rather than an instance.

- [ ] **Step 4: Confirm it builds**

Run:

```sh
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)/site":/app -w /app node:26-alpine \
  sh -c "npm ci --silent && npm run build"
```

Expected: a successful build.

- [ ] **Step 5: Confirm nothing still imports the deleted component**

Run:

```sh
grep -rn "InstanceForm\|submitSignup" site/
```

Expected: no matches.

- [ ] **Step 6: Commit**

```bash
git add -A site/
git commit -m "feat(site): /start creates an account, not an instance

The form posts to admin's signup and the slug preview goes: there is no
instance at this point, and previewing one promises something the
submission does not create. Creating the instance is now a step in the
portal, which the page's What happens next panel spells out.

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

---

### Task 9: Retire sitesvc's signup

**Files:**

- Delete: `sitesvc/internal/api/signup.go`
- Modify: `sitesvc/internal/api/api.go`, `sitesvc/internal/store/store.go`, `sitesvc/internal/models/`, `sitesvc/cmd/main.go`

**Interfaces:**

- Consumes: nothing.
- Produces: sitesvc serving `POST /api/contact` and nothing else.

**Do this task last, and do not deploy it until Task 8 is live.** The cutover is staged for a reason: sitesvc's `/api/verify` must keep answering until every outstanding pending signup has expired, or someone's verification link breaks. The 24-hour wait belongs to the deployment, not to this commit — but the commit is what makes the wait necessary, so it goes last.

- [ ] **Step 1: Remove the handlers**

- Delete `sitesvc/internal/api/signup.go`.
- In `sitesvc/internal/api/api.go`, remove the `/api/signup` and `/api/verify` route registrations, the `signups` rate limiter field and its initialisation, and the `publicURL` / `appLoginURL` fields if nothing else uses them. Keep `/api/contact` and the health endpoint exactly as they are.

- [ ] **Step 2: Remove the store's signup half**

In `sitesvc/internal/store/store.go`, delete `CreatePending`, `Verify`, `EmailTaken`, `randomToken`, `hashToken`, `PendingTTL`, the `ErrEmailTaken` / `ErrBadToken` / `ErrNameRejected` vars, and the `site_pending_signups` index block inside `EnsureIndexes`.

Keep `Connect`, `DatabaseName`, `col` and `RequireMigratedDatabase`.

`EnsureIndexes` should now only call `indexes.EnsureCoreIndexes`. Keep that call: it is cheap, it is idempotent, and it means sitesvc does not depend on another service having started first.

Delete `sitesvc/internal/models/` if `PendingSignup` was its only type; otherwise delete just that type.

- [ ] **Step 3: Drop the unused config**

In `sitesvc/cmd/main.go`, remove `APP_LOGIN_URL` and any signup-only wiring. Leave `PUBLIC_URL` if the contact flow still uses it; remove it if not.

- [ ] **Step 4: Confirm it compiles and the signup surface is gone**

Run:

```sh
sh /tmp/gorun.sh sitesvc go build ./...
```

Expected: no output.

Run:

```sh
grep -rn "site_pending_signups\|CreatePending\|handleSignup\|handleVerify" sitesvc/
```

Expected: no matches.

- [ ] **Step 5: Run `go mod tidy` with GOWORK off**

Removing code may orphan a dependency:

```sh
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \
  -w /src/sitesvc -e GOWORK=off golang:1.26 go mod tidy
sh /tmp/gorun.sh sitesvc go build ./...
```

Expected: build still clean. `GOWORK=off` is mandatory — in workspace mode `tidy` drops `require` lines and the Docker build then fails with "missing go.sum entry".

- [ ] **Step 6: Commit**

```bash
git add -A sitesvc/
git commit -m "refactor(sitesvc): remove signup and verification

Account creation moved to admin, which owns accounts, and the marketing
form now posts there. sitesvc keeps the contact mailer only.

DEPLOY LAST: sitesvc's verify endpoint must stay live until every
outstanding pending signup has expired, or an in-flight verification link
breaks. Do not roll this out until the site change has been live 24 hours
and site_pending_signups is empty.

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

---

### Task 10: Configuration, docs and end-to-end verification

**Files:**

- Modify: `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml`, `CLAUDE.md`

This task proves the phase. With no test suite, this transcript is the only evidence — run it in full.

- [ ] **Step 1: Wire the compose file**

In `deploy/docker-compose.site.yml`:

- On `admin`, add `APP_LOGIN_URL: "https://{slug}.vantage.hostxtra.co.uk/login"` and `FREE_INSTANCE_REAP_AFTER: "336h"`.
- On `server`, add `FREE_INSTANCE_REAP_AFTER: "336h"`.
- On `sitesvc`, remove `APP_LOGIN_URL`.

**Do not add `FREE_INSTANCE_REAP_AFTER` to `deploy/docker-compose.yml`.** A self-hosted deployment must never reap. Confirm after editing:

```sh
grep -n "FREE_INSTANCE_REAP_AFTER" deploy/docker-compose.yml
```

Expected: no matches.

The two values must match: admin uses it to name the deletion date in emails, the server uses it to decide. `CLAUDE.md` documents them as a pair in Step 3.

- [ ] **Step 2: Add the site build arg**

In `.gitea/workflows/server-deploy.yml`, wherever the `site` image is built, pass `ADMIN_API_URL` through as a build arg alongside the existing `SITE_API_URL`, mapping to `NEXT_PUBLIC_ADMIN_API_URL`. Read how `SITE_API_URL` is wired and mirror it exactly — including any `site/Dockerfile` `ARG`/`ENV` lines it needs.

- [ ] **Step 3: Update `CLAUDE.md`**

- In **Subsystems → Marketing site and sitesvc**, change the table so only Contact remains, and state that account signup posts to admin.
- In the **Signup and verification** section, replace the sitesvc-centred description with the account-first flow: signup creates an HQ account, the instance is created from the portal afterwards, and `site_pending_signups` is gone.
- In **Admin REST API**, add `POST /api/instances` and `POST /api/instances/:id/renew` to the customer-session block.
- In **Environment Variables (server)**, add:
  | `FREE_INSTANCE_REAP_AFTER` | no | duration past a Free licence's expiry before the instance and all its data are deleted. **Empty disables the reaper, and empty is the default.** Set to `336h` in `docker-compose.site.yml` only — a self-hosted deployment must never reap. Must match admin's value, which only names the date in warning emails |
- In **Design Decisions**, add:
    - **Deletion lives in the control plane** — admin sends the warnings because it knows the billing address; the control plane performs the delete because it is the only service that knows which collections carry `instance_id`. Mirroring that list into admin would drift, and a drift there deletes the wrong rows.
- Update the sentence in **Admin REST API** or **Security** that describes admin's control-plane access as read-only apart from three licence fields, to name `cloudprov` as the second write path.

- [ ] **Step 4: Build every image**

```sh
MSYS_NO_PATHCONV=1 docker build -q -f server/Dockerfile -t vantage-server:p2 .
MSYS_NO_PATHCONV=1 docker build -q -f admin/Dockerfile -t vantage-admin:p2 .
MSYS_NO_PATHCONV=1 docker build -q -f sitesvc/Dockerfile -t vantage-sitesvc:p2 .
```

Expected: three image IDs. A "missing go.sum entry" failure means Step 5 of Task 9 was run in workspace mode.

- [ ] **Step 5: Start scratch infrastructure**

```sh
MSYS_NO_PATHCONV=1 docker run -d --name p2-redis -p 6390:6379 redis:7
MSYS_NO_PATHCONV=1 docker run -d --name p2-mongo -p 27024:27017 mongo:7
sleep 6
MSYS_NO_PATHCONV=1 docker run -d --name p2-server -p 8092:8080 \
  -e MONGO_URI=mongodb://host.docker.internal:27024 -e MONGO_DB=p2 \
  -e GRPC_HOST=localhost:9090 -e REDIS_ADDR=host.docker.internal:6390 \
  --add-host host.docker.internal:host-gateway vantage-server:p2
sleep 6
MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | grep -i reaper
```

Expected: `reaper: DISABLED (FREE_INSTANCE_REAP_AFTER is unset or zero)`. **This is the safety default and must appear.**

- [ ] **Step 6: Start admin with a real signing key**

`licensing.Issue` actually signs here, so a placeholder will not do. Generate one and keep it for the rest of this task:

```sh
export LK=$(MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \
  -w /src/shared golang:1.26 go run ./cmd/lkctl genkey 2>/dev/null | tail -1)
echo "key length: ${#LK}"
```

Check `shared/cmd/lkctl`'s real subcommand names first (`go run ./cmd/lkctl --help`) and use whatever it provides to produce a signing key. If `lkctl` cannot generate one, say so and stop — issuing licences cannot be verified without it.

```sh
MSYS_NO_PATHCONV=1 docker run -d --name p2-admin -p 8094:8083 \
  -e ADMIN_MONGO_URI=mongodb://host.docker.internal:27024/p2_admin \
  -e CONTROL_MONGO_URI=mongodb://host.docker.internal:27024/p2 \
  -e REDIS_ADDR=host.docker.internal:6390 \
  -e LICENSE_SIGNING_KEY="$LK" \
  -e PUBLIC_URL=http://localhost:8094 -e ADMIN_ORIGIN=http://localhost:3004 \
  -e APP_LOGIN_URL='https://{slug}.vantage.test/login' \
  --add-host host.docker.internal:host-gateway vantage-admin:p2
sleep 6
curl -s http://localhost:8094/healthz
```

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

- [ ] **Step 7: Create an account and verify it**

SMTP is not configured, so signup will refuse to send. Create the account and mark it verified directly:

```sh
MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
  mongosh "mongodb://host.docker.internal:27024/p2_admin" --quiet --eval '
    const acct = { account_id: "acct-test", name: "Test Co",
      billing_email: "owner@example.com", status: "active", created_at: new Date() };
    db.accounts.insertOne(acct);
    // bcrypt cost-12 hash of "hunter2hunter2"
    db.customer_users.insertOne({ user_id: "cu-test", account_id: "acct-test",
      email: "owner@example.com",
      password_hash: "$2a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyDF2xrjRkPYVe",
      verified_at: new Date(), created_at: new Date() });
    print("seeded account + verified user");'
```

That hash is a well-known bcrypt test vector; if the sign-in below returns 401, generate a real cost-12 hash instead and say so in your report.

```sh
curl -s -X POST http://localhost:8094/auth/login -H 'Content-Type: application/json' \
  -c /tmp/p2.jar -d '{"email":"owner@example.com","password":"hunter2hunter2"}'
```

Expected: `{"kind":"customer","email":"owner@example.com"}`.

- [ ] **Step 8: Seed the Free plan and create an instance**

Admin seeds `plans` at boot from `shared/license`; confirm Free is there:

```sh
MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
  mongosh "mongodb://host.docker.internal:27024/p2_admin" --quiet --eval \
  'printjson(db.plans.find({},{tier:1,deployment:1,_id:0}).toArray())'
```

Expected: includes `{tier:"free", deployment:"cloud"}`. If `plans` is empty, admin's seeding did not run — report it.

```sh
curl -s -X POST http://localhost:8094/api/instances -b /tmp/p2.jar \
  -H 'Content-Type: application/json' -d '{"name":"Northgate Systems"}'
```

Expected: `201` with an instance whose `slug` is `northgate-systems`, `deployment` `cloud`, `status` `active`, and a `tier` of `free`.

- [ ] **Step 9: Confirm the whole chain landed**

```sh
MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
  mongosh "mongodb://host.docker.internal:27024" --quiet --eval '
    const c = db.getSiblingDB("p2"), a = db.getSiblingDB("p2_admin");
    const inst = c.instances.findOne({slug:"northgate-systems"});
    print("control instance: " + (inst ? inst.instance_id : "MISSING"));
    const u = c.users.findOne({instance_id: inst.instance_id});
    print("owner: " + u.email + " role=" + u.role + " auth_source=" + u.auth_source + " hq_user_id=" + u.hq_user_id);
    print("license_tier: " + inst.license_tier + " expiry: " + inst.license_expiry);
    print("blob present: " + (inst.license_blob ? inst.license_blob.length > 0 : false));
    const ai = a.admin_instances.findOne({instance_id: inst.instance_id});
    print("admin row: status=" + ai.status + " tier=" + ai.tier + " account=" + ai.account_id);
    print("licences recorded: " + a.licenses.countDocuments({instance_id: inst.instance_id}));'
```

Expected, all of them:

- `owner: owner@example.com role=owner auth_source=hq hq_user_id=cu-test`
- `license_tier: free`, an expiry roughly one month and three days out
- `blob present: true` — this proves injection ran
- `admin row: status=active tier=free account=acct-test`
- `licences recorded: 1`

- [ ] **Step 10: Confirm the owner can sign in to the new instance**

```sh
curl -s -X POST http://localhost:8092/auth/login -H 'Host: northgate-systems.vantage.test' \
  -H 'Content-Type: application/json' -c /tmp/p2i.jar \
  -d '{"email":"owner@example.com","password":"hunter2hunter2"}'
curl -s http://localhost:8092/auth/me -H 'Host: northgate-systems.vantage.test' -b /tmp/p2i.jar
```

Expected: `{"ok":true}`, then a body naming the Northgate instance. This is the payoff of the whole phase — the HQ password works on the instance.

- [ ] **Step 11: Confirm the Free cap**

```sh
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:8094/api/instances \
  -b /tmp/p2.jar -H 'Content-Type: application/json' -d '{"name":"Second One"}'
```

Expected: `409`.

```sh
MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
  mongosh "mongodb://host.docker.internal:27024/p2" --quiet --eval \
  'print("instances: " + db.instances.countDocuments({}))'
```

Expected: `instances: 1`. The refusal must leave nothing behind.

- [ ] **Step 12: Confirm renewal refuses outside the window**

```sh
INST=$(MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
  mongosh "mongodb://host.docker.internal:27024/p2" --quiet --eval \
  'print(db.instances.findOne({slug:"northgate-systems"}).instance_id)')
curl -s -X POST "http://localhost:8094/api/instances/$INST/renew" -b /tmp/p2.jar
```

Expected: `400` with "not due yet".

- [ ] **Step 13: Confirm renewal works inside the window**

Move the licence's expiry to two days out, then renew:

```sh
MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
  mongosh "mongodb://host.docker.internal:27024/p2_admin" --quiet --eval '
    const soon = new Date(Date.now() + 2*24*3600*1000);
    db.licenses.updateMany({}, {$set:{expires_at: soon}});
    print("expiry moved to " + soon.toISOString());'

curl -s -X POST "http://localhost:8094/api/instances/$INST/renew" -b /tmp/p2.jar
```

Expected: `200` with a new licence whose `expires_at` is about a month out, and a `reason` of `renewal`.

```sh
MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
  mongosh "mongodb://host.docker.internal:27024" --quiet --eval '
    const a = db.getSiblingDB("p2_admin"), c = db.getSiblingDB("p2");
    print("licences: " + a.licenses.countDocuments({}));
    print("superseded: " + a.licenses.countDocuments({superseded_by:{$exists:true,$ne:""}}));
    const inst = c.instances.findOne({slug:"northgate-systems"});
    print("injected expiry: " + inst.license_expiry);'
```

Expected: 2 licences, 1 superseded, and the injected expiry matching the new one.

- [ ] **Step 14: Confirm the reaper stays off, then deletes only what it should**

Restart the server WITH the window set, and an instance whose licence expired long ago:

```sh
MSYS_NO_PATHCONV=1 docker rm -f p2-server
MSYS_NO_PATHCONV=1 docker run -d --name p2-server -p 8092:8080 \
  -e MONGO_URI=mongodb://host.docker.internal:27024 -e MONGO_DB=p2 \
  -e GRPC_HOST=localhost:9090 -e REDIS_ADDR=host.docker.internal:6390 \
  --add-host host.docker.internal:host-gateway vantage-server:p2
sleep 6
MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | grep -i reaper
```

Expected: `reaper: ENABLED — Free instances are deleted 336h0m0s after their licence expires`.

Now seed three instances the reaper must treat differently, plus rows scoped to the doomed one:

```sh
MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
  mongosh "mongodb://host.docker.internal:27024/p2" --quiet --eval '
    const old = new Date(Date.now() - 400*3600*1000);   // past the 336h window
    const recent = new Date(Date.now() - 100*3600*1000); // expired, inside the window
    db.instances.insertOne({instance_id:"doomed", name:"Doomed", slug:"doomed",
      license_tier:"free", license_expiry: old, created_at:new Date()});
    db.instances.insertOne({instance_id:"safe-recent", name:"Recent", slug:"recent",
      license_tier:"free", license_expiry: recent, created_at:new Date()});
    db.instances.insertOne({instance_id:"safe-paid", name:"Paid", slug:"paid",
      license_tier:"professional", license_expiry: old, created_at:new Date()});
    db.instances.insertOne({instance_id:"safe-nolicence", name:"NoLic", slug:"nolic",
      created_at:new Date()});
    db.users.insertOne({user_id:"u-doomed", instance_id:"doomed", email:"d@example.com",
      role:"owner", auth_source:"local", created_at:new Date()});
    db.servers.insertOne({instance_id:"doomed", name:"srv"});
    db.secrets.insertOne({instance_id:"doomed", group:"g"});
    print("seeded 4 instances");'
```

Now force a sweep. `StartReaper` sweeps once at boot, so a restart runs it immediately:

```sh
MSYS_NO_PATHCONV=1 docker restart p2-server
sleep 10
MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | grep -i "REAPING\|reaped\|reaper:"
```

Expected: a `REAPING instance doomed (Doomed, slug=doomed)` line naming the expiry and window, then `reaped instance doomed: map[...]` listing the deleted counts, then `reaper: checked 1, purged 1`.

**`checked` must be 1.** If it is higher, something ineligible was selected — stop and report it. This is the one bug in this phase that destroys customer data.

- [ ] **Step 15: Confirm the purge deleted exactly the doomed instance**

```sh
MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
  mongosh "mongodb://host.docker.internal:27024/p2" --quiet --eval '
    print("doomed instance:  " + db.instances.countDocuments({instance_id:"doomed"}));
    print("doomed users:     " + db.users.countDocuments({instance_id:"doomed"}));
    print("doomed servers:   " + db.servers.countDocuments({instance_id:"doomed"}));
    print("doomed secrets:   " + db.secrets.countDocuments({instance_id:"doomed"}));
    print("--- survivors ---");
    print("safe-recent:      " + db.instances.countDocuments({instance_id:"safe-recent"}));
    print("safe-paid:        " + db.instances.countDocuments({instance_id:"safe-paid"}));
    print("safe-nolicence:   " + db.instances.countDocuments({instance_id:"safe-nolicence"}));
    print("northgate:        " + db.instances.countDocuments({slug:"northgate-systems"}));
    print("northgate users:  " + db.users.countDocuments({email:"owner@example.com"}));'
```

Expected, every line:

- all four `doomed` counts are `0`
- all four survivor counts are `1`

A surviving `doomed` row means `instanceScopedCollections` is missing a collection. A missing survivor means the eligibility rule is too broad — either is a stop-and-report.

- [ ] **Step 15b: Confirm the purge is idempotent**

Restart again. `doomed` is already gone, so the sweep must find nothing and must not error:

```sh
MSYS_NO_PATHCONV=1 docker restart p2-server
sleep 10
MSYS_NO_PATHCONV=1 docker logs p2-server 2>&1 | tail -20 | grep -i "reaper\|panic\|error"
```

Expected: `reaper: ENABLED …` and nothing else — no `REAPING`, no panic, no error.

- [ ] **Step 15c: Confirm the collection list is exhaustive**

This is the check that catches a leak nothing else would:

```sh
MSYS_NO_PATHCONV=1 docker run --rm --add-host host.docker.internal:host-gateway mongo:7 \
  mongosh "mongodb://host.docker.internal:27024/p2" --quiet --eval '
    const names = db.getCollectionNames().sort();
    print("collections present: " + names.join(", "));
    let orphanable = [];
    names.forEach(n => {
      if (["instances","migrations","orgs"].includes(n)) return;
      const s = db.getCollection(n).findOne({instance_id:{$exists:true}});
      if (s) orphanable.push(n);
    });
    print("carry instance_id: " + orphanable.join(", "));'
```

Every name in `carry instance_id` must appear in `instanceScopedCollections` in `server/internal/services/reap.go`. Compare them by eye and state the result explicitly in your report. Any collection present there and absent from the list leaks rows that outlive their instance.

- [ ] **Step 16: Tear down**

```sh
MSYS_NO_PATHCONV=1 docker rm -f p2-server p2-admin p2-mongo p2-redis
```

- [ ] **Step 17: Commit**

```bash
git add deploy/ .gitea/ CLAUDE.md
git commit -m "docs: phase 2 configuration and the reaper's containment

FREE_INSTANCE_REAP_AFTER is set only in docker-compose.site.yml, so a
self-hosted deployment can never reap. Admin and server must carry the
same value: one names the deletion date in warnings, the other acts on it.

Records that admin now has a second control-plane write path, cloudprov,
and that deletion lives in the control plane because that is where the
knowledge of what an instance is made of belongs.

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

---

## Done when

- A verified HQ customer can create a Free cloud instance in one request, and the control plane, `admin_instances`, the licence and the injected blob all agree.
- The instance owner signs in to their new instance with their HQ password, and their control-plane row carries `auth_source: hq` and `hq_user_id`.
- A second Free instance is refused `409` and leaves nothing behind.
- Renewal refuses outside the window, works inside it, supersedes the old licence and re-injects.
- The reaper logs `DISABLED` with no configuration, and its eligibility query selects exactly the one instance that is Free, licensed, and past the window.
- Every collection carrying `instance_id` appears in `instanceScopedCollections`.
- `deploy/docker-compose.yml` does not mention `FREE_INSTANCE_REAP_AFTER`.
- `grep -rn "InstanceForm\|submitSignup" site/` and `grep -rn "site_pending_signups" sitesvc/` both return nothing.

**Not proven by this plan:** the notice emails, because the harness has no SMTP. The lapse sweep and the notice _selection_ run, but nothing is delivered. Watch the first real send on deployment, and confirm a notice is recorded in `notices_sent` so it does not repeat.

## Deployment order

This phase is not safe to roll out in an arbitrary order:

1. `server` and `admin` first, with `FREE_INSTANCE_REAP_AFTER` **unset**, so creation and renewal work while nothing can be deleted.
2. `site`, pointing at admin's signup.
3. Wait 24 hours with sitesvc's verify still live, until `site_pending_signups` is empty.
4. `sitesvc` without signup.
5. Only then set `FREE_INSTANCE_REAP_AFTER=336h` on both `server` and `admin`, once you have watched a notice email actually send.

## Not in this phase

Account roles, invitations, instance membership, per-instance grants, password propagation, and the `web/` read-only treatment for `hq`-sourced users. Those are phase 3.
