From cdc50b7aaf1d3effe9cc3e4a13f34f3d4eecfb01 Mon Sep 17 00:00:00 2001
From: mrhid6
Date: Wed, 12 Aug 2026 11:08:36 +0000
Subject: [PATCH] fix: Harden instance rename against interleaving and lost
unwinds
---
admin/internal/api/customer.go | 104 +++++++++++++++---
admin/internal/api/staff.go | 44 ++++++--
admin/internal/cloudprov/cloudprov.go | 5 +-
admin/internal/models/models.go | 4 +-
.../app/(customer)/instances/[id]/page.tsx | 8 ++
.../app/(staff)/staff/instances/[id]/page.tsx | 7 ++
adminsite/components/RenamePanel.tsx | 60 ++++++----
adminsite/lib/api.ts | 2 +
.../2026-08-12-instance-rename-design.md | 15 ++-
shared/provision/instance.go | 35 +++---
10 files changed, 220 insertions(+), 64 deletions(-)
diff --git a/admin/internal/api/customer.go b/admin/internal/api/customer.go
index e199043..6c6df86 100644
--- a/admin/internal/api/customer.go
+++ b/admin/internal/api/customer.go
@@ -1,6 +1,7 @@
package api
import (
+ "context"
"errors"
"fmt"
"log"
@@ -23,6 +24,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
+ "go.mongodb.org/mongo-driver/v2/mongo"
)
// ownedInstance resolves an instance and confirms the session's account owns it.
@@ -576,50 +578,126 @@ func renameInstance(c *gin.Context) {
return
}
- if inst.RenamedAt != nil {
- if until := inst.RenamedAt.Add(models.RenameCooldown); time.Now().UTC().Before(until) {
+ ctx := c.Request.Context()
+
+ // The unwind and the audit write run on a context detached from the request.
+ // The commonest reason the admin-side write fails at all is the caller
+ // walking away, and an unwind sharing that context fails with it — leaving
+ // the control plane renamed and admin's row not, which is the exact
+ // divergence this handler is arranged to prevent.
+ bgCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
+ defer cancel()
+
+ // Claim the cooldown atomically BEFORE the control-plane call. Checking it
+ // and then acting lets two parallel PUTs both pass the check and then
+ // interleave their two-database writes, which ends with the two databases
+ // disagreeing about the host — a worse outcome than either rename losing.
+ // The conditional update IS the cooldown; there is no second reading of it.
+ now := time.Now().UTC()
+ var claimed models.Instance
+ err := db.Admin("admin_instances").FindOneAndUpdate(ctx,
+ bson.M{
+ "instance_id": inst.InstanceID,
+ "account_id": inst.AccountID,
+ "$or": []bson.M{
+ {"renamed_at": bson.M{"$exists": false}},
+ {"renamed_at": bson.M{"$lte": now.Add(-models.RenameCooldown)}},
+ },
+ },
+ bson.M{"$set": bson.M{"renamed_at": now}}).Decode(&claimed)
+ if err != nil {
+ if !errors.Is(err, mongo.ErrNoDocuments) {
+ log.Printf("renameInstance: claiming the cooldown on %s: %v", inst.InstanceID, err)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
+ return
+ }
+ // No match means the cooldown is live or the row has gone; only a
+ // re-read tells those apart, and they are different answers.
+ var cur models.Instance
+ if err := db.Admin("admin_instances").FindOne(ctx,
+ bson.M{"instance_id": inst.InstanceID, "account_id": inst.AccountID}).Decode(&cur); err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+ return
+ }
+ if cur.RenamedAt != nil {
+ until := cur.RenamedAt.Add(models.RenameCooldown)
c.JSON(http.StatusTooManyRequests, gin.H{
"error": fmt.Sprintf("this instance was renamed recently; it can be renamed again after %s UTC", until.Format("2 Jan 2006 15:04")),
"retry_after": until,
})
return
}
+ // The row is here and its cooldown is spent, yet the claim matched
+ // nothing: it changed under us. Nothing has been written, so refuse
+ // rather than guess which way.
+ log.Printf("renameInstance: cooldown claim on %s matched nothing against an eligible row", inst.InstanceID)
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
+ return
}
- ctx := c.Request.Context()
- renamed, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name)
+ // releaseClaim puts renamed_at back to whatever the claim overwrote — the
+ // previous instant, or absent when there was none. Every failure past the
+ // claim owes the customer their rename back.
+ releaseClaim := func(after string) {
+ undo := bson.M{"$unset": bson.M{"renamed_at": ""}}
+ if claimed.RenamedAt != nil {
+ undo = bson.M{"$set": bson.M{"renamed_at": *claimed.RenamedAt}}
+ }
+ if _, err := db.Admin("admin_instances").UpdateOne(bgCtx,
+ bson.M{"instance_id": inst.InstanceID}, undo); err != nil {
+ log.Printf("renameInstance: releasing the cooldown claim on %s after %s: %v", inst.InstanceID, after, err)
+ }
+ }
+
+ renamed, prevName, prevSlug, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name)
switch {
case errors.Is(err, provision.ErrSlugTaken):
+ releaseClaim("a taken slug")
c.JSON(http.StatusConflict, gin.H{"error": "that name is already in use — try another"})
return
case errors.Is(err, provision.ErrNameRejected):
+ releaseClaim("a rejected name")
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
case err != nil:
+ releaseClaim("a failed control-plane rename")
log.Printf("renameInstance: control plane rename of %s: %v", inst.InstanceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
return
}
- if _, err := db.Admin("admin_instances").UpdateOne(ctx,
+ // A matched count of zero is a silent version of the same failure: the
+ // control plane moved and admin's row did not.
+ res, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": inst.InstanceID},
- bson.M{"$set": bson.M{
- "name": renamed.Name,
- "slug": renamed.Slug,
- "renamed_at": time.Now().UTC(),
- }}); err != nil {
- if rbErr := cloudprov.RestoreInstanceIdentity(ctx, inst.InstanceID, inst.Name, inst.Slug); rbErr != nil {
+ bson.M{"$set": bson.M{"name": renamed.Name, "slug": renamed.Slug}})
+ if err == nil && res.MatchedCount == 0 {
+ err = errors.New("admin_instances row matched nothing")
+ }
+ if err != nil {
+ // The control plane's own previous values, not admin's copy: admin's may
+ // be stale, and its slug is omitempty.
+ if rbErr := cloudprov.RestoreInstanceIdentity(bgCtx, inst.InstanceID, prevName, prevSlug); rbErr != nil {
log.Printf("renameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr)
}
+ releaseClaim("a failed record write")
log.Printf("renameInstance: record rename of %s: %v", inst.InstanceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
return
}
+ if renamed.Slug == prevSlug {
+ // The cooldown exists because a rename moves the DNS host; a cosmetic
+ // edit that derives to the same slug moves nothing, so it should not
+ // spend one. The claim is already written by this point — releasing it
+ // is how that is expressed now the check is atomic.
+ releaseClaim("a rename that did not move the host")
+ }
+
s := auth.Current(c)
- audit.Write(ctx, models.AuditEntry{
+ audit.Write(bgCtx, models.AuditEntry{
Actor: s.Email, Action: "instance.renamed", AccountID: s.AccountID,
- Target: inst.InstanceID, Detail: inst.Slug + " -> " + renamed.Slug, IP: c.ClientIP()})
+ Target: inst.InstanceID, Detail: prevSlug + " -> " + renamed.Slug, IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{
"instance_id": inst.InstanceID,
diff --git a/admin/internal/api/staff.go b/admin/internal/api/staff.go
index 13a2d83..f5f726b 100644
--- a/admin/internal/api/staff.go
+++ b/admin/internal/api/staff.go
@@ -1,6 +1,7 @@
package api
import (
+ "context"
"errors"
"fmt"
"log"
@@ -631,6 +632,11 @@ func staffCreateAccountUser(c *gin.Context) {
// On self-hosted it changes admin's label only. There is no control-plane row to
// write — the install is the customer's — and no slug, because self-hosted has
// no tenant subdomain.
+//
+// A cloud placeholder is refused outright rather than relabelled: it has no
+// control-plane row yet, so a label-only rename here would be a name that the
+// instance never gets when provisioning finally derives its slug from the
+// checkout's name. The customer endpoint refuses it for the same reason.
func staffRenameInstance(c *gin.Context) {
var body struct {
Name string `json:"name"`
@@ -652,12 +658,27 @@ func staffRenameInstance(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
+ if inst.Deployment == license.DeploymentCloud && inst.Placeholder {
+ c.JSON(http.StatusConflict, gin.H{"error": "this instance is not provisioned yet"})
+ return
+ }
+
+ // The unwind and the audit write must survive the request being cancelled:
+ // an unwind on a dead context leaves the two databases disagreeing, which is
+ // the failure the unwind exists for.
+ bgCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
+ defer cancel()
set := bson.M{"name": name}
slug := inst.Slug
+ cloud := inst.Deployment == license.DeploymentCloud
+ // The control plane's own previous values, not admin's copy: admin's may be
+ // stale, and its slug is omitempty, so unwinding from it can write an empty
+ // slug into instances.
+ prevName, prevSlug := inst.Name, inst.Slug
- if inst.Deployment == license.DeploymentCloud && !inst.Placeholder {
- renamed, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name)
+ if cloud {
+ renamed, pName, pSlug, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name)
switch {
case errors.Is(err, provision.ErrSlugTaken):
c.JSON(http.StatusConflict, gin.H{"error": "that name is already in use"})
@@ -669,14 +690,21 @@ func staffRenameInstance(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+ prevName, prevSlug = pName, pSlug
slug = renamed.Slug
set["slug"] = renamed.Slug
}
- if _, err := db.Admin("admin_instances").UpdateOne(ctx,
- bson.M{"instance_id": inst.InstanceID}, bson.M{"$set": set}); err != nil {
- if inst.Deployment == license.DeploymentCloud && !inst.Placeholder {
- if rbErr := cloudprov.RestoreInstanceIdentity(ctx, inst.InstanceID, inst.Name, inst.Slug); rbErr != nil {
+ // A matched count of zero is the same failure quietly: the control plane
+ // moved and admin's row did not.
+ res, err := db.Admin("admin_instances").UpdateOne(ctx,
+ bson.M{"instance_id": inst.InstanceID}, bson.M{"$set": set})
+ if err == nil && res.MatchedCount == 0 {
+ err = errors.New("admin_instances row matched nothing")
+ }
+ if err != nil {
+ if cloud {
+ if rbErr := cloudprov.RestoreInstanceIdentity(bgCtx, inst.InstanceID, prevName, prevSlug); rbErr != nil {
log.Printf("staffRenameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr)
}
}
@@ -684,9 +712,9 @@ func staffRenameInstance(c *gin.Context) {
return
}
- audit.Write(ctx, models.AuditEntry{
+ audit.Write(bgCtx, models.AuditEntry{
Actor: auth.Current(c).Email, Action: "instance.renamed", AccountID: inst.AccountID,
- Target: inst.InstanceID, Detail: inst.Slug + " -> " + slug, IP: c.ClientIP()})
+ Target: inst.InstanceID, Detail: prevSlug + " -> " + slug, IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{"instance_id": inst.InstanceID, "name": name, "slug": slug})
}
diff --git a/admin/internal/cloudprov/cloudprov.go b/admin/internal/cloudprov/cloudprov.go
index 9824aeb..ed7c4a3 100644
--- a/admin/internal/cloudprov/cloudprov.go
+++ b/admin/internal/cloudprov/cloudprov.go
@@ -209,7 +209,10 @@ func ProjectedUsers(ctx context.Context, hqUserID string) ([]sharedmodels.User,
// It writes `instances` and nothing else, so admin's control-plane write
// boundary is unchanged. It issues no licence: a licence binds the instance
// UUID, which a rename never touches.
-func RenameInstance(ctx context.Context, instanceID, name string) (*sharedmodels.Instance, error) {
+//
+// The previous name and slug come back with the result because they are what an
+// unwind must restore — admin's own copy can be stale, or slugless.
+func RenameInstance(ctx context.Context, instanceID, name string) (inst *sharedmodels.Instance, prevName, prevSlug string, err error) {
return provision.RenameInstance(ctx, db.ControlDB(), instanceID, name)
}
diff --git a/admin/internal/models/models.go b/admin/internal/models/models.go
index c12bcfb..32aea0c 100644
--- a/admin/internal/models/models.go
+++ b/admin/internal/models/models.go
@@ -150,8 +150,8 @@ type Instance struct {
// rename cooldown. It is a pointer because absent means "never renamed"; a
// zero time.Time would read as year 1 — an inert cooldown, but only by
// accident. Staff renames deliberately leave it alone.
- RenamedAt *time.Time `bson:"renamed_at,omitempty" json:"renamed_at,omitempty"`
- InjectFailedAt *time.Time `bson:"inject_failed_at,omitempty" json:"inject_failed_at,omitempty"`
+ RenamedAt *time.Time `bson:"renamed_at,omitempty" json:"renamed_at,omitempty"`
+ InjectFailedAt *time.Time `bson:"inject_failed_at,omitempty" json:"inject_failed_at,omitempty"`
// 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
diff --git a/adminsite/app/(customer)/instances/[id]/page.tsx b/adminsite/app/(customer)/instances/[id]/page.tsx
index e110eec..9c63b51 100644
--- a/adminsite/app/(customer)/instances/[id]/page.tsx
+++ b/adminsite/app/(customer)/instances/[id]/page.tsx
@@ -213,7 +213,15 @@ export default function InstancePage() {
The instance name is where its address comes from. Renaming moves it to a new address and releases the old
one, so saved links and bookmarks to it stop working.
+ {/*
+ * Keyed on the instance: this element stays mounted
+ * across a navigation between two instance pages, so
+ * without a key the success note and the typed name
+ * from one instance surface on the next.
+ */}
{
diff --git a/adminsite/app/(staff)/staff/instances/[id]/page.tsx b/adminsite/app/(staff)/staff/instances/[id]/page.tsx
index 7090430..b56f2f6 100644
--- a/adminsite/app/(staff)/staff/instances/[id]/page.tsx
+++ b/adminsite/app/(staff)/staff/instances/[id]/page.tsx
@@ -87,7 +87,14 @@ export default function StaffInstancePage() {
* hours.
*/}
+ {/*
+ * Keyed on the instance so a success note cannot follow staff
+ * from one instance page to the next — the element stays
+ * mounted across that navigation.
+ */}
{
diff --git a/adminsite/components/RenamePanel.tsx b/adminsite/components/RenamePanel.tsx
index bf406b6..555c991 100644
--- a/adminsite/components/RenamePanel.tsx
+++ b/adminsite/components/RenamePanel.tsx
@@ -14,14 +14,22 @@ import { baseSlug, hostFor, slugError } from "@/lib/slug";
*
* The host preview is drawn from lib/slug.ts, a mirror of the Go rules. It can
* disagree with the server; the 409 that comes back is the answer that counts.
+ *
+ * movesHost is what separates a rename that moves a DNS host from one that only
+ * changes a label. Self-hosted instances and unprovisioned cloud placeholders
+ * have no address, so every word about old links breaking and signing in again
+ * is false for them — and a preview host they will never live at is worse than
+ * no preview at all.
*/
export function RenamePanel({
currentName,
currentSlug,
+ movesHost,
onRename,
}: {
currentName: string;
currentSlug: string;
+ movesHost: boolean;
onRename: (name: string) => Promise;
}) {
const [open, setOpen] = useState(false);
@@ -45,6 +53,10 @@ export function RenamePanel({
const res = await onRename(name);
setDone(res);
setOpen(false);
+ // The input is prefilled with the current name, and the current name
+ // is now this one. Leaving the old text in would make the next open
+ // look like an edit already in progress.
+ setValue(res.name);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Rename failed. Try again.");
} finally {
@@ -52,26 +64,34 @@ export function RenamePanel({
}
}
- if (done) {
- const host = done.login_url || `https://${hostFor(done.slug)}`;
- return (
-
-
-
- This instance is now {done.name}, at{" "}
- {hostFor(done.slug)}. The old address has stopped working, and your
- sign-in does not follow it — you will need to sign in again there.
-
-
- Open {hostFor(done.slug)} →
-
-
-
- );
- }
-
+ // The note sits ABOVE the control rather than replacing it. A rename is not
+ // a one-shot action — a customer who mistypes the new name needs the panel
+ // back, and returning early here left them with a success message and no way
+ // to correct it short of a reload.
return (
+ {done &&
+ (movesHost ? (
+
+
+
+ This instance is now {done.name}, at{" "}
+ {hostFor(done.slug)}. The old address has stopped working, and
+ your sign-in does not follow it — you will need to sign in again there.
+
+
+ Open {hostFor(done.slug)} →
+
+
+
+ ) : (
+
+ This instance is now {done.name}.
+
+ ))}
{open && (
setValue(e.target.value)}
error={error ?? (name ? invalid : undefined)}
hint={
- name && !invalid ? (
+ movesHost && name && !invalid ? (
<>
Moves to {hostFor(derived)}
{derived === currentSlug && " — the address does not change"}
@@ -99,7 +119,7 @@ export function RenamePanel({
>
{busy ? "Renaming…" : "Rename instance"}
- {open && (
+ {open && movesHost && (
Anyone signed in will need to sign in again at the new address, and links to the old one stop working.
diff --git a/adminsite/lib/api.ts b/adminsite/lib/api.ts
index c1d3909..60e07e5 100644
--- a/adminsite/lib/api.ts
+++ b/adminsite/lib/api.ts
@@ -129,6 +129,8 @@ export interface Instance {
status: InstanceStatus;
current_license?: string;
relink_count: number;
+ /** Cloud only, and only until the paid checkout provisions the real row. */
+ placeholder?: boolean;
renamed_at?: string;
inject_failed_at?: string | null;
notices_sent?: string[];
diff --git a/docs/superpowers/specs/2026-08-12-instance-rename-design.md b/docs/superpowers/specs/2026-08-12-instance-rename-design.md
index 671e2e3..fc1ac47 100644
--- a/docs/superpowers/specs/2026-08-12-instance-rename-design.md
+++ b/docs/superpowers/specs/2026-08-12-instance-rename-design.md
@@ -39,10 +39,13 @@ that tempts someone to touch the licence from this path.
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.
+ another process's memory. The released slug can be claimed by another account
+ inside that window, so for up to a minute a replica still maps that host to the
+ previous tenant. No data is exposed — the host/session guard rejects a session
+ belonging to a different instance — but the new owner's users can briefly reach
+ the old tenant's instance on their own host, and see its login page rather than
+ theirs. 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.
@@ -52,7 +55,7 @@ that tempts someone to touch the licence from this path.
| | Customer (owner or admin) | Staff |
|---|---|---|
| Cloud instance | rename, 24h cooldown | rename, no cooldown |
-| Self-hosted instance | refused, 409 | name only; there is no slug |
+| Self-hosted instance | refused, 400 | 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
@@ -219,7 +222,7 @@ exercise, matching existing practice:
- 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.
+ 400, the same status and constant the member endpoints already answer with.
- The audit log carries `instance.renamed` with both slugs.
## Out of scope
diff --git a/shared/provision/instance.go b/shared/provision/instance.go
index 2ba4d30..d4c6d8f 100644
--- a/shared/provision/instance.go
+++ b/shared/provision/instance.go
@@ -107,34 +107,41 @@ func RenameSlug(name, currentSlug string) (string, error) {
// RenameInstance changes an instance's name and re-derives its slug from it.
//
+// It returns the name and slug the control plane held BEFORE the write, and
+// those are the only correct values to unwind with. The caller's own copy of the
+// instance may be stale, and admin's copy stores slug with `omitempty`, so an
+// unwind driven from there can write an empty slug — which either mis-restores
+// the tenant host or trips the unique index against every other slugless row.
+//
// The count-then-update is racy on its own, and is safe for the same reason
// CreateInstanceWithID's loop is: instances.slug carries a unique index, so a
// lost race surfaces as a duplicate-key error. Unlike creation there is nothing
// to retry with — the caller asked for one specific name — so it becomes
// ErrSlugTaken. Do not remove the duplicate-key branch, and do not remove the
// index.
-func RenameInstance(ctx context.Context, db *mongo.Database, instanceID, name string) (*models.Instance, error) {
- var inst models.Instance
+func RenameInstance(ctx context.Context, db *mongo.Database, instanceID, name string) (inst *models.Instance, prevName, prevSlug string, err error) {
+ var cur models.Instance
if err := db.Collection("instances").FindOne(ctx,
- bson.M{"instance_id": instanceID}).Decode(&inst); err != nil {
- return nil, err
+ bson.M{"instance_id": instanceID}).Decode(&cur); err != nil {
+ return nil, "", "", err
}
+ prevName, prevSlug = cur.Name, cur.Slug
- slug, err := RenameSlug(name, inst.Slug)
+ slug, err := RenameSlug(name, cur.Slug)
if err != nil {
- return nil, err
+ return nil, prevName, prevSlug, err
}
- if slug != inst.Slug {
+ if slug != cur.Slug {
n, err := db.Collection("instances").CountDocuments(ctx, bson.M{
"slug": slug,
"instance_id": bson.M{"$ne": instanceID},
})
if err != nil {
- return nil, err
+ return nil, prevName, prevSlug, err
}
if n > 0 {
- return nil, fmt.Errorf("%w: %s", ErrSlugTaken, slug)
+ return nil, prevName, prevSlug, fmt.Errorf("%w: %s", ErrSlugTaken, slug)
}
}
@@ -142,14 +149,14 @@ func RenameInstance(ctx context.Context, db *mongo.Database, instanceID, name st
bson.M{"instance_id": instanceID},
bson.M{"$set": bson.M{"name": name, "slug": slug}}); err != nil {
if mongo.IsDuplicateKeyError(err) {
- return nil, fmt.Errorf("%w: %s", ErrSlugTaken, slug)
+ return nil, prevName, prevSlug, fmt.Errorf("%w: %s", ErrSlugTaken, slug)
}
- return nil, err
+ return nil, prevName, prevSlug, err
}
- inst.Name = name
- inst.Slug = slug
- return &inst, nil
+ cur.Name = name
+ cur.Slug = slug
+ return &cur, prevName, prevSlug, nil
}
// RestoreInstanceIdentity writes an exact name and slug back, unwinding a rename