docs: Design for instance rename in Vantage HQ

This commit is contained in:
2026-08-12 09:46:08 +00:00
parent 84f9587b9a
commit 21786fa1a8
@@ -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 340 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 `<old-slug> -> <new-slug>`, 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.