carlos / platform Public

console: the Phase 1 move door speaks its own gate sentences (Task 4 fix round 2)

With CARLOS_MOVES_PHASE1 on, moveGate renders its class rails and the
shared gates' refusals it surfaces (the instance rate limit, the missing
deployment bucket, an ambiguous account segment) from phase1MoveCopy,
with no em dashes. The shared gates take a copy table; every other door,
and the move door with the switch off, passes mainGateCopy, main's bytes.
TestMoveDoorGateSentences pins both. Copy rows MV-N47 to MV-N54 (pending).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 82c58a8c38a9e05069ac6065477c564bc66e86e6 parent 8437004
5 files changed, +193 −13
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +8 −0
  • internal/console/apicli.go +32 −3
  • internal/console/apiinstancemove.go +46 −6
  • internal/console/apiinstancemove_test.go +99 −0
  • internal/console/apiinstances.go +8 −4
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
index 99da074d0..95f1ba358 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -86,6 +86,14 @@ Columns: id, file, text, status.
| MV-N44 | internal/provision/provision.go | a <role> does not move. delete it and create one in the new region | pending |
| MV-N45 | internal/provision/provision.go | no move in flight | pending |
| MV-N46 | internal/console/apiinstancemove.go | this move's state does not allow that. read its state and try again | pending |
+| MV-N47 | internal/console/apiinstancemove.go | only exec-backed instances move. this route is kind <kind> | pending |
+| MV-N48 | internal/console/apiinstancemove.go | an addr-backed route's process is not platform-managed. there is nothing to move | pending |
+| MV-N49 | internal/console/apiinstancemove.go | fleet-placed instances keep state-does-not-follow semantics. moves are for platform regions | pending |
+| MV-N50 | internal/console/apiinstancemove.go | pooled instances do not move yet. their data lives on the pool's own box | pending |
+| MV-N51 | internal/console/apiinstancemove.go | a non-hibernating instance runs outside the activator. nothing manages its data, so nothing can move it | pending |
+| MV-N52 | internal/console/apiinstancemove.go | too many instance requests. wait a minute and try again | pending |
+| MV-N53 | internal/console/apiinstancemove.go | this console has no deployment bucket, so it cannot answer this | pending |
+| MV-N54 | internal/console/apiinstancemove.go | account "<segment>" is ambiguous. pass --account-id with one of: <sqid> ("<name>"), ... | pending |
## Plan 2
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 338e42ee3..dfc352eb8 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -57,7 +57,12 @@ func (s *Server) apiTrashedApp(w http.ResponseWriter, r *http.Request) (string,
// half resolves first, then `has` — the last gate and the only variable —
// says whether THIS door's subject exists.
func (s *Server) apiAccountAppBy(w http.ResponseWriter, r *http.Request, has func(int64, string) (bool, error)) (string, *Account, string, bool) {
- p, acct, ok := s.apiPrincipalAccount(w, r)
+ return s.apiAccountAppSaying(w, r, has, mainGateCopy)
+}
+
+// apiAccountAppSaying is apiAccountAppBy with the sentences of c.
+func (s *Server) apiAccountAppSaying(w http.ResponseWriter, r *http.Request, has func(int64, string) (bool, error), c gateCopy) (string, *Account, string, bool) {
+ p, acct, ok := s.apiPrincipalAccountSaying(w, r, c)
if !ok {
return "", nil, "", false
}
@@ -175,6 +180,31 @@ func (s *Server) serviceBinding(p Principal) (*Account, bool) {
// settle an ambiguity can never itself be ambiguous. It never widens access
// — a non-member's probe meets the same 404 either way.
func (s *Server) apiPrincipalAccount(w http.ResponseWriter, r *http.Request) (Principal, *Account, bool) {
+ return s.apiPrincipalAccountSaying(w, r, mainGateCopy)
+}
+
+// gateCopy is what the shared CLI gates say when they refuse in a sentence
+// of their own. Every door speaks mainGateCopy, main's bytes; a door with
+// its own reviewed copy passes its own table (the Phase 1 move door,
+// phase1MoveCopy) and the gates' logic is unchanged.
+type gateCopy struct {
+ ambiguous func(seg, alts string) string // the account segment matches several of the caller's accounts
+ tooMany func(noun string) string // the instance verbs' rate limit
+ noBucket string // the console runs without a deployment bucket
+}
+
+// mainGateCopy is main's sentences, byte for byte (the dashes are escaped
+// only in the source).
+var mainGateCopy = gateCopy{
+ ambiguous: func(seg, alts string) string {
+ return fmt.Sprintf("account %q is ambiguous \u2014 pass --account-id with one of: %s", seg, alts)
+ },
+ tooMany: func(noun string) string { return "too many " + noun + " requests \u2014 wait a minute and try again" },
+ noBucket: "no deployment bucket on this host \u2014 the console is running without one",
+}
+
+// apiPrincipalAccountSaying is apiPrincipalAccount with the sentences of c.
+func (s *Server) apiPrincipalAccountSaying(w http.ResponseWriter, r *http.Request, c gateCopy) (Principal, *Account, bool) {
p, ok := principalFrom(r.Context())
if !ok {
// Only reachable from a door that skipped cliRoute, which
@@ -236,8 +266,7 @@ func (s *Server) apiPrincipalAccount(w http.ResponseWriter, r *http.Request) (Pr
for i, m := range matches {
alts[i] = fmt.Sprintf("%s (%q)", m.Sqid, m.Name)
}
- jsonError(w, http.StatusConflict,
- fmt.Sprintf("account %q is ambiguous — pass --account-id with one of: %s", seg, strings.Join(alts, ", ")))
+ jsonError(w, http.StatusConflict, c.ambiguous(seg, strings.Join(alts, ", ")))
return Principal{}, nil, false
}
if acct == nil {
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go
index 3fd023656..80a2fe56c 100644
--- a/internal/console/apiinstancemove.go
+++ b/internal/console/apiinstancemove.go
@@ -37,7 +37,11 @@ import (
// (spec §Out of scope), and fleet placements keep their deliberate
// state-does-not-follow semantics.
func (s *Server) moveGate(w http.ResponseWriter, r *http.Request) (acct *Account, app, host string, rec *provision.Record, ok bool) {
- _, acct, app, gok := s.instanceGate(w, r)
+ c := mainMoveCopy
+ if s.cfg.MovesEnabled {
+ c = phase1MoveCopy
+ }
+ _, acct, app, gok := s.instanceGateSaying(w, r, "instance", c.gate)
if !gok {
return nil, "", "", nil, false
}
@@ -58,28 +62,64 @@ func (s *Server) moveGate(w http.ResponseWriter, r *http.Request) (acct *Account
return nil, "", "", nil, false
}
if k := rec.Kind; k != "" && k != "instance" {
- jsonError(w, http.StatusBadRequest, "only exec-backed instances move — this route is kind "+k)
+ jsonError(w, http.StatusBadRequest, c.kind(k))
return nil, "", "", nil, false
}
if rec.Addr != "" || len(rec.Addrs) > 0 {
- jsonError(w, http.StatusBadRequest, "an addr-backed route's process is not platform-managed — nothing to move")
+ jsonError(w, http.StatusBadRequest, c.addr)
return nil, "", "", nil, false
}
if _, fleet := registry.FleetPlacement(rec.Region); fleet {
- jsonError(w, http.StatusBadRequest, "fleet-placed instances keep state-does-not-follow semantics — move is for platform regions")
+ jsonError(w, http.StatusBadRequest, c.fleet)
return nil, "", "", nil, false
}
if rec.Pool != "" {
- jsonError(w, http.StatusBadRequest, "pooled instances do not move in v1 — their data lives on the pool's own box")
+ jsonError(w, http.StatusBadRequest, c.pool)
return nil, "", "", nil, false
}
if !rec.HibernateOrDefault() {
- jsonError(w, http.StatusBadRequest, "a non-hibernating instance runs outside the activator — nothing manages its data, nothing can move it")
+ jsonError(w, http.StatusBadRequest, c.hibernate)
return nil, "", "", nil, false
}
return acct, app, host, rec, true
}
+// moveCopy is what moveGate says: its class rails and the shared gates'
+// sentences. With the switch off the move doors speak main's bytes
+// (mainMoveCopy); with it on, the Phase 1 sentences (phase1MoveCopy, copy
+// record MV-N47 to MV-N54), which no other door ever speaks.
+type moveCopy struct {
+ gate gateCopy
+ kind func(kind string) string
+ addr, fleet, pool, hibernate string
+}
+
+// mainMoveCopy is main's sentences, byte for byte (the dashes are escaped
+// only in the source).
+var mainMoveCopy = moveCopy{
+ gate: mainGateCopy,
+ kind: func(k string) string { return "only exec-backed instances move \u2014 this route is kind " + k },
+ addr: "an addr-backed route's process is not platform-managed \u2014 nothing to move",
+ fleet: "fleet-placed instances keep state-does-not-follow semantics \u2014 move is for platform regions",
+ pool: "pooled instances do not move in v1 \u2014 their data lives on the pool's own box",
+ hibernate: "a non-hibernating instance runs outside the activator \u2014 nothing manages its data, nothing can move it",
+}
+
+var phase1MoveCopy = moveCopy{
+ gate: gateCopy{
+ ambiguous: func(seg, alts string) string {
+ return fmt.Sprintf("account %q is ambiguous. pass --account-id with one of: %s", seg, alts)
+ },
+ tooMany: func(noun string) string { return "too many " + noun + " requests. wait a minute and try again" },
+ noBucket: "this console has no deployment bucket, so it cannot answer this",
+ },
+ kind: func(k string) string { return "only exec-backed instances move. this route is kind " + k },
+ addr: "an addr-backed route's process is not platform-managed. there is nothing to move",
+ fleet: "fleet-placed instances keep state-does-not-follow semantics. moves are for platform regions",
+ pool: "pooled instances do not move yet. their data lives on the pool's own box",
+ hibernate: "a non-hibernating instance runs outside the activator. nothing manages its data, so nothing can move it",
+}
+
// moveView is the flat answer every move door speaks — titogo's consumer
// contract (teamtito/titogo#1848, region-doors design): state, target, one
// freshness stamp. "none" is a real state, not an error: a host with no
diff --git a/internal/console/apiinstancemove_test.go b/internal/console/apiinstancemove_test.go
index 9eec22335..cce47d64d 100644
--- a/internal/console/apiinstancemove_test.go
+++ b/internal/console/apiinstancemove_test.go
@@ -1084,3 +1084,102 @@ func TestMoveDoorCompensatesAFailedRecordWrite(t *testing.T) {
})
}
}
+
+// The move door's own refusals and the shared gates' sentences it surfaces
+// (F13): with the switch off, main's bytes; with it on, the Phase 1
+// sentences at the move door and main's bytes at every other door.
+func TestMoveDoorGateSentences(t *testing.T) {
+ const dash = "\u2014"
+ hib := false
+ records := []provision.Record{
+ {Host: "st.go.tito.io", Slug: "st", Kind: "static"},
+ {Host: "ad.go.tito.io", Slug: "ad", Addrs: []string{"10.0.0.1:80"}},
+ {Host: "fl.go.tito.io", Slug: "fl", Region: "fleet/pi"},
+ {Host: "po.go.tito.io", Slug: "po", Pool: "dedicated-x"},
+ {Host: "nh.go.tito.io", Slug: "nh", Hibernate: &hib},
+ }
+ type sentence struct{ host, legacy, phase1 string }
+ rails := []sentence{
+ {"st.go.tito.io", "only exec-backed instances move " + dash + " this route is kind static",
+ "only exec-backed instances move. this route is kind static"},
+ {"ad.go.tito.io", "an addr-backed route's process is not platform-managed " + dash + " nothing to move",
+ "an addr-backed route's process is not platform-managed. there is nothing to move"},
+ {"fl.go.tito.io", "fleet-placed instances keep state-does-not-follow semantics " + dash + " move is for platform regions",
+ "fleet-placed instances keep state-does-not-follow semantics. moves are for platform regions"},
+ {"po.go.tito.io", "pooled instances do not move in v1 " + dash + " their data lives on the pool's own box",
+ "pooled instances do not move yet. their data lives on the pool's own box"},
+ {"nh.go.tito.io", "a non-hibernating instance runs outside the activator " + dash + " nothing manages its data, nothing can move it",
+ "a non-hibernating instance runs outside the activator. nothing manages its data, so nothing can move it"},
+ }
+ const (
+ limitLegacy = "too many instance requests " + dash + " wait a minute and try again"
+ limitPhase1 = "too many instance requests. wait a minute and try again"
+ bucketLegacy = "no deployment bucket on this host " + dash + " the console is running without one"
+ bucketPhase1 = "this console has no deployment bucket, so it cannot answer this"
+ )
+ ambiguousLegacy := `account "bab" is ambiguous ` + dash + ` pass --account-id with one of: bab ("ops"), bac ("bab")`
+ ambiguousPhase1 := `account "bab" is ambiguous. pass --account-id with one of: bab ("ops"), bac ("bab")`
+
+ for _, on := range []bool{false, true} {
+ t.Run(fmt.Sprintf("switch %v", on), func(t *testing.T) {
+ fx, token := operatorInstanceFixture(t, fmt.Sprintf("code-inst-move-copy-%v", on))
+ if !on {
+ fx.srv.cfg.MovesEnabled = false
+ }
+ ctx := context.Background()
+ for _, rec := range records {
+ rec.Schema, rec.App, rec.Account, rec.Channel, rec.CreatedAt = 1, "titogo", "bab", "stable", time.Now().UTC()
+ if err := provision.CreateRecord(ctx, fx.rel, rec); err != nil {
+ t.Fatal(err)
+ }
+ }
+ u := instancesURL(fx, "bab", "titogo")
+ pick := func(s sentence) string {
+ if on {
+ return s.phase1
+ }
+ return s.legacy
+ }
+ check := func(what, method, url, body string, wantCode int, want string) {
+ t.Helper()
+ code, _, out := rawReq(t, method, url, token, body)
+ if code != wantCode || errorText(out) != want {
+ t.Errorf("%s = %d %s, want %d %q", what, code, out, wantCode, want)
+ }
+ }
+ // The class rails, on every move verb that runs them.
+ for _, rail := range rails {
+ for _, v := range []struct{ method, suffix, body string }{
+ {http.MethodPost, "/move", `{"region":"uk"}`},
+ {http.MethodGet, "/move", ""},
+ {http.MethodPost, "/move/cut", ""},
+ {http.MethodDelete, "/move", ""},
+ } {
+ check(rail.host+" "+v.method+v.suffix, v.method, u+"/"+rail.host+v.suffix, v.body, http.StatusBadRequest, pick(rail))
+ }
+ }
+ // The shared instance gate: no deployment bucket.
+ rel := fx.srv.cfg.Releases
+ fx.srv.cfg.Releases = nil
+ check("move, no bucket", http.MethodPost, u+"/st.go.tito.io/move", `{"region":"uk"}`, http.StatusServiceUnavailable, pick(sentence{"", bucketLegacy, bucketPhase1}))
+ check("instance GET, no bucket", http.MethodGet, u+"/st.go.tito.io", "", http.StatusServiceUnavailable, bucketLegacy)
+ fx.srv.cfg.Releases = rel
+ // The account resolver: an ambiguous account segment.
+ if err := fx.st.CreateAccount(2, "bac", "bab", "alice@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ check("move, ambiguous account", http.MethodPost, u+"/st.go.tito.io/move", `{"region":"uk"}`, http.StatusConflict, pick(sentence{"", ambiguousLegacy, ambiguousPhase1}))
+ check("instance GET, ambiguous account", http.MethodGet, u+"/st.go.tito.io", "", http.StatusConflict, ambiguousLegacy)
+ check("channels GET, ambiguous account", http.MethodGet, fx.ts.URL+"/api/cli/apps/bab/titogo/channels", "", http.StatusConflict, ambiguousLegacy)
+ check("app create, ambiguous account", http.MethodPost, fx.ts.URL+"/api/cli/apps/bab/new", `{"name":"other"}`, http.StatusConflict, ambiguousLegacy)
+ u = u + "?exact=sqid"
+ // The shared instance gate: the rate limit, spent last.
+ for i := 0; i < cliInstanceBurst; i++ {
+ fx.srv.cli.instances.allow(hashToken(token), time.Now())
+ }
+ base := instancesURL(fx, "bab", "titogo")
+ check("move, rate limited", http.MethodPost, base+"/st.go.tito.io/move?exact=sqid", `{"region":"uk"}`, http.StatusTooManyRequests, pick(sentence{"", limitLegacy, limitPhase1}))
+ check("instance GET, rate limited", http.MethodGet, base+"/st.go.tito.io?exact=sqid", "", http.StatusTooManyRequests, limitLegacy)
+ })
+ }
+}
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index 91844c54c..3476b1efb 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -79,7 +79,12 @@ func (s *Server) instanceGate(w http.ResponseWriter, r *http.Request) (string, *
// requests" when they hit it. Only the sentence varies; nothing about the
// order, the key or the limiter does.
func (s *Server) instanceGateFor(w http.ResponseWriter, r *http.Request, noun string) (string, *Account, string, bool) {
- principal, acct, app, ok := s.apiAccountApp(w, r)
+ return s.instanceGateSaying(w, r, noun, mainGateCopy)
+}
+
+// instanceGateSaying is instanceGateFor with the sentences of c.
+func (s *Server) instanceGateSaying(w http.ResponseWriter, r *http.Request, noun string, c gateCopy) (string, *Account, string, bool) {
+ principal, acct, app, ok := s.apiAccountAppSaying(w, r, s.store.HasAppIn, c)
if !ok {
return "", nil, "", false
}
@@ -87,15 +92,14 @@ func (s *Server) instanceGateFor(w http.ResponseWriter, r *http.Request, noun st
// principal here is frequently an app, which has no address of its own,
// and a signup burst is the workload rather than the abuse.
if !s.cli.instances.allow(hashToken(bearer(r)), time.Now()) {
- jsonError(w, http.StatusTooManyRequests, "too many "+noun+" requests — wait a minute and try again")
+ jsonError(w, http.StatusTooManyRequests, c.tooMany(noun))
return "", nil, "", false
}
// A console with no deployment bucket configured can answer none of
// these. Same shape as promote's signing-key check, and in the same
// place: after the gate, before anything reaches for the store.
if s.cfg.Releases == nil {
- jsonError(w, http.StatusServiceUnavailable,
- "no deployment bucket on this host — the console is running without one")
+ jsonError(w, http.StatusServiceUnavailable, c.noBucket)
return "", nil, "", false
}
return principal, acct, app, true