console: the control-plane flag refuses destruction, not promotion
Trash, purge, rename and transfer are refused outright for a flagged app; a service credential cannot promote or roll one back; a PERSON promotes it with ordinary ops membership. The asymmetry is the design. Gating promotion would have broken carlos deploy -app console — stable's fresh-auth 403 has no API-side step-up and deploy log.Fatalfs on a failed promote after the ship has landed. Meanwhile purge could delete the release history the fleet is running, behind a confirmation that had no idea what the app was. Every refusal sits after its door's membership gate and before any write or fresh-auth prompt, so a non-member still meets the byte-identical 404 and nobody spends a passkey on a gesture that cannot help. A failed flag lookup refuses with a 503 rather than falling through: treating an unreadable row as "not the control plane" would make a database blip the way past the guard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5 files changed,
+343
−0
internal/console/apicli.go+23 −0internal/console/appsettings.go+20 −0internal/console/controlplane.go+94 −0internal/console/controlplane_test.go+194 −0internal/console/ownership.go+12 −0
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index c8d1bc05b..32ebb663d 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -680,6 +680,13 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) { |
| }) |
| return |
| } |
| + // A person with ops membership may roll the control plane; a service |
| + // credential may not. This is the flag's promote-side job — the whole of |
| + // it. Gating a PERSON here would break `carlos deploy -app console`, the |
| + // command this exists to make ordinary (controlplane.go). |
| + if s.refuseServiceControlPlane(w, p, acct.ID, app, "promote") { |
| + return |
| + } |
| res, err := release.PromoteOnLadder(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel, version, |
| release.PromoteOptions{Actor: addr}) |
| if err != nil { |
| @@ -799,6 +806,12 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| target := cur.History[0] |
| + // Rollback moves the same pointer promote moves, so it wears the same |
| + // service refusal — a credential barred from promoting the control plane |
| + // must not reach the identical effect through the other verb. |
| + if s.refuseServiceControlPlane(w, p, acct.ID, app, "roll back") { |
| + return |
| + } |
| if err := release.Rollback(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel); err != nil { |
| log.Printf("console: api rollback %s %s: %v", app, channel, err) |
| jsonError(w, http.StatusConflict, err.Error()) |
| @@ -913,6 +926,10 @@ func (s *Server) handleAPIRestart(w http.ResponseWriter, r *http.Request) { |
| // probing this door teaches a stranger nothing. |
| // rate limit — keyed on the ADDRESS, like handleAPIPromote: a write |
| // budget belongs to the person, not the terminal. |
| +// control plane — 403, outright. An app flagged as the control plane IS |
| +// the platform; its release history is what the fleet is |
| +// running. Clearing the flag is the separate, deliberate |
| +// act that unlocks this door (controlplane.go). |
| // Releases nil — 503. A console with no bucket cannot trash at all: the |
| // marker IS the trash state, and a row-only trash would |
| // un-hide itself at the next materialize pass (which |
| @@ -944,6 +961,12 @@ func (s *Server) handleAPITrashApp(w http.ResponseWriter, r *http.Request) { |
| jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| return |
| } |
| + // The control plane is not trashable at all (controlplane.go): after the |
| + // byte-identical 404 above, so a stranger never learns the app exists, |
| + // and before anything is written. |
| + if s.refuseControlPlane(w, acct.ID, app, "trashed") { |
| + return |
| + } |
| if s.cfg.Releases == nil { |
| jsonError(w, http.StatusServiceUnavailable, |
| "no deployment bucket on this host — this console cannot delete an app") |
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go| index 35d55cb6b..b2916fa47 100644 |
| --- a/internal/console/appsettings.go |
| +++ b/internal/console/appsettings.go |
| @@ -317,6 +317,14 @@ func (s *Server) handleAppRename(w http.ResponseWriter, r *http.Request) { |
| s.setFlash(w, kind, msg) |
| http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther) |
| } |
| + // The control plane keeps its name (controlplane.go): the fleet's release |
| + // keys, its channels and the units that exec them are all written in it. |
| + // Refused here, straight after the owner gate and before the confirm card |
| + // is ever rendered — a card that cannot be confirmed is worse than no card. |
| + if msg, _ := s.controlPlaneRefusal(acct.ID, app, "renamed"); msg != "" { |
| + back("err", msg) |
| + return |
| + } |
| // Normalized exactly as the create doors normalize (normalizeAppName), |
| // and refused exactly where claimApp refuses: "new" is the create form's |
| // own literal route, and an app renamed onto it would be unreachable — |
| @@ -447,6 +455,12 @@ func (s *Server) handleAppDelete(w http.ResponseWriter, r *http.Request) { |
| s.setFlash(w, kind, msg) |
| http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther) |
| } |
| + // Outright, before the typed name and long before the passkey: the flag |
| + // is the guard, and no gesture answers it (controlplane.go). |
| + if msg, _ := s.controlPlaneRefusal(acct.ID, app, "trashed"); msg != "" { |
| + back("err", msg) |
| + return |
| + } |
| // Typing the name is a "did you mean this app?" control, not a security |
| // one — checked before step-up so a slip does not cost a passkey touch. |
| if strings.TrimSpace(r.FormValue("confirm")) != app { |
| @@ -668,6 +682,12 @@ func (s *Server) handleAppMoveAccount(w http.ResponseWriter, r *http.Request) { |
| s.setFlash(w, kind, msg) |
| http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther) |
| } |
| + // The control plane does not change accounts: its releases are read from |
| + // one account's prefix by every box in the fleet (controlplane.go). |
| + if msg, _ := s.controlPlaneRefusal(acct.ID, app, "moved to another account"); msg != "" { |
| + back("err", msg) |
| + return |
| + } |
| target, agreed, err := s.routeAccountFor(app) |
| if err != nil { |
| back("err", "Could not read the routes — nothing was moved. Try again.") |
diff --git a/internal/console/controlplane.go b/internal/console/controlplane.go| new file mode 100644 |
| index 000000000..d85ea4d7b |
| --- /dev/null |
| +++ b/internal/console/controlplane.go |
| @@ -0,0 +1,94 @@ |
| +// Control-plane apps: the flag that says "this app IS the platform". |
| +// |
| +// It gates DESTRUCTION, not promotion, and the asymmetry is the whole design. |
| +// Deploying the console should be as ordinary as deploying any app — that is |
| +// what this slice exists to make true — so a promote gate would defeat it: |
| +// `stable`'s fresh-auth 403 has no API-side step-up, and `carlos deploy` |
| +// log.Fatalfs on a failed promote AFTER the ship has already landed |
| +// (deploy.go), so any gate there would leave every console deploy half-done. |
| +// Destroying the console is the act that deserves a wall, and it had none: |
| +// handlePurgeApp will delete apps/<sqid>/console/** — the release history the |
| +// fleet is currently running — behind an owner check, a typed name and a |
| +// passkey, none of which knows what this app is. |
| +// |
| +// Refused OUTRIGHT rather than behind fresh auth. A gesture proves intent, |
| +// and intent is not the thing in doubt: an operator who means to delete the |
| +// console can clear the flag first, which is a separate, deliberate act with |
| +// its own record. |
| +// |
| +// The one promote-side restriction is about WHO, not whether: a person with |
| +// ops membership may roll the control plane, a service credential may not. |
| +package console |
| + |
| +import ( |
| + "log" |
| + "net/http" |
| +) |
| + |
| +// controlPlaneRefusal answers "may this door proceed?" for a destructive verb. |
| +// msg is "" when the app is not the control plane and the caller may carry on; |
| +// otherwise msg is the sentence to show and status the code a JSON door should |
| +// send with it. |
| +// |
| +// Callers MUST be past their own membership gate first — the byte-identical |
| +// 404 protects account membership, and consulting the flag before it would |
| +// turn this refusal into an oracle for which accounts hold a control-plane |
| +// app. |
| +// |
| +// A lookup failure REFUSES too, with a 503 rather than a 403: the alternative |
| +// — treating an unreadable row as "not the control plane" — makes a database |
| +// blip the way past the guard. The error itself is logged, never returned: |
| +// the caller gets one fixed sentence, as with every other store failure here. |
| +func (s *Server) controlPlaneRefusal(accountID int64, app, verb string) (msg string, status int) { |
| + cp, _, err := s.store.AppFlags(accountID, app) |
| + if err != nil { |
| + log.Printf("console: control-plane check %s in account %d: %v", app, accountID, err) |
| + return "Could not check whether " + app + " is the control plane — nothing was changed. Try again.", |
| + http.StatusServiceUnavailable |
| + } |
| + if !cp { |
| + return "", 0 |
| + } |
| + return app + " is marked as the control plane, so it cannot be " + verb + |
| + " — clear the control-plane flag first if you really mean to.", http.StatusForbidden |
| +} |
| + |
| +// refuseControlPlane is controlPlaneRefusal for the JSON doors: it writes the |
| +// refusal and returns true when the caller must stop. Browser doors render |
| +// pages and set flashes, so they call controlPlaneRefusal directly and hand |
| +// msg to their own back()/fail() closure. |
| +func (s *Server) refuseControlPlane(w http.ResponseWriter, accountID int64, app, verb string) bool { |
| + msg, status := s.controlPlaneRefusal(accountID, app, verb) |
| + if msg == "" { |
| + return false |
| + } |
| + jsonError(w, status, msg) |
| + return true |
| +} |
| + |
| +// refuseServiceControlPlane is the promote pair's gate, and the whole of what |
| +// the flag does on the promote path: a service credential cannot move a |
| +// control-plane app's channel pointer; a PERSON with ops membership can, and |
| +// must be able to — gating a person here would break `carlos deploy -app |
| +// console`, the command this work exists to make ordinary. |
| +// |
| +// The flag is only read for a service, so a person's promote does not grow a |
| +// database round trip it has no use for. |
| +func (s *Server) refuseServiceControlPlane(w http.ResponseWriter, p Principal, accountID int64, app, verb string) bool { |
| + if p.Kind == PrincipalUser { |
| + return false |
| + } |
| + cp, _, err := s.store.AppFlags(accountID, app) |
| + if err != nil { |
| + log.Printf("console: control-plane check %s in account %d: %v", app, accountID, err) |
| + jsonError(w, http.StatusServiceUnavailable, |
| + "could not check this app's control-plane status — nothing was changed") |
| + return true |
| + } |
| + if !cp { |
| + return false |
| + } |
| + jsonError(w, http.StatusForbidden, |
| + app+" is the control plane — a service credential cannot "+verb+" it; a person must") |
| + return true |
| +} |
diff --git a/internal/console/controlplane_test.go b/internal/console/controlplane_test.go| new file mode 100644 |
| index 000000000..de92defdd |
| --- /dev/null |
| +++ b/internal/console/controlplane_test.go |
| @@ -0,0 +1,194 @@ |
| +package console |
| + |
| +import ( |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +// controlPlaneFixture is a console holding ONE control-plane app: `console` |
| +// in the ops account (bab), owned by paul, with the flag set. It returns the |
| +// fixture and a live CLI token for paul, so a test can go straight to the |
| +// door it is about. |
| +// |
| +// Deliberately minimal, because later tasks build on it: nothing is shipped, |
| +// no channel is pointed anywhere, and the ship rate limiter is left alone — |
| +// a test that ships more than twice calls liftShipBudget itself (apicli_test.go). |
| +func controlPlaneFixture(t *testing.T, label string) (*consoleFixture, string) { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "console", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.SetAppControlPlane(OpsAccountID, "console", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + return fx, cliToken(t, fx, "paul@keymail.dev", label) |
| +} |
| + |
| +// TestControlPlaneAppRefusesTrash: the console's release history is not |
| +// something to delete behind a confirmation dialog. Refused outright, not |
| +// "with fresh auth" — a gesture is not the guard here, the flag is. |
| +func TestControlPlaneAppRefusesTrash(t *testing.T) { |
| + fx, token := controlPlaneFixture(t, "code-cp-trash") |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/delete", map[string]string{}, token) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("trash a control-plane app = %d %v, want 403", status, out) |
| + } |
| + if msg := str(t, out, "error"); !strings.Contains(msg, "control plane") { |
| + t.Fatalf("refusal %q does not name the flag the operator has to clear", msg) |
| + } |
| + present, err := fx.st.HasAppIn(OpsAccountID, "console") |
| + if err != nil || !present { |
| + t.Fatalf("app row after a refused trash: HasAppIn = %v %v, want true (untouched)", present, err) |
| + } |
| +} |
| + |
| +// TestControlPlaneAppAllowsAPersonToPromote is the other half, and the one |
| +// that matters most: gating promotion is what an earlier draft got wrong. |
| +// `stable`'s fresh-auth 403 has no API-side step-up and carlos deploy |
| +// log.Fatalfs on a failed promote AFTER the ship has landed, so a promote gate |
| +// would break the very command this work exists to create. |
| +func TestControlPlaneAppAllowsAPersonToPromote(t *testing.T) { |
| + fx, token := controlPlaneFixture(t, "code-cp-promote") |
| + status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token, |
| + []shipPart{metaPart(t, "cp00001", "binary"), {name: "linux-arm64", body: []byte("x")}}) |
| + if status != http.StatusCreated { |
| + t.Fatalf("ship = %d %v", status, out) |
| + } |
| + status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/promote", |
| + map[string]string{"version": "cp00001", "channel": "canary/console"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("a PERSON promoting a control-plane app = %d %v, want 200 — gating this breaks carlos deploy", status, out) |
| + } |
| +} |
| + |
| +// TestControlPlaneAppRefusesAServicePromote: a person with ops membership may |
| +// roll the console; a CI credential may not. This is what the flag buys on the |
| +// promote path instead of a fresh-auth gate. |
| +func TestControlPlaneAppRefusesAServicePromote(t *testing.T) { |
| + fx, token := controlPlaneFixture(t, "code-cp-svc") |
| + status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token, |
| + []shipPart{metaPart(t, "cp00002", "binary"), {name: "linux-arm64", body: []byte("x")}}) |
| + if status != http.StatusCreated { |
| + t.Fatalf("ship = %d %v", status, out) |
| + } |
| + svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Publish) |
| + status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/promote", |
| + map[string]string{"version": "cp00002", "channel": "canary/console"}, svc) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("a SERVICE promoting a control-plane app = %d %v, want 403", status, out) |
| + } |
| +} |
| + |
| +// TestControlPlaneAppRefusesAServiceRollback: rollback moves the same pointer |
| +// promote does, so it wears the same service refusal — otherwise a CI |
| +// credential rolls the control plane by taking the other verb. |
| +func TestControlPlaneAppRefusesAServiceRollback(t *testing.T) { |
| + fx, token := controlPlaneFixture(t, "code-cp-svc-rollback") |
| + liftShipBudget(fx) |
| + for _, v := range []string{"cp00003", "cp00004"} { |
| + status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token, |
| + []shipPart{metaPart(t, v, "binary"), {name: "linux-arm64", body: []byte("x")}}) |
| + if status != http.StatusCreated { |
| + t.Fatalf("ship %s = %d %v", v, status, out) |
| + } |
| + status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/promote", |
| + map[string]string{"version": v, "channel": "canary/console"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("promote %s = %d %v", v, status, out) |
| + } |
| + } |
| + svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Publish) |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/rollback", |
| + map[string]string{"channel": "canary/console"}, svc) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("a SERVICE rolling back a control-plane app = %d %v, want 403", status, out) |
| + } |
| + // And the pointer did not move. |
| + if got := fx.channelVersion(t, "console", "canary/console").Version; got != "cp00004" { |
| + t.Fatalf("channel after a refused rollback = %q, want cp00004", got) |
| + } |
| +} |
| + |
| +// TestControlPlaneAppRefusesBrowserRename: the browser doors render pages, so |
| +// their refusal is a flash and a redirect rather than JSON — but it is the |
| +// same refusal, ahead of the confirm card and ahead of any passkey. |
| +func TestControlPlaneAppRefusesBrowserRename(t *testing.T) { |
| + fx, _ := controlPlaneFixture(t, "code-cp-rename") |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + status, loc, _ := postForm(t, c, fx.u("/apps/console/rename"), |
| + url.Values{"csrf": {csrf}, "to": {"console2"}}) |
| + if status != http.StatusSeeOther { |
| + t.Fatalf("rename a control-plane app = %d, want a redirect carrying the refusal", status) |
| + } |
| + if !strings.Contains(loc, "/apps/console/settings") { |
| + t.Fatalf("rename refusal redirected to %q, want the settings tab", loc) |
| + } |
| + if renamed, err := fx.st.HasAppIn(OpsAccountID, "console"); err != nil || !renamed { |
| + t.Fatalf("app row after a refused rename: HasAppIn(console) = %v %v, want true", renamed, err) |
| + } |
| + if moved, err := fx.st.HasAppIn(OpsAccountID, "console2"); err != nil || moved { |
| + t.Fatalf("HasAppIn(console2) = %v %v, want false — the rename must not have landed", moved, err) |
| + } |
| +} |
| + |
| +// TestControlPlaneAppRefusesPurge is the refusal this whole flag exists for: |
| +// "Delete now" is the console's one irreversible button, and its target is |
| +// apps/<sqid>/console/** — the release history the fleet is running. The |
| +// refusal must land BEFORE requireFreshAuth, so the answer is the trash page |
| +// with a flash, never a trip into Keymail for a passkey that cannot help. |
| +func TestControlPlaneAppRefusesPurge(t *testing.T) { |
| + fx, _ := controlPlaneFixture(t, "code-cp-purge") |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + status, loc, _ := postForm(t, c, fx.u("/trash/delete"), |
| + url.Values{"csrf": {csrf}, "app": {"console"}, "confirm": {"console"}}) |
| + if status != http.StatusSeeOther { |
| + t.Fatalf("purge a control-plane app = %d, want a redirect carrying the refusal", status) |
| + } |
| + if loc != fx.path("/trash") { |
| + t.Fatalf("purge refusal redirected to %q, want %q — a step-up redirect here means the flag was checked too late", |
| + loc, fx.path("/trash")) |
| + } |
| + if present, err := fx.st.HasAppIn(OpsAccountID, "console"); err != nil || !present { |
| + t.Fatalf("app row after a refused purge: HasAppIn = %v %v, want true (untouched)", present, err) |
| + } |
| +} |
| + |
| +// TestOrdinaryAppUnaffected: every refusal above is scoped to the flag. |
| +func TestOrdinaryAppUnaffected(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-cp-ordinary") |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", map[string]string{}, token) |
| + if status == http.StatusForbidden { |
| + t.Fatalf("trash an ORDINARY app = %d %v, want it to work as before", status, out) |
| + } |
| +} |
| + |
| +// TestControlPlaneRefusalIsNotAMembershipOracle: a non-member must meet the |
| +// byte-identical 404 in apiAccountApp before the flag is ever consulted, or |
| +// the refusal becomes a way to learn which accounts hold a control-plane app. |
| +func TestControlPlaneRefusalIsNotAMembershipOracle(t *testing.T) { |
| + fx, _ := controlPlaneFixture(t, "code-cp-oracle") |
| + stranger := cliToken(t, fx, "eve@keymail.dev", "code-cp-stranger") |
| + // postRaw, not postJSON: apiAccountApp's 404 is http.NotFound's plain |
| + // text, and the point of this test is that those bytes do not vary. |
| + status, body := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/console/delete", map[string]string{}, stranger) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("a non-member trashing the control plane = %d %q, want the byte-identical 404", status, body) |
| + } |
| + otherStatus, otherBody := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/nosuchapp/delete", map[string]string{}, stranger) |
| + if otherStatus != status || otherBody != body { |
| + t.Fatalf("control-plane 404 (%d %q) differs from unknown-app 404 (%d %q) — the refusal is an oracle", |
| + status, body, otherStatus, otherBody) |
| + } |
| +} |
diff --git a/internal/console/ownership.go b/internal/console/ownership.go| index 353ad31e8..c98236c8f 100644 |
| --- a/internal/console/ownership.go |
| +++ b/internal/console/ownership.go |
| @@ -274,6 +274,10 @@ func (s *Server) handleRestoreApp(w http.ResponseWriter, r *http.Request) { |
| // answer. |
| // CSRF — constant-time, before any pending action can be |
| // parked by a cross-site post. |
| +// control plane — refused outright, before the typed name and before |
| +// step-up: an app flagged as the control plane holds the |
| +// release history the fleet is running, and clearing the |
| +// flag is the separate act that unlocks this door. |
| // type the APP name — not a security control, a "did you mean this app?" |
| // control, and checked BEFORE step-up so a slip does not |
| // cost a passkey touch. |
| @@ -308,6 +312,14 @@ func (s *Server) handlePurgeApp(w http.ResponseWriter, r *http.Request) { |
| fail("Choose an app to delete permanently.") |
| return |
| } |
| + // The control plane is the one app this door must never destroy: its |
| + // prefix holds the release history the fleet is RUNNING. Refused before |
| + // the typed name and before the passkey — neither gesture can answer the |
| + // flag, and asking for one would only spend it (controlplane.go). |
| + if msg, _ := s.controlPlaneRefusal(acct.ID, app, "deleted"); msg != "" { |
| + fail(msg) |
| + return |
| + } |
| if strings.TrimSpace(r.FormValue("confirm")) != app { |
| fail("Type “" + app + "” exactly to delete it permanently. Nothing was deleted.") |
| return |