From 21786fa1a80fb3f2b60221d6bbb641e5ceb6b334 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 12 Aug 2026 09:46:08 +0000 Subject: [PATCH] docs: Design for instance rename in Vantage HQ --- .../2026-08-12-instance-rename-design.md | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-12-instance-rename-design.md diff --git a/docs/superpowers/specs/2026-08-12-instance-rename-design.md b/docs/superpowers/specs/2026-08-12-instance-rename-design.md new file mode 100644 index 0000000..eb95885 --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-instance-rename-design.md @@ -0,0 +1,226 @@ +# Instance rename in Vantage HQ + +**Date:** 2026-08-12 +**Status:** approved, not yet implemented + +## Problem + +A cloud instance is named once, at creation, and never again. The name is +chosen in the first thirty seconds of a customer's relationship with the +product — before they have decided whether this is "Acme" or "Acme +Production" — and it is the name that becomes their DNS host, appears in every +sign-in link and heads every page of their control plane. Today the only way to +change it is to create a second instance and move, or to open a support ticket +that has no tooling behind it. + +## What a rename is + +One customer-initiated action on a **cloud** instance: a new name, from which a +new slug is derived, which moves the instance to a new DNS host. + +Name and slug move together. The slug is re-derived through +`provision.BaseSlug`, so the rules that named the instance at creation are the +rules that rename it — the same reserved-label list, the same 3–40 character +bound, the same `Slugify` collapse of non-alphanumeric runs. There is no +separate slug field for the customer to edit, because two fields invite the +state where the name says one thing and the host says another, and that +divergence is exactly what a rename exists to fix. + +A licence binds an instance **UUID**, not a slug. A rename therefore issues no +licence, calls Paddle not at all, and consumes no relink. This is the property +that makes the whole feature cheap, and it should be stated in any future change +that tempts someone to touch the licence from this path. + +### What breaks, deliberately + +- **The old host stops working.** The old slug is released the moment the rename + commits; another account may take it. Bookmarks, saved sign-in links and any + agent install one-liner that named the web host are stale. Agents themselves + are unaffected — they dial `GRPC_HOST`, which is not per-tenant. +- **The old host keeps working for up to 60 seconds.** `server/internal/auth/instancehost.go` + caches slug-to-instance lookups for 60s, and admin has no path to invalidate + another process's memory. This is a lag, not a leak: the stale entry maps the + old slug to the same instance, so nothing is exposed that was not exposed a + minute earlier. Adding a cross-service invalidation channel for a 60-second + window is not worth the coupling. +- **The customer must sign in again.** `km_session` is set with no `Domain` + attribute, so it is host-only and does not follow the instance to its new + subdomain. The UI says so rather than letting the customer discover it. + +## Scope + +| | Customer (owner or admin) | Staff | +|---|---|---| +| Cloud instance | rename, 24h cooldown | rename, no cooldown | +| Self-hosted instance | refused, 409 | name only; there is no slug | +| Cloud placeholder | refused, 409 | refused, 409 | + +Self-hosted is refused on the customer side for the same reason the member +endpoints refuse it: there is no control-plane row to write. The install is the +customer's, on their own database, and admin cannot reach it. Staff may still +correct the label on admin's own row, because that label is what staff search +by. + +## Data flow + +Two writes, in this order: + +1. **Control plane `instances`** — `{name, slug}`. +2. **Admin `admin_instances`** — `{name, slug, renamed_at}`. + +The control plane goes first because `instances.slug` carries the unique index, +and that index is what actually decides a race between two accounts reaching for +the same name. Deciding it anywhere else would be guessing. + +If the second write fails, the first is rolled back best-effort — restoring the +previous name and slug — and the request answers 500. Leaving them divergent +would have HQ print a host that is not the host, which is worse than a failed +rename. + +## Backend + +### `shared/provision/instance.go` + +```go +// ErrSlugTaken means the derived slug belongs to another instance. +var ErrSlugTaken = errors.New("slug taken") + +// RenameInstance changes an instance's name and re-derives its slug. +func RenameInstance(ctx context.Context, db *mongo.Database, instanceID, name string) (*models.Instance, error) +``` + +It lives beside `CreateInstanceWithID` so slug derivation keeps one home, and it +behaves as that function's rules imply: + +- `BaseSlug(name)` failures wrap `ErrNameRejected` — too short, too long, + reserved. +- The derived slug is compared against the instance's current one. If they are + equal, only the name is written; a cosmetic capitalisation change is not a + move, and must not fail on its own slug. +- **No `-2` suffix loop.** Creation appends a counter because the customer is + waiting on a instance and any free slug will do. A rename is a request for a + specific host, and silently landing the customer on `acme-2` is a worse answer + than refusing. +- A duplicate-key error on the update surfaces as `ErrSlugTaken`, exactly as the + create path treats it as "that slug is taken". The pre-check is a courtesy; + the index is the boundary. + +### `admin/internal/cloudprov` + +```go +func RenameInstance(ctx context.Context, instanceID, name string) (*sharedmodels.Instance, error) +``` + +A thin wrapper over `provision.RenameInstance` on `db.ControlDB()`. It writes +`instances` and nothing else, so admin's documented control-plane write boundary +— `instances` and `users`, from `cloudprov` and `inject` only — is unchanged. + +### `admin/internal/models` + +`Instance` gains: + +```go +// RenamedAt is when this instance last changed name, and backs the 24h +// customer cooldown. The cooldown is admin's policy, so it lives on admin's +// row rather than in the control plane, which has no opinion about how often +// a customer may move. +RenamedAt *time.Time `bson:"renamed_at,omitempty" json:"renamed_at,omitempty"` +``` + +A pointer because absent means "never renamed", and a zero `time.Time` would +read as 1 January year 1 — far enough in the past that the cooldown is inert, +but only by accident. + +### `PUT /api/instances/:id/name` (customer) + +Mounted in the `cust` group behind `auth.RequireAccountRole(owner, admin)`, and +resolving the instance through `ownedInstance` like every other instance route, +so another account's instance answers 404 rather than 403. + +Body: `{"name": "..."}`, trimmed before use. + +Refusals, in the order checked: + +| Condition | Status | Body | +|---|---|---| +| `deployment != cloud` | 409 | `self_hosted` | +| `placeholder` | 409 | instance is not provisioned yet | +| within 24h of `renamed_at` | 429 | includes the UTC time it unlocks | +| `provision.ErrNameRejected` | 422 | the wrapped reason, verbatim | +| `provision.ErrSlugTaken` | 409 | that name is already in use | + +Success returns `{"instance_id", "name", "slug", "host"}` and writes an audit +entry `instance.renamed` with detail ` -> `, so the history +of a host is answerable from the audit log alone. + +`host` is composed server-side from the slug and the deployment's instance +domain, so the portal is not the only place that knows how a host is spelled. + +### `PUT /api/staff/instances/:id/name` + +The same core, without the cooldown, actor recorded as the staff user. On a +self-hosted instance it updates `admin_instances.name` only and does not call +`cloudprov`. + +## Frontend (`adminsite`) + +### `lib/slug.ts` + +A TypeScript mirror of `provision.Slugify` and the length/reserved checks, used +only to preview the resulting host while the customer types. It carries the same +warning as `web/lib/targets.ts`: it is a second implementation and must change in +the same commit as the Go one. The preview can disagree with the server — the +409 is the answer that counts. + +### `components/RenameInstanceModal.tsx` + +Prefilled with the current name. Below the input, a live line reading +`acme-ltd.vantage.hostxtra.co.uk` as the customer types, and a note that they +will need to sign in again on the new host. Submit is disabled while the derived +slug is unchanged or invalid. + +Mounted from a `Rename` action in `PageHeader` on +`app/(customer)/instances/[id]/page.tsx`, rendered only when the instance is +cloud and `account_role` is `owner` or `admin`. The staff instance page mounts +the same component against the staff route. + +`InstanceRecord` on the Overview page is not touched: it stays a summary, and +the rename is a decision that deserves the detail page. + +### After a successful rename + +Invalidate `["account"]`, close the modal, and let the page redraw with the new +name and host. The Console rail card shows the new host, with a note: + +> This instance now lives at `acme-ltd.vantage.hostxtra.co.uk`. You will need to +> sign in again there. + +**No automatic redirect.** Sending the browser to the new host lands the customer +on a login screen with no explanation, having just lost the HQ page they were +standing on. The link is right there; they click it when they are ready. + +## Verification + +The repository has no Go test suite, so verification is build plus manual +exercise, matching existing practice: + +- `go build ./...` in `shared` and `admin`; `npm run build` in `adminsite`. +- Rename a cloud instance; confirm `instances` and `admin_instances` agree on + name and slug. +- The new host serves a login page; the old host stops resolving to the instance + within ~60 seconds. +- A second rename within 24 hours answers 429. +- A rename onto an occupied slug answers 409 and changes nothing. +- A rename attempt on a self-hosted instance from the customer portal answers + 409. +- The audit log carries `instance.renamed` with both slugs. + +## Out of scope + +- Slug aliases or redirects from the old host. The control plane resolves one + slug per instance, and an alias table is a second identity to keep correct for + the sake of stale bookmarks. +- Renaming from inside the control plane's own `/settings`. HQ owns instance + identity, the same way it owns licences and `hq`-sourced users; a second + writer would need the same collision handling and the same cooldown. +- Any change to the licence, subscription or Paddle line items.