carlos / platform Public

Merge pull request #261 from carlosframework/static-cli

A static site gets a URL from the CLI, and a route's channel can change
Paul Campbell pushed by paul@keymail.dev ee76d3948b3c1670a83f4357ca61f1cfaa78ab23 parent 2fb300bparent 0f850e1
20 files changed, +2927 −49
  • apimode.go +64 −3
  • apimode_test.go +99 −2
  • cli.go +8 −2
  • deploy.go +127 −38
  • deploy_e2e_test.go +167 −0
  • deploy_test.go +159 −0
  • docs/superpowers/plans/2026-08-23-record-channel-change.md +601 −0
  • docs/superpowers/plans/2026-08-23-static-platform-host.md +558 −0
  • docs/superpowers/specs/2026-08-23-record-channel-change-design.md +179 −0
  • docs/superpowers/specs/2026-08-23-static-platform-host-design.md +201 −0
  • instances_cli.go +68 −1
  • instances_e2e_test.go +32 −0
  • internal/console/apiinstances_test.go +50 −0
  • internal/console/authcli.go +6 −0
  • internal/console/channelmove.go +229 −0
  • internal/console/channelmove_test.go +343 −0
  • internal/console/pendinghandlers.go +15 −0
  • internal/console/svcroutes_test.go +9 −2
  • internal/release/pipeline.go +10 −0
  • main.go +2 −1
diff --git a/apimode.go b/apimode.go
index 661fd08a3..71ba7adfa 100644
--- a/apimode.go
+++ b/apimode.go
@@ -2055,6 +2055,40 @@ type apiInstanceManifest struct {
Seed []string `json:"seed"`
}
+// enableError carries the instances-manifest door's STATUS alongside its
+// message. apiErrorFromBody collapses a refusal to its sentence, which is
+// right for a message a member reads and wrong for a decision a caller has
+// to make: the deploy mint must tell "this deployment has no platform host
+// to give" from "your credential just lost authority", and reading English
+// to do it is the mistake errAmbiguousChannel's comment records. Same shape
+// as domainListError, for the same reason.
+type enableError struct {
+ status int
+ msg string
+}
+
+func (e *enableError) Error() string { return e.msg }
+
+// enableUnavailable reports whether the deployment simply has no platform
+// host for this app: no apps domain configured at all (503), or an
+// instance domain an operator pinned elsewhere in store mode (409), which
+// this door refuses to speak for. Both mean "ask the member for a hostname
+// instead"; every other status is a real failure and must not be swallowed.
+//
+// The 409 is deliberately imprecise: the door also answers 409 for a lost
+// CAS race ("instance settings changed while this request was in flight"),
+// which is transient and would be better retried than fallen back from.
+// Telling the two apart needs the message, and reading English to make a
+// control-flow decision is the mistake this type exists to avoid — so both
+// take the fallback, and the caller PRINTS the door's own sentence, which
+// says "try again" in the transient case. A wrong branch here costs one
+// re-run, never a wrong record.
+func enableUnavailable(err error) bool {
+ var ee *enableError
+ return errors.As(err, &ee) &&
+ (ee.status == http.StatusServiceUnavailable || ee.status == http.StatusConflict)
+}
+
// instancesEnable asks the console to write (or update) the app's instances
// manifest under the account's own platform subdomain — the member-facing
// half of `carlos instances enable` (issue #66). channel and seed are
@@ -2071,7 +2105,7 @@ func (s *apiSession) instancesEnable(ctx context.Context, a apiAcct, app string,
return apiInstanceManifest{}, err
}
if status != http.StatusOK {
- return apiInstanceManifest{}, apiErrorFromBody(status, body)
+ return apiInstanceManifest{}, &enableError{status: status, msg: apiErrorFromBody(status, body).Error()}
}
var out apiInstanceManifest
if err := json.Unmarshal(body, &out); err != nil {
@@ -2164,14 +2198,15 @@ type instanceProbeReq struct {
// upstream (kind defaults to "instance", which needs no addr at all), and
// probe nil means unprobed — the same "absent, not zero" contract the wire
// carries for every optional field on this door.
-func (s *apiSession) instanceCreate(ctx context.Context, a apiAcct, app, host, region, channel string, addrs []string, probe *instanceProbeReq) (apiInstanceRecord, error) {
+func (s *apiSession) instanceCreate(ctx context.Context, a apiAcct, app, host, region, channel, kind string, addrs []string, probe *instanceProbeReq) (apiInstanceRecord, error) {
in := struct {
Host string `json:"host"`
Region string `json:"region,omitempty"`
Channel string `json:"channel,omitempty"`
+ Kind string `json:"kind,omitempty"`
Addrs []string `json:"addrs,omitempty"`
Probe *instanceProbeReq `json:"probe,omitempty"`
- }{host, region, channel, addrs, probe}
+ }{host, region, channel, kind, addrs, probe}
status, body, err := s.postJSON(ctx, apiPath(a, app, "/instances"), in)
if err != nil {
return apiInstanceRecord{}, err
@@ -2186,6 +2221,32 @@ func (s *apiSession) instanceCreate(ctx context.Context, a apiAcct, app, host, r
return out, nil
}
+// instanceSetChannel moves one route onto a different release channel —
+// `carlos instances set-channel`, the CLI front over
+// handleAPIInstanceChannel. A gated move comes back as a 202 the same way a
+// gated promote does, so the caller gets an *apiProposedError naming where
+// to finish rather than a refusal that reads like a failure.
+func (s *apiSession) instanceSetChannel(ctx context.Context, a apiAcct, app, host, channel string) (apiInstanceRecord, error) {
+ in := struct {
+ Channel string `json:"channel"`
+ }{channel}
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)+"/channel"), in)
+ if err != nil {
+ return apiInstanceRecord{}, err
+ }
+ if status == http.StatusAccepted {
+ return apiInstanceRecord{}, proposedFromBody(body)
+ }
+ if status != http.StatusOK {
+ return apiInstanceRecord{}, apiErrorFromBody(status, body)
+ }
+ var out apiInstanceRecord
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiInstanceRecord{}, fmt.Errorf("instances set-channel: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
// instanceSetUpstreams asks the console to repoint one instance's upstream
// set and/or its probe — `carlos instances set-upstreams`, the CLI front
// over handleAPIInstanceUpdate's PATCH (Task 7, issue #152).
diff --git a/apimode_test.go b/apimode_test.go
index 941e12fd9..c6f59418d 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -771,7 +771,7 @@ func TestInstanceCreateSendsAddrsAndProbe(t *testing.T) {
s := newTestSession(ts.URL)
rec, err := s.instanceCreate(context.Background(), apiAcct{Seg: "bah"}, "app",
- "a.example.com", "", "", []string{"10.0.0.1:8090", "10.0.0.2:8090"},
+ "a.example.com", "", "", "", []string{"10.0.0.1:8090", "10.0.0.2:8090"},
&instanceProbeReq{Path: "/healthz", Status: 200})
if err != nil {
t.Fatalf("instanceCreate: %v", err)
@@ -799,7 +799,7 @@ func TestInstanceCreateSendsAddrsAndProbe(t *testing.T) {
// No addrs/probe given at all: neither key crosses the wire.
gotBody = ""
if _, err := s.instanceCreate(context.Background(), apiAcct{Seg: "bah"}, "app",
- "b.example.com", "", "", nil, nil); err != nil {
+ "b.example.com", "", "", "", nil, nil); err != nil {
t.Fatalf("instanceCreate (no addrs/probe): %v", err)
}
if strings.Contains(gotBody, "addrs") || strings.Contains(gotBody, "probe") {
@@ -3733,3 +3733,100 @@ func TestResolvedAccountExplicitFlagsBypass(t *testing.T) {
t.Fatalf("explicit flags must never consult the endpoint; hits = %d", *hits)
}
}
+
+// TestAPISessionInstanceCreateSendsKind pins the field the static mint
+// depends on: the door has read a "kind" since the route-records design, but
+// this client never sent one, so every record it wrote was an instance.
+// Empty stays absent from the wire — the door's own "empty means instance"
+// contract, not a value this client invents.
+func TestAPISessionInstanceCreateSendsKind(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ kind string
+ wantKind any
+ }{
+ {"static", "static", "static"},
+ {"empty is omitted", "", nil},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var got map[string]any
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
+ t.Errorf("decode body: %v", err)
+ }
+ w.WriteHeader(http.StatusCreated)
+ io.WriteString(w, `{"host":"mysite.bab.oncarlos.test","kind":"static","channel":"edge"}`)
+ }))
+ defer ts.Close()
+
+ rec, err := newTestSession(ts.URL).instanceCreate(context.Background(),
+ apiAcct{Seg: "bab"}, "mysite", "mysite.bab.oncarlos.test", "", "edge", tc.kind, nil, nil)
+ if err != nil {
+ t.Fatalf("instanceCreate: %v", err)
+ }
+ if got["kind"] != tc.wantKind {
+ t.Fatalf("wire kind = %#v, want %#v", got["kind"], tc.wantKind)
+ }
+ // The channel is the rail: an omitted one is defaulted to stable
+ // by the door, and no door can change a record's channel after.
+ if got["channel"] != "edge" {
+ t.Fatalf("wire channel = %#v, want an explicit %q", got["channel"], "edge")
+ }
+ if rec.Kind != "static" {
+ t.Fatalf("decoded Kind = %q", rec.Kind)
+ }
+ })
+ }
+}
+
+// TestAPISessionInstanceSetChannel pins the move client: the channel on the
+// wire, the record back on a 200, and — the case that must not read as a
+// failure — a 202 decoded as a PROPOSAL naming where to finish.
+func TestAPISessionInstanceSetChannel(t *testing.T) {
+ t.Run("lands", func(t *testing.T) {
+ var got map[string]any
+ var gotPath string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
+ t.Errorf("decode body: %v", err)
+ }
+ io.WriteString(w, `{"host":"web.bab.oncarlos.test","channel":"edge","kind":"instance"}`)
+ }))
+ defer ts.Close()
+
+ rec, err := newTestSession(ts.URL).instanceSetChannel(context.Background(),
+ apiAcct{Seg: "bab"}, "mysite", "web.bab.oncarlos.test", "edge")
+ if err != nil {
+ t.Fatalf("instanceSetChannel: %v", err)
+ }
+ if got["channel"] != "edge" {
+ t.Fatalf("wire channel = %#v", got["channel"])
+ }
+ if !strings.HasSuffix(gotPath, "/instances/web.bab.oncarlos.test/channel") {
+ t.Fatalf("path = %q, want the per-host channel door", gotPath)
+ }
+ if rec.Channel != "edge" {
+ t.Fatalf("decoded Channel = %q", rec.Channel)
+ }
+ })
+
+ t.Run("gated move is a proposal, not a failure", func(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusAccepted)
+ io.WriteString(w, `{"result":"proposed","pending_id":"pc1","needed":2,"confirmed":0,`+
+ `"console_url":"https://console.example/a/bab/apps/mysite"}`)
+ }))
+ defer ts.Close()
+
+ _, err := newTestSession(ts.URL).instanceSetChannel(context.Background(),
+ apiAcct{Seg: "bab"}, "mysite", "web.bab.oncarlos.test", "edge")
+ var prop *apiProposedError
+ if !errors.As(err, &prop) {
+ t.Fatalf("err = %v, want an *apiProposedError", err)
+ }
+ if prop.Needed != 2 || prop.ConsoleURL == "" {
+ t.Fatalf("proposal = %+v, want the quorum and where to finish", prop)
+ }
+ })
+}
diff --git a/cli.go b/cli.go
index 35b914db7..c7c919307 100644
--- a/cli.go
+++ b/cli.go
@@ -287,6 +287,9 @@ func runInstances(args []string) {
case "set-upstreams":
runInstancesSetUpstreams(args[1:])
return
+ case "set-channel":
+ runInstancesSetChannel(args[1:])
+ return
}
}
fs := flag.NewFlagSet("instances", flag.ExitOnError)
@@ -781,8 +784,11 @@ func runRoute(args []string) {
chanVerdicts[i] = verdict{rt: rt, from: from, kind: vRecordOwned, note: fmt.Sprintf(
"provisioned route on %s — its channel is owned by the instance record at\n"+
"apps/%s/%s/instances/%s.json, which the provisioning reconciler converges onto this row\n"+
- "on every pass; a change here is reverted within seconds of the next serial bump.",
- from, release.RouteAccount(rt), rt.App, rt.Host)}
+ "on every pass; a change here is reverted within seconds of the next serial bump.\n"+
+ "Change it where it lives:\n"+
+ " carlos instances set-channel -app %s -host %s -channel %s",
+ from, release.RouteAccount(rt), rt.App, rt.Host,
+ rt.App, rt.Host, *channel)}
default:
chanVerdicts[i] = verdict{rt: rt, from: from, kind: vNeedsCheck}
anyNeedsCheck = true
diff --git a/deploy.go b/deploy.go
index eb597b75d..6e5a9415d 100644
--- a/deploy.go
+++ b/deploy.go
@@ -42,7 +42,7 @@ func runDeploy(args []string) {
target := fs.String("target", "linux-arm64", "GOOS-GOARCH the binary was built for (binary kind, single file)")
capsFile := fs.String("capabilities", "", "path to a JSON capability block (internal/caps) to embed in the manifest")
channelFlag := fs.String("channel", "", "channel to promote to (default: resolved from the app's instances, or its one channel if it has no instances; error if ambiguous)")
- hostFlag := fs.String("host", "", "scope channel resolution and the watch to one instance host (required for an app with no instances, e.g. a static site)")
+ hostFlag := fs.String("host", "", "serve on this hostname instead of the app's own platform host (also scopes channel resolution and the watch to it)")
timeout := fs.Duration("timeout", defaultDeployTimeout, "how long to watch the URL before failing")
fs.Parse(args)
usage := "usage: carlos deploy -app <a> [-version <sha>] [-kind binary|static] [-target linux-arm64] " +
@@ -188,6 +188,32 @@ func runDeploy(args []string) {
func() (apiChannelsResult, error) { return sess.channels(ctx, acct, *app) })
}
}
+ // A static app with nothing serving it yet: mint its platform host
+ // rather than refusing (#186's static half). Typed, never text-matched,
+ // and BEFORE the ship — attachUnclaimedHost's ordering, so a failed mint
+ // never leaves a published version with nothing to serve it.
+ //
+ // The channel guard is load-bearing, not defensive: deployTargets
+ // returns this refusal with an empty channel when the app has no
+ // resolvable channel EITHER, and minting a record with no channel is
+ // exactly the stable-defaulted route this path exists to avoid. With no
+ // channel there is nothing to mint onto, so the refusal stands and names
+ // -host, which is the step that makes -channel askable next.
+ if errors.Is(err, errNoStaticHost) && channel != "" {
+ host, mintErr := mintPlatformHost(ctx, sess, acct, *app, channel)
+ switch {
+ case mintErr == nil:
+ hosts, err = []string{host}, nil
+ case enableUnavailable(mintErr):
+ // This deployment has no platform host to give — no apps domain,
+ // or an operator-pinned instance domain. The original refusal is
+ // the actionable one; the door's own reason rides along so the
+ // member is not left wondering why nothing was offered.
+ mutedf(stdout, "%v\n", mintErr)
+ default:
+ log.Fatalf("deploy: mint the platform host: %v", mintErr)
+ }
+ }
if err != nil {
log.Fatalf("deploy: %v", err)
}
@@ -512,40 +538,27 @@ func (s *apiSession) accountName(acct apiAcct) string {
func deployTargets(recs []apiInstanceRecord, kind, channelFlag, hostFlag string,
channels func() (apiChannelsResult, error)) (channel string, hosts []string, static bool, err error) {
if kind == "static" && staticOnlyRecords(recs) {
- hosts, err := staticDeployHosts(hostFlag, recs)
- if err != nil {
- return "", nil, true, err
- }
- if channelFlag != "" {
- return channelFlag, hosts, true, nil
- }
- // A static record that names the deploy's own -host already knows
- // its channel — the record the first deploy's attach wrote. Same
- // convenience the instance path has always had (resolveDeployChannel
- // resolves from the -host record); a host with no record yet — the
- // SECOND static domain — falls through to the channels listing.
- if ch := staticRecordChannel(recs, hostFlag); ch != "" {
- return ch, hosts, true, nil
- }
- res, err := channels()
- if err != nil {
- return "", nil, true, fmt.Errorf("list channels: %w", err)
- }
- // No channels at all AND a declared pipeline: this is spec §2's
- // entry channel doing its job — a brand-new one-channel app's
- // FIRST deploy just lands on it, rather than being refused for a
- // channel that (being pipelined) was never going to be created any
- // other way. A pipeline whose channels have ALREADY been promoted
- // onto is not this case — staticDeployChannel below resolves it
- // exactly as before, unambiguous or not.
- if len(res.Channels) == 0 && res.PipelineEntry != "" {
- return res.PipelineEntry, hosts, true, nil
- }
- channel, err := staticDeployChannel(res.Channels)
- if err != nil {
- return "", nil, true, err
+ // Hosts and channel are resolved INDEPENDENTLY, because the mint
+ // path needs the channel precisely when the host is missing: the
+ // record this deploy is about to create has to name a channel, and
+ // an omitted one is defaulted to stable by the door
+ // (internal/console/apiinstances.go) — a route that serves nothing,
+ // with no door to change it afterwards.
+ //
+ // So errNoStaticHost travels WITH a usable channel: the one place
+ // this function returns both a value and an error, and the reason
+ // the two resolutions are no longer nested.
+ hosts, herr := staticDeployHosts(hostFlag, recs)
+ channel, cerr := staticChannel(channelFlag, hostFlag, recs, channels)
+ if cerr != nil {
+ // Both unresolved: the host refusal is the one that names
+ // something the member can actually supply, so it wins.
+ if herr != nil {
+ return "", nil, true, herr
+ }
+ return "", nil, true, cerr
}
- return channel, hosts, true, nil
+ return channel, hosts, true, herr
}
if channelFlag != "" {
// Explicit channel: no ambiguity to resolve. Watch the instances that
@@ -651,10 +664,45 @@ func staticDeployHosts(hostFlag string, recs []apiInstanceRecord) ([]string, err
if len(hosts) > 0 {
return hosts, nil
}
- return nil, errors.New("this app has no instance records — a static site is served by the edge straight from its\n" +
- "channel pointer, so nothing is ever adopted and no instance exists to resolve a host from.\n" +
- "Name the hostname the site answers on, so the deploy can watch it:\n" +
- " carlos deploy -app <a> -kind static -host carlosframework.com <site-dir>")
+ return nil, fmt.Errorf("%w: this app has no instance records — a static site is served by the edge straight from its\n"+
+ "channel pointer, so nothing is ever adopted and no instance exists to resolve a host from.\n"+
+ "Name the hostname the site answers on, so the deploy can watch it:\n"+
+ " carlos deploy -app <a> -kind static -host carlosframework.com <site-dir>", errNoStaticHost)
+}
+
+// staticChannel resolves the channel a static deploy promotes onto, in the
+// order the flag, the record and the app's channels earn it. Split out of
+// deployTargets' static branch so the MINT path can resolve a channel on the
+// very path where the host resolution fails — the two answers are
+// independent, and only their nesting ever made them look otherwise.
+func staticChannel(channelFlag, hostFlag string, recs []apiInstanceRecord,
+ channels func() (apiChannelsResult, error)) (string, error) {
+ if channelFlag != "" {
+ return channelFlag, nil
+ }
+ // A static record that names the deploy's own -host already knows its
+ // channel — the record the first deploy's attach wrote. Same convenience
+ // the instance path has always had (resolveDeployChannel resolves from
+ // the -host record); a host with no record yet — the SECOND static
+ // domain — falls through to the channels listing.
+ if ch := staticRecordChannel(recs, hostFlag); ch != "" {
+ return ch, nil
+ }
+ res, err := channels()
+ if err != nil {
+ return "", fmt.Errorf("list channels: %w", err)
+ }
+ // No channels at all AND a declared pipeline: this is the pipelines
+ // spec's entry channel doing its job — a brand-new one-channel app's
+ // FIRST deploy just lands on it, rather than being refused for a channel
+ // that (being pipelined) was never going to be created any other way. A
+ // pipeline whose channels have ALREADY been promoted onto is not this
+ // case — staticDeployChannel resolves it exactly as before, unambiguous
+ // or not.
+ if len(res.Channels) == 0 && res.PipelineEntry != "" {
+ return res.PipelineEntry, nil
+ }
+ return staticDeployChannel(res.Channels)
}
// staticDeployChannel picks the channel a NO-INSTANCE deploy promotes to,
@@ -696,6 +744,47 @@ func staticDeployChannel(chans []apiChannel) (string, error) {
// (review 2026-08-13, finding 2).
var errAmbiguousChannel = errors.New("ambiguous channel")
+// errNoStaticHost marks the refusal "this static app has nothing serving it
+// yet". Typed for errAmbiguousChannel's reason, and the stakes are the same
+// shape: runDeploy answers THIS refusal by minting the app's platform host
+// (static-platform-host design §3.2), and a refusal matched by text would
+// eventually mint a hostname in answer to a different sentence that merely
+// mentioned one.
+var errNoStaticHost = errors.New("no static host")
+
+// mintPlatformHost gives an app its platform hostname —
+// <app>.<sqid>.<apps-domain> — and the static route record that serves it.
+// This is the whole of what #186's static half was missing: DNS already
+// resolves every name under the apps domain to the edge, and the edge
+// derives its own per-account wildcard cert from the live route table, so a
+// record is the only thing standing between a fresh static app and a URL.
+//
+// Two existing doors, in order, and no new authority anywhere. The manifest
+// PUT is an idempotent upsert — it CREATES when absent and otherwise
+// preserves every field, since nil channel and nil seed both mean "leave it"
+// — and it answers with the domain the console pins to the account, so this
+// client never has to know the deployment's apps domain to build the host.
+//
+// Then the record, carrying the kind and — the rail that must not bend — an
+// EXPLICIT channel. An omitted one is defaulted by the door to the
+// manifest's, then to "stable", while a fresh app's pipeline entry is
+// "edge": that record would follow a channel nothing ever promotes to, and
+// no door can change a record's channel afterwards. Repairing exactly that
+// took a raw bucket write (hello.bdn, 2026-08-22).
+func mintPlatformHost(ctx context.Context, sess *apiSession, acct apiAcct, app, channel string) (string, error) {
+ m, err := sess.instancesEnable(ctx, acct, app, nil, nil)
+ if err != nil {
+ return "", err
+ }
+ rec, err := sess.instanceCreate(ctx, acct, app, app+"."+m.Domain, "", channel, "static", nil, nil)
+ if err != nil {
+ return "", err
+ }
+ fmt.Fprintf(stdout, "%s %s (static, channel %s)\n",
+ colorFor(stdout).ok("minted"), rec.Host, rec.Channel)
+ return rec.Host, nil
+}
+
// attachUnclaimedHost auto-attaches -host before shipping, when this deploy
// is targeting a fresh static host with no route of its own yet (self-serve
// custom domains, #82/#151): the app's first deploy to a brand-new hostname
diff --git a/deploy_e2e_test.go b/deploy_e2e_test.go
index a810f457e..76f9e5357 100644
--- a/deploy_e2e_test.go
+++ b/deploy_e2e_test.go
@@ -445,3 +445,170 @@ func TestE2EDeployThroughConsole(t *testing.T) {
// Nothing here may have opened a local registry: this is API mode.
checkNoRegistry()
}
+
+// TestE2EStaticDeployMintsPlatformHost is #186's static half, end to end: an
+// app with no route and no -host gets one. The console, the edge and the
+// bucket are all real, so what this proves is not "the CLI sent a request"
+// but that the record the mint writes is the record the edge serves from.
+//
+// The member never names a hostname. That is the whole feature: DNS under
+// the apps domain already resolves to the edge and the edge already derives
+// its own wildcard cert, so a record was the only missing piece.
+func TestE2EStaticDeployMintsPlatformHost(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ const (
+ addr = "paul@keymail.dev"
+ app = "freshly"
+ channel = "canary/site"
+ // What the mint must derive on its own: the app's label under the
+ // account's platform subdomain, ops being "bab".
+ wantHost = app + ".bab.oncarlos.test"
+ )
+
+ tmp := t.TempDir()
+ relStore := release.NewDirStore(filepath.Join(tmp, "store"))
+ seed, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ priv, err := release.ParseSigningKey(seed, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub, err := release.ParsePublicKey(pubB64, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cst, err := console.Open(filepath.Join(tmp, "console.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { cst.Close() })
+
+ var srv *console.Server
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ srv.ServeHTTP(w, r)
+ }))
+ t.Cleanup(ts.Close)
+ srv, err = console.NewServer(console.Config{
+ Origin: ts.URL,
+ Keymail: "http://keymail.invalid",
+ Registry: filepath.Join(tmp, "registry.db"),
+ Releases: relStore,
+ ReleasePub: pub,
+ Key: priv,
+ AppScheme: "http",
+ AppsDomain: "oncarlos.test",
+ }, cst)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := cst.CreateAppIn(console.OpsAccountID, app, addr, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ token, expiresAt := e2eDeviceLogin(t, ts.URL, cst, addr, "e2e@laptop")
+ credFile, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt})
+ if err := credFile.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ edgeReg, err := registry.Open(filepath.Join(tmp, "edge-registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { edgeReg.Close() })
+ ed, err := edge.New(edgeReg, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ed.EnableStatic(relStore, pub, filepath.Join(tmp, "edge-cache"))
+ ed.SetStaticRecheck(0)
+ edgeSrv := httptest.NewServer(ed)
+ t.Cleanup(edgeSrv.Close)
+ // Standing in for the reconcile pass that materializes the record on a
+ // box: the same route, on the loopback host the watch dials.
+ if err := edgeReg.Upsert(registry.Route{
+ Host: "127.0.0.1", Kind: "static", App: app, Channel: channel, Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := ed.Reload(); err != nil {
+ t.Fatal(err)
+ }
+ origOrigin := watchOrigin
+ watchOrigin = func(h string) string {
+ if h != wantHost {
+ t.Errorf("watch dialled %q, want the MINTED host %q", h, wantHost)
+ }
+ return edgeSrv.URL
+ }
+ t.Cleanup(func() { watchOrigin = origOrigin })
+
+ siteDir := filepath.Join(tmp, "site")
+ if err := os.MkdirAll(siteDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("hello"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ buf := captureStdout(t)
+ runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001",
+ "-channel", channel, "-timeout", "30s", siteDir})
+ out := buf.String()
+ for _, wantLine := range []string{
+ "minted " + wantHost,
+ "deploying https://" + wantHost,
+ "promoted " + app + " sha0001 -> " + channel,
+ "live https://" + wantHost,
+ } {
+ if !strings.Contains(out, wantLine) {
+ t.Fatalf("mint deploy stdout missing %q:\n%s", wantLine, out)
+ }
+ }
+
+ // The record is the deliverable: a box's reconcile pass mints the route
+ // from THIS, so the kind and the channel are what decide whether the URL
+ // ever serves anything.
+ rec, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, app, wantHost)
+ if err != nil {
+ t.Fatalf("record after mint: %v", err)
+ }
+ if rec.Kind != "static" {
+ t.Fatalf("record Kind = %q, want static", rec.Kind)
+ }
+ if rec.Channel != channel {
+ t.Fatalf("record Channel = %q, want %q — an omitted channel would have written stable", rec.Channel, channel)
+ }
+ assertServes(t, edgeSrv.URL, "hello", "sha0001")
+
+ // A REDEPLOY must not mint again: the record is now in the instance
+ // list, so the host resolves from it.
+ if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("hello2"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ buf.Reset()
+ runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0002",
+ "-timeout", "30s", siteDir})
+ out = buf.String()
+ if strings.Contains(out, "minted") {
+ t.Fatalf("redeploy minted a second time:\n%s", out)
+ }
+ if !strings.Contains(out, "live https://"+wantHost) {
+ t.Fatalf("redeploy did not resolve the host from the record:\n%s", out)
+ }
+ assertServes(t, edgeSrv.URL, "hello2", "sha0002")
+
+ checkNoRegistry()
+}
diff --git a/deploy_test.go b/deploy_test.go
index 7c018ad60..70ae92f03 100644
--- a/deploy_test.go
+++ b/deploy_test.go
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -1550,3 +1551,161 @@ func TestDeployPromoteProposedExitsCleanly(t *testing.T) {
}
checkNoRegistry()
}
+
+// TestDeployTargetsStaticNoHostIsTyped pins the refusal runDeploy answers by
+// MINTING the app's platform host (static-platform-host design §3.2). It is
+// typed for errAmbiguousChannel's reason: a refusal matched by text would
+// eventually mint in answer to a different sentence that merely mentioned a
+// hostname.
+func TestDeployTargetsStaticNoHostIsTyped(t *testing.T) {
+ _, _, static, err := deployTargets(nil, "static", "", "", func() (apiChannelsResult, error) {
+ return apiChannelsResult{PipelineEntry: "edge"}, nil
+ })
+ if !static {
+ t.Fatal("static = false, want true")
+ }
+ if !errors.Is(err, errNoStaticHost) {
+ t.Fatalf("err = %v, want errNoStaticHost", err)
+ }
+}
+
+// The mint needs a channel, and the only place it can come from is the
+// resolution deployTargets already does — so errNoStaticHost travels WITH a
+// usable channel.
+func TestDeployTargetsStaticNoHostCarriesResolvedChannel(t *testing.T) {
+ channel, hosts, _, err := deployTargets(nil, "static", "", "", func() (apiChannelsResult, error) {
+ return apiChannelsResult{PipelineEntry: "edge"}, nil
+ })
+ if !errors.Is(err, errNoStaticHost) {
+ t.Fatalf("err = %v, want errNoStaticHost", err)
+ }
+ if channel != "edge" {
+ t.Fatalf("channel = %q, want %q — the mint needs it", channel, "edge")
+ }
+ if len(hosts) != 0 {
+ t.Fatalf("hosts = %v, want none", hosts)
+ }
+}
+
+// Both unresolved: the host refusal names something the member can supply,
+// so it is the one that surfaces.
+func TestDeployTargetsStaticNoHostNoChannelPrefersHostRefusal(t *testing.T) {
+ channel, _, _, err := deployTargets(nil, "static", "", "", func() (apiChannelsResult, error) {
+ return apiChannelsResult{}, nil
+ })
+ if !errors.Is(err, errNoStaticHost) {
+ t.Fatalf("err = %v, want errNoStaticHost", err)
+ }
+ // And the channel is EMPTY here, which is the whole reason runDeploy
+ // guards its mint on one: a record written with no channel is defaulted
+ // to stable by the door, and nothing can change it afterwards.
+ if channel != "" {
+ t.Fatalf("channel = %q, want empty — runDeploy must have nothing to mint onto", channel)
+ }
+}
+
+// Unchanged: a -host whose static record names a channel resolves from the
+// record and never lists channels.
+func TestDeployTargetsStaticHostRecordChannelSkipsListing(t *testing.T) {
+ recs := []apiInstanceRecord{{Host: "site.example.com", Kind: "static", Channel: "beta"}}
+ channel, hosts, static, err := deployTargets(recs, "static", "", "site.example.com", func() (apiChannelsResult, error) {
+ t.Error("channels() called; the record already names the channel")
+ return apiChannelsResult{}, nil
+ })
+ if err != nil || !static || channel != "beta" || len(hosts) != 1 || hosts[0] != "site.example.com" {
+ t.Fatalf("got %q %v %v %v", channel, hosts, static, err)
+ }
+}
+
+// TestMintPlatformHostSendsKindAndChannel pins the two fields the mint
+// cannot get wrong: the record's kind, and an EXPLICIT channel (an omitted
+// one is defaulted to stable by the door, and no door can change a record's
+// channel afterwards).
+func TestMintPlatformHostSendsKindAndChannel(t *testing.T) {
+ var create map[string]any
+ var enabled bool
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case strings.HasSuffix(r.URL.Path, "/instances-manifest"):
+ enabled = true
+ io.WriteString(w, `{"app":"mysite","domain":"bab.oncarlos.test"}`)
+ case strings.HasSuffix(r.URL.Path, "/instances"):
+ if err := json.NewDecoder(r.Body).Decode(&create); err != nil {
+ t.Errorf("decode create: %v", err)
+ }
+ w.WriteHeader(http.StatusCreated)
+ io.WriteString(w, `{"host":"mysite.bab.oncarlos.test","kind":"static","channel":"edge"}`)
+ default:
+ t.Errorf("unexpected path %s", r.URL.Path)
+ }
+ }))
+ defer ts.Close()
+
+ out := captureStdout(t)
+ host, err := mintPlatformHost(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "mysite", "edge")
+ if err != nil {
+ t.Fatalf("mintPlatformHost: %v", err)
+ }
+ if !enabled {
+ t.Fatal("the manifest was never written; the create door refuses without one")
+ }
+ if host != "mysite.bab.oncarlos.test" {
+ t.Fatalf("host = %q, want the app's label under the manifest's domain", host)
+ }
+ if create["kind"] != "static" {
+ t.Fatalf("create kind = %#v, want static", create["kind"])
+ }
+ if create["channel"] != "edge" {
+ t.Fatalf("create channel = %#v, want an EXPLICIT edge", create["channel"])
+ }
+ if got := out.String(); !strings.Contains(got, "minted") || !strings.Contains(got, host) {
+ t.Fatalf("stdout = %q, want it to name what was minted", got)
+ }
+}
+
+// A deployment with no platform namespace (503) and one whose instance
+// domain an operator pinned elsewhere (409) both mean the same thing: this
+// deployment will not mint a platform host for this app. Classified by
+// STATUS, never by reading the message.
+func TestMintPlatformHostUnavailableIsClassifiedByStatus(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ status int
+ body string
+ }{
+ {"no apps domain", http.StatusServiceUnavailable,
+ `{"error":"this console has no platform apps domain configured"}`},
+ {"operator-pinned domain", http.StatusConflict,
+ `{"error":"instances for mysite are managed under go.tito.io"}`},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(tc.status)
+ io.WriteString(w, tc.body)
+ }))
+ defer ts.Close()
+ _, err := mintPlatformHost(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "mysite", "edge")
+ if err == nil {
+ t.Fatal("want a refusal")
+ }
+ if !enableUnavailable(err) {
+ t.Fatalf("err = %v, want it classified unavailable", err)
+ }
+ })
+ }
+}
+
+// Every other failure is a real one and must NOT be classified as "this
+// deployment has no platform host" — a 403 means the credential lost
+// authority, which is worth stopping the deploy for.
+func TestMintPlatformHostForbiddenIsNotUnavailable(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ io.WriteString(w, `{"error":"not a member"}`)
+ }))
+ defer ts.Close()
+ _, err := mintPlatformHost(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bab"}, "mysite", "edge")
+ if err == nil || enableUnavailable(err) {
+ t.Fatalf("err = %v, want an unclassified failure", err)
+ }
+}
diff --git a/docs/superpowers/plans/2026-08-23-record-channel-change.md b/docs/superpowers/plans/2026-08-23-record-channel-change.md
new file mode 100644
index 000000000..e98250fd7
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-23-record-channel-change.md
@@ -0,0 +1,601 @@
+# Record channel change Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A member can change which channel one of their routes follows, from a terminal, with no bucket credentials — closing the gap that made repairing `hello.bdn` a raw S3 write.
+
+**Architecture:** A new door, `POST /api/cli/apps/{account}/{app}/instances/{host}/channel`, writing the record under the etag it was read with. The gate is the union of the rules of the channel being left and the channel being joined, each at its strictest, reusing the pipelines-v2 proposal primitive unchanged.
+
+**Tech Stack:** Go 1.x, `internal/console` (doors, pending changes), `internal/provision` (record read/write), `internal/release` (pipeline rules).
+
+**Spec:** `docs/superpowers/specs/2026-08-23-record-channel-change-design.md`
+
+## Global Constraints
+
+- Full gate before any commit lands: `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l` clean for touched files, plus `GOOS=darwin go build ./...`.
+- Sandbox env: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`.
+- Console security gates are ordered: session → CSRF → owner (byte-identical 404) → `requireFreshAuth` last.
+- A write bumps `control/serial` only when it actually changed something.
+- The CLI proposes and never confirms; confirmation is passkey-backed, in the console.
+- Production channel names are pinned in tests (`internal/release/laddercheck_test.go`) — the fleet runs `canary/*` today and nothing here may refuse them.
+
+---
+
+### Task 1: The move gate as a pure function
+
+**Files:**
+- Create: `internal/console/channelmove.go`
+- Test: `internal/console/channelmove_test.go`
+
+**Interfaces:**
+- Produces: `func moveGate(p *release.Pipeline, from, to string) (passkey bool, approvals int)`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package console
+
+import (
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func pipe(chans ...release.PipelineChannel) *release.Pipeline {
+ return &release.Pipeline{Schema: 1, Channels: chans}
+}
+
+// The bypass the union exists to close: the guarded channel is the one being
+// LEFT, and the destination carries no rules at all.
+func TestMoveGateTakesRulesFromTheChannelBeingLeft(t *testing.T) {
+ p := pipe(
+ release.PipelineChannel{Name: "edge"},
+ release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}},
+ )
+ passkey, approvals := moveGate(p, "production", "edge")
+ if approvals != 2 {
+ t.Fatalf("approvals = %d, want 2 — moving OFF a guarded channel serves its URL unreviewed code", approvals)
+ }
+ if passkey {
+ t.Fatal("passkey = true, want false — neither channel asks for one")
+ }
+}
+
+func TestMoveGateTakesRulesFromTheChannelBeingJoined(t *testing.T) {
+ p := pipe(
+ release.PipelineChannel{Name: "edge"},
+ release.PipelineChannel{Name: "production", Rules: release.ChannelRules{Passkey: true}},
+ )
+ passkey, approvals := moveGate(p, "edge", "production")
+ if !passkey || approvals != 0 {
+ t.Fatalf("passkey=%v approvals=%d, want true/0", passkey, approvals)
+ }
+}
+
+func TestMoveGateTakesTheMaximumAcrossBothEndsAndBothRules(t *testing.T) {
+ p := pipe(
+ release.PipelineChannel{Name: "a", Rules: release.ChannelRules{ChangeApprovals: 3}},
+ release.PipelineChannel{Name: "b", Rules: release.ChannelRules{PromoteApprovals: 1}},
+ )
+ if _, approvals := moveGate(p, "a", "b"); approvals != 3 {
+ t.Fatalf("approvals = %d, want 3", approvals)
+ }
+}
+
+// A legacy app has no pipeline: the stable RUNG keeps the console's existing
+// unconditional passkey, in either direction, and nothing else is gated.
+func TestMoveGateLegacyStableNeedsPasskeyBothDirections(t *testing.T) {
+ if passkey, _ := moveGate(nil, "edge", "stable"); !passkey {
+ t.Fatal("moving ONTO stable must need a passkey")
+ }
+ if passkey, _ := moveGate(nil, "stable", "edge"); !passkey {
+ t.Fatal("moving OFF stable must need a passkey")
+ }
+ if passkey, approvals := moveGate(nil, "edge", "beta"); passkey || approvals != 0 {
+ t.Fatalf("edge→beta on a legacy app must be free, got %v/%d", passkey, approvals)
+ }
+}
+
+// canary/* is reserved platform namespace: always allowed, never gated.
+func TestMoveGateCanaryIsUngated(t *testing.T) {
+ p := pipe(release.PipelineChannel{Name: "edge", Rules: release.ChannelRules{Passkey: true}})
+ if passkey, approvals := moveGate(p, "canary/x", "canary/y"); passkey || approvals != 0 {
+ t.Fatalf("canary moves must be ungated, got %v/%d", passkey, approvals)
+ }
+}
+```
+
+- [ ] **Step 2: Run them and watch them fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console -run TestMoveGate -count=1`
+Expected: FAIL — `undefined: moveGate`.
+
+- [ ] **Step 3: Implement**
+
+```go
+// Moving a route between channels — the gate, as one function, because the
+// rule is easy to state and easy to get subtly wrong.
+//
+// A channel move is not a promotion, but it can ACHIEVE one. Given `edge`
+// (no rules) and `production` (promote_approvals 2), promoting v5 onto
+// production takes two approvals — or the production route is moved onto
+// edge and its URL serves v5 with nobody's approval. Guarding only the
+// destination misses that entirely, because the destination is the
+// unguarded channel; guarding only the source lets a route be walked onto a
+// channel whose policy the mover never met. So both ends, each at its
+// strictest.
+//
+// promote_approvals counts here even though this is not a promotion: it is
+// the number that expresses "this channel's URL only serves what N people
+// agreed to", and a move is a way to change what that URL serves.
+package console
+
+func moveGate(p *release.Pipeline, from, to string) (passkey bool, approvals int) {
+ for _, ch := range []string{from, to} {
+ // canary/* is reserved platform namespace, outside every pipeline:
+ // always allowed, bake 0, no rules (release pipelines v2 §2).
+ if strings.HasPrefix(ch, "canary/") {
+ continue
+ }
+ if p == nil {
+ // Legacy ladder: the console's standing unconditional passkey on
+ // stable, in both directions.
+ if rung, _ := release.Rung(ch); rung == release.RungStable {
+ passkey = true
+ }
+ continue
+ }
+ rules := channelRules(p, ch)
+ if rules.Passkey {
+ passkey = true
+ }
+ if rules.PromoteApprovals > approvals {
+ approvals = rules.PromoteApprovals
+ }
+ if rules.ChangeApprovals > approvals {
+ approvals = rules.ChangeApprovals
+ }
+ }
+ return passkey, approvals
+}
+```
+
+- [ ] **Step 4: Run the tests**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console -run TestMoveGate -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/channelmove.go internal/console/channelmove_test.go
+git commit -m "console: the channel-move gate is the union of both ends"
+```
+
+---
+
+### Task 2: The door
+
+**Files:**
+- Modify: `internal/console/channelmove.go` (handler), `internal/console/authcli.go` (registration)
+- Test: `internal/console/channelmove_test.go`
+
+**Interfaces:**
+- Consumes: `moveGate` (Task 1), `s.instanceGate`, `provision.GetRecordETag`, `provision.UpdateRecord`, `s.proposeOverAPI`, `s.appConsoleURL`, `s.audit`, `s.bumpSerial`.
+- Produces: `func (s *Server) handleAPIInstanceChannel(w http.ResponseWriter, r *http.Request)`; `type channelMovePayload struct { Host, From, To, ETag string }`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestChannelMoveLegacyEdgeToBetaLands(t *testing.T) {
+ srv, store := consoleWithRecord(t, "bab", "mysite", "web.bab.oncarlos.com", "edge")
+ status, _ := postChannel(t, srv, "bab", "mysite", "web.bab.oncarlos.com", "beta")
+ if status != http.StatusOK {
+ t.Fatalf("status = %d, want 200", status)
+ }
+ rec := readRecord(t, store, "bab", "mysite", "web.bab.oncarlos.com")
+ if rec.Channel != "beta" {
+ t.Fatalf("Channel = %q, want beta", rec.Channel)
+ }
+ if serialBumps(store) != 1 {
+ t.Fatalf("serial bumps = %d, want exactly 1", serialBumps(store))
+ }
+}
+
+func TestChannelMoveLegacyStableIs403WithConsoleURL(t *testing.T) {
+ srv, _ := consoleWithRecord(t, "bab", "mysite", "web.bab.oncarlos.com", "edge")
+ status, body := postChannel(t, srv, "bab", "mysite", "web.bab.oncarlos.com", "stable")
+ if status != http.StatusForbidden {
+ t.Fatalf("status = %d, want 403", status)
+ }
+ if !strings.Contains(body, "console_url") {
+ t.Fatalf("403 must name where to finish: %s", body)
+ }
+}
+
+// The bypass, asserted end to end at the door.
+func TestChannelMoveOffGuardedChannelProposesWithItsQuorum(t *testing.T) {
+ srv, store := consoleWithRecord(t, "bab", "mysite", "web.bab.oncarlos.com", "production")
+ setPipeline(t, store, "bab", "mysite",
+ release.PipelineChannel{Name: "edge"},
+ release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}})
+ status, body := postChannel(t, srv, "bab", "mysite", "web.bab.oncarlos.com", "edge")
+ if status != http.StatusAccepted {
+ t.Fatalf("status = %d, want 202 (proposed): %s", status, body)
+ }
+ if rec := readRecord(t, store, "bab", "mysite", "web.bab.oncarlos.com"); rec.Channel != "production" {
+ t.Fatalf("Channel = %q — a proposal must not have written yet", rec.Channel)
+ }
+}
+
+func TestChannelMoveToUnknownChannelNamesTheAppsChannels(t *testing.T) {
+ srv, store := consoleWithRecord(t, "bab", "mysite", "web.bab.oncarlos.com", "edge")
+ setPipeline(t, store, "bab", "mysite", release.PipelineChannel{Name: "edge"})
+ status, body := postChannel(t, srv, "bab", "mysite", "web.bab.oncarlos.com", "production")
+ if status != http.StatusConflict {
+ t.Fatalf("status = %d, want 409", status)
+ }
+ if !strings.Contains(body, "edge") {
+ t.Fatalf("refusal must list the app's channels: %s", body)
+ }
+}
+
+func TestChannelMoveToSameChannelChangesNothing(t *testing.T) {
+ srv, store := consoleWithRecord(t, "bab", "mysite", "web.bab.oncarlos.com", "edge")
+ status, _ := postChannel(t, srv, "bab", "mysite", "web.bab.oncarlos.com", "edge")
+ if status != http.StatusOK {
+ t.Fatalf("status = %d, want 200", status)
+ }
+ if serialBumps(store) != 0 {
+ t.Fatal("a no-op move must not bump the serial or send the fleet converging")
+ }
+}
+```
+
+- [ ] **Step 2: Run them and watch them fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console -run TestChannelMove -count=1`
+Expected: FAIL — 404, the door is not registered.
+
+- [ ] **Step 3: Implement**
+
+The handler, in refusal order — gate, record, shape, no-op, pipeline membership, rules, write:
+
+```go
+func (s *Server) handleAPIInstanceChannel(w http.ResponseWriter, r *http.Request) {
+ principal, acct, app, ok := s.instanceGate(w, r)
+ if !ok {
+ return
+ }
+ host := r.PathValue("host")
+ var req struct {
+ Channel string `json:"channel"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ to := strings.TrimSpace(req.Channel)
+ if !release.ValidChannel(to) {
+ jsonError(w, http.StatusBadRequest, "invalid channel name")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, host)
+ if errors.Is(err, os.ErrNotExist) {
+ http.NotFound(w, r) // the family's byte-identical 404
+ return
+ }
+ if err != nil {
+ log.Printf("console: api instance channel %s/%s/%s: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not read the instance")
+ return
+ }
+ from := rec.Channel
+ if from == to {
+ // Nothing changed: no write, no serial bump, no audit line. The
+ // changed-gating rule — a serial that names no change sends every
+ // box in the fleet converging for nothing.
+ writeJSON(w, http.StatusOK, rec)
+ return
+ }
+ pipe, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
+ if err != nil {
+ log.Printf("console: api instance channel %s/%s: read pipeline: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not read the pipeline")
+ return
+ }
+ // A pipelined app may only reach a channel its pipeline names. An
+ // app-defined pipeline is a reason to govern its own channels, never a
+ // way to reach one it left out — the promote door's own ruling.
+ if pipe != nil && !strings.HasPrefix(to, "canary/") && pipe.Index(to) < 0 {
+ jsonError(w, http.StatusConflict,
+ "this app's pipeline does not have a channel called "+to+" — it has "+strings.Join(pipe.Names(), ", "))
+ return
+ }
+ switch passkey, approvals := moveGate(pipe, from, to); {
+ case approvals >= 1:
+ s.proposeOverAPI(w, acct, app, kindRouteChannel,
+ channelMovePayload{Host: host, From: from, To: to, ETag: etag}, approvals, principal)
+ return
+ case passkey:
+ writeJSON(w, http.StatusForbidden, map[string]string{
+ "error": "moving " + host + " between " + from + " and " + to + " needs a passkey — finish in the console",
+ "console_url": s.appConsoleURL(acct, app),
+ })
+ return
+ }
+ rec.Channel = to
+ if err := provision.UpdateRecord(ctx, s.cfg.Releases, *rec, etag); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ jsonError(w, http.StatusConflict, "this instance changed while the request was in flight — try again")
+ return
+ }
+ log.Printf("console: api instance channel %s/%s/%s: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not change the channel — try again")
+ return
+ }
+ s.audit(acct, app, principal, "instance-channel", "moved "+host+" from "+from+" to "+to+" (cli)")
+ s.bumpSerial(ctx)
+ writeJSON(w, http.StatusOK, rec)
+}
+```
+
+Registration in `authcli.go`, beside the other instances doors, with the manifest door's reasoning for the zero Set:
+
+```go
+ // The zero Set, for the manifest door's reason: this door decides what a
+ // route SERVES, and a credential the app itself holds must not be able
+ // to move its own route onto a channel whose rules it never met.
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/channel", svcrole.Set{}, s.handleAPIInstanceChannel)
+```
+
+`Pipeline` has `Entry`, `Index`, `ChannelNamed` and `MaxApprovals` but no `Names`, so add it to `internal/release/pipeline.go` beside them, with a test asserting it returns the names in pipeline order:
+
+```go
+// Names is the pipeline's channel names in promotion order — what a refusal
+// lists when it has to say which channels an app actually has.
+func (p *Pipeline) Names() []string {
+ out := make([]string, len(p.Channels))
+ for i, c := range p.Channels {
+ out[i] = c.Name
+ }
+ return out
+}
+```
+
+- [ ] **Step 4: Run the tests**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/channelmove.go internal/console/channelmove_test.go internal/console/authcli.go internal/release/pipeline.go
+git commit -m "console: a door to change which channel a route follows"
+```
+
+---
+
+### Task 3: Execute the proposal
+
+**Files:**
+- Modify: `internal/console/pendinghandlers.go` (`kindRouteChannel`, `executePending` switch, `pendingSummary`)
+- Test: `internal/console/pendingflow_test.go`
+
+**Interfaces:**
+- Consumes: `channelMovePayload` (Task 2).
+- Produces: `executePendingRouteChannel(ctx, acct, app, actor string, pc *PendingChange) (string, error)`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestExecutePendingRouteChannelWrites(t *testing.T) {
+ // propose with quorum 1 from the console (proposer counts), confirm, and
+ // the record moves.
+ srv, store := consoleWithRecord(t, "bab", "mysite", "web.bab.oncarlos.com", "production")
+ setPipeline(t, store, "bab", "mysite",
+ release.PipelineChannel{Name: "edge"},
+ release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 1}})
+ pc := proposeChannelMove(t, srv, "bab", "mysite", "web.bab.oncarlos.com", "edge")
+ confirmInConsole(t, srv, pc.ID)
+ if rec := readRecord(t, store, "bab", "mysite", "web.bab.oncarlos.com"); rec.Channel != "edge" {
+ t.Fatalf("Channel = %q, want edge", rec.Channel)
+ }
+}
+
+// The pin: a record that moved underneath the proposal invalidates it.
+func TestExecutePendingRouteChannelStaleETagRefuses(t *testing.T) {
+ srv, store := consoleWithRecord(t, "bab", "mysite", "web.bab.oncarlos.com", "production")
+ setPipeline(t, store, "bab", "mysite",
+ release.PipelineChannel{Name: "edge"},
+ release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 1}})
+ pc := proposeChannelMove(t, srv, "bab", "mysite", "web.bab.oncarlos.com", "edge")
+ writeRecordDirectly(t, store, "bab", "mysite", "web.bab.oncarlos.com", "beta") // moves the etag
+ err := confirmInConsoleErr(t, srv, pc.ID)
+ if err == nil || !strings.Contains(err.Error(), "re-propose") {
+ t.Fatalf("err = %v, want the stale-proposal refusal", err)
+ }
+}
+
+func TestPendingSummaryNamesBothEnds(t *testing.T) {
+ pc := PendingChange{Kind: kindRouteChannel, App: "mysite",
+ Payload: mustJSON(channelMovePayload{Host: "web.bab.oncarlos.com", From: "production", To: "edge"})}
+ if got := pendingSummary(pc); got != "move web.bab.oncarlos.com from production to edge" {
+ t.Fatalf("summary = %q", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run them and watch them fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console -run 'PendingRouteChannel|PendingSummaryNamesBothEnds' -count=1`
+Expected: FAIL — `undefined: kindRouteChannel`.
+
+- [ ] **Step 3: Implement**
+
+Add the kind constant beside the other three, the `executePending` case, the summary case, and:
+
+```go
+// executePendingRouteChannel carries out a confirmed channel move. The
+// record is re-read and the proposal's etag is the pin: a record that moved
+// underneath the proposal is a decision made about bytes that no longer
+// exist, which is what staleProposal says.
+func (s *Server) executePendingRouteChannel(ctx context.Context, acct *Account, app, actor string, pc *PendingChange) (string, error) {
+ var p channelMovePayload
+ if err := json.Unmarshal(pc.Payload, &p); err != nil {
+ return "", errors.New("this proposal's payload cannot be read — reject it and propose again")
+ }
+ rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, p.Host)
+ if err != nil {
+ log.Printf("console: execute route-channel %s/%s/%s: %v", acct.Sqid, app, p.Host, err)
+ return "", errors.New("could not read the instance")
+ }
+ if etag != p.ETag || rec.Channel != p.From {
+ return "", staleProposal("the instance is on " + rec.Channel + " now")
+ }
+ rec.Channel = p.To
+ if err := provision.UpdateRecord(ctx, s.cfg.Releases, *rec, etag); err != nil {
+ log.Printf("console: execute route-channel %s/%s/%s: %v", acct.Sqid, app, p.Host, err)
+ return "", errors.New("could not change the channel — try again")
+ }
+ s.audit(acct, app, actor, "instance-channel", fmt.Sprintf(
+ "moved %s from %s to %s (proposed by %s, %d confirmations)", p.Host, p.From, p.To, pc.Proposer, len(pc.Confirms)))
+ s.bumpSerial(ctx)
+ return "moved " + p.Host + " to " + p.To, nil
+}
+```
+
+- [ ] **Step 4: Run the tests**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/pendinghandlers.go internal/console/pendingflow_test.go
+git commit -m "console: carry out a confirmed channel move, pinned to the record it was decided from"
+```
+
+---
+
+### Task 4: `carlos instances set-channel`
+
+**Files:**
+- Modify: `apimode.go` (client method), `instances_cli.go` (verb, usage), `cli.go:772` (the `vRecordOwned` note)
+- Test: `instances_cli_test.go`
+
+**Interfaces:**
+- Consumes: the door from Task 2.
+- Produces: `func (s *apiSession) instanceSetChannel(ctx context.Context, a apiAcct, app, host, channel string) (apiInstanceRecord, error)`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestInstancesSetChannelSendsChannelAndNamesBothEnds(t *testing.T) {
+ var got map[string]any
+ srv := fakeConsole(t, func(w http.ResponseWriter, r *http.Request) {
+ json.NewDecoder(r.Body).Decode(&got)
+ json.NewEncoder(w).Encode(map[string]any{
+ "host": "web.bab.oncarlos.com", "channel": "edge", "kind": "instance"})
+ })
+ out := runCLI(t, srv, "instances", "set-channel", "-app", "mysite",
+ "-host", "web.bab.oncarlos.com", "-channel", "edge")
+ if got["channel"] != "edge" {
+ t.Fatalf("channel = %v", got["channel"])
+ }
+ if !strings.Contains(out, "web.bab.oncarlos.com") || !strings.Contains(out, "edge") {
+ t.Fatalf("output must name the host and the channel:\n%s", out)
+ }
+}
+
+// A gated move is not a failure: the ship-shaped clean exit with the URL.
+func TestInstancesSetChannelProposedExitsCleanlyWithConsoleURL(t *testing.T) {
+ srv := fakeConsole(t, jsonStatus(http.StatusAccepted, map[string]any{
+ "pending_id": "pc_1", "needed": 2, "console_url": "https://console.example/a/bab/apps/mysite"}))
+ out, err := runCLIOut(t, srv, "instances", "set-channel", "-app", "mysite",
+ "-host", "web.bab.oncarlos.com", "-channel", "edge")
+ if err != nil {
+ t.Fatalf("a proposal must exit 0: %v", err)
+ }
+ if !strings.Contains(out, "console.example") {
+ t.Fatalf("output must name where to finish:\n%s", out)
+ }
+}
+
+// A static record takes up to a recheck window to flip; say so.
+func TestInstancesSetChannelStaticPrintsRecheckNote(t *testing.T) {
+ srv := fakeConsole(t, jsonOK(map[string]any{
+ "host": "mysite.bab.oncarlos.com", "channel": "edge", "kind": "static"}))
+ out := runCLI(t, srv, "instances", "set-channel", "-app", "mysite",
+ "-host", "mysite.bab.oncarlos.com", "-channel", "edge")
+ if !strings.Contains(out, "60s") && !strings.Contains(out, "re-checks") {
+ t.Fatalf("a static move must warn about the recheck window:\n%s", out)
+ }
+}
+```
+
+- [ ] **Step 2: Run them and watch them fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run TestInstancesSetChannel -count=1`
+Expected: FAIL — unknown sub-verb `set-channel`.
+
+- [ ] **Step 3: Implement**
+
+Client method posting to `apiPath(a, app, "/instances/"+url.PathEscape(host)+"/channel")`, treating 202 as `*apiProposedError` exactly as promote does; the sub-verb registered beside `set-upstreams`, with `-app`, `-host`, `-channel`, the account flags and `-console`; output:
+
+```go
+ fmt.Fprintf(stdout, "%s %s from %s to %s\n", colorFor(stdout).ok("moved"), rec.Host, from, rec.Channel)
+ if rec.Kind == "static" {
+ mutedf(stdout, "a static route re-checks its channel about every %ds, so the URL can take that long to flip\n",
+ int(staticRecheckWindow.Seconds()))
+ }
+```
+
+Extend the `vRecordOwned` note in `cli.go` with the command that CAN do it:
+
+```go
+ "a change here is reverted within seconds of the next serial bump.\n"+
+ "Change it where it lives: carlos instances set-channel -app %s -host %s -channel <c>",
+```
+
+- [ ] **Step 4: Run the tests**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1`
+Expected: PASS across every package.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apimode.go instances_cli.go instances_cli_test.go cli.go
+git commit -m "cli: carlos instances set-channel, and the box refusal now names it"
+```
+
+---
+
+### Task 5: Gate and live verification
+
+- [ ] **Step 1: Full gate**
+
+```bash
+export GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off
+go build ./... && go vet ./... && go test ./... -count=1
+gofmt -l internal/console/channelmove.go internal/console/pendinghandlers.go apimode.go instances_cli.go cli.go
+GOOS=darwin go build ./...
+```
+
+- [ ] **Step 2: Live verification**
+
+The console must be rolled for the door to exist (see the console-deploy memory: `roll-console.sh`, not `carlos deploy`), so this step may end as "built and gated, deploy owed" — say which, do not imply otherwise.
+
+With a rolled console: move a throwaway app's route between two channels and confirm the box converged.
+
+```bash
+/tmp/claude-1001/carlos instances set-channel -app <throwaway> -host <host> -channel edge
+/tmp/claude-1001/carlos routes -app <throwaway> # channel column reads edge
+curl -sI https://<host> | grep -i x-carlos-version # serves the new channel's head
+```
+
+- [ ] **Step 3: Record what was verified and what was not in the PR body.**
diff --git a/docs/superpowers/plans/2026-08-23-static-platform-host.md b/docs/superpowers/plans/2026-08-23-static-platform-host.md
new file mode 100644
index 000000000..84c5c11f5
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-23-static-platform-host.md
@@ -0,0 +1,558 @@
+# Static platform host Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** `carlos deploy` gives a static site a working URL without the member owning a domain, by minting `<app>.<sqid>.<apps-domain>` through the instances door.
+
+**Architecture:** No new doors and no security boundary moves. `staticDeployHosts`'s refusal becomes typed; `runDeploy` answers it by calling the existing `instances-manifest` PUT (idempotent upsert, returns the account's pinned domain) and the existing `POST /instances` with `kind:"static"` and an explicit channel, then continues into the ship it was always going to run.
+
+**Tech Stack:** Go 1.x, stdlib `flag`/`net/http`, existing `apiSession` client in `apimode.go`.
+
+**Spec:** `docs/superpowers/specs/2026-08-23-static-platform-host-design.md`
+
+## Global Constraints
+
+- Full gate before any commit lands: `go build ./...`, `go vet ./...`, `go test ./... -count=1`, and `gofmt -l` clean for touched files.
+- Sandbox env for go commands: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`.
+- Wire-shape changes are additive: anything written before this change must still parse.
+- A test that shells out to the carlos binary builds its env through `carlosTestEnv` and sets `cmd.Dir` to a scratch dir.
+- Never classify an API refusal by matching error text. Status-carrying typed errors only (`domainListError` is the precedent).
+- The mint always sends an explicit channel. A record that reaches the door with no channel is defaulted to `stable` and becomes unfixable.
+
+---
+
+### Task 1: Type the no-static-host refusal and extract channel resolution
+
+**Files:**
+- Modify: `deploy.go` (`staticDeployHosts`, `deployTargets`)
+- Test: `deploy_test.go`
+
+**Interfaces:**
+- Produces: `var errNoStaticHost = errors.New(...)`; `staticChannel(channelFlag, hostFlag string, recs []apiInstanceRecord, channels func() (apiChannelsResult, error)) (string, error)`.
+- `deployTargets` keeps its signature but may now return a non-nil `channel` **together with** `errNoStaticHost` — the one case where both are meaningful.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestDeployTargetsStaticNoHostIsTyped(t *testing.T) {
+ _, _, static, err := deployTargets(nil, "static", "", "", func() (apiChannelsResult, error) {
+ return apiChannelsResult{PipelineEntry: "edge"}, nil
+ })
+ if !static {
+ t.Fatal("static = false, want true")
+ }
+ if !errors.Is(err, errNoStaticHost) {
+ t.Fatalf("err = %v, want errNoStaticHost", err)
+ }
+}
+
+func TestDeployTargetsStaticNoHostCarriesResolvedChannel(t *testing.T) {
+ channel, hosts, _, err := deployTargets(nil, "static", "", "", func() (apiChannelsResult, error) {
+ return apiChannelsResult{PipelineEntry: "edge"}, nil
+ })
+ if !errors.Is(err, errNoStaticHost) {
+ t.Fatalf("err = %v, want errNoStaticHost", err)
+ }
+ if channel != "edge" {
+ t.Fatalf("channel = %q, want %q — the mint needs it", channel, "edge")
+ }
+ if len(hosts) != 0 {
+ t.Fatalf("hosts = %v, want none", hosts)
+ }
+}
+
+// Both unresolved: the host refusal is the one that names something the
+// member can supply, so it wins.
+func TestDeployTargetsStaticNoHostNoChannelPrefersHostRefusal(t *testing.T) {
+ _, _, _, err := deployTargets(nil, "static", "", "", func() (apiChannelsResult, error) {
+ return apiChannelsResult{}, nil
+ })
+ if !errors.Is(err, errNoStaticHost) {
+ t.Fatalf("err = %v, want errNoStaticHost", err)
+ }
+}
+
+// Unchanged behaviour: a named host with a record resolves from the record
+// and never lists channels.
+func TestDeployTargetsStaticHostRecordChannelSkipsListing(t *testing.T) {
+ recs := []apiInstanceRecord{{Host: "site.example.com", Kind: "static", Channel: "beta"}}
+ channel, hosts, static, err := deployTargets(recs, "static", "", "site.example.com", func() (apiChannelsResult, error) {
+ t.Fatal("channels() called; the record already names the channel")
+ return apiChannelsResult{}, nil
+ })
+ if err != nil || !static || channel != "beta" || len(hosts) != 1 || hosts[0] != "site.example.com" {
+ t.Fatalf("got %q %v %v %v", channel, hosts, static, err)
+ }
+}
+```
+
+- [ ] **Step 2: Run them and watch them fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run 'TestDeployTargetsStatic' -count=1`
+Expected: FAIL — `undefined: errNoStaticHost`.
+
+- [ ] **Step 3: Implement**
+
+In `deploy.go`, add the typed error beside `errAmbiguousChannel` and give it the same reasoning comment:
+
+```go
+// errNoStaticHost marks the refusal "this static app has no host to deploy
+// to". Typed for the reason errAmbiguousChannel is: runDeploy answers this
+// one by MINTING the app's platform host, and a refusal matched by text
+// would eventually mint in answer to a different sentence that merely
+// mentioned a hostname.
+var errNoStaticHost = errors.New("no static host")
+```
+
+Wrap it in `staticDeployHosts`'s existing message with `fmt.Errorf("%w: this app has no instance records — …", errNoStaticHost)`, keeping the current wording verbatim after the prefix.
+
+Extract the static branch's channel resolution:
+
+```go
+// staticChannel resolves the channel a static deploy promotes onto, in the
+// order the flag, the record and the app's channels earn: an explicit
+// -channel wins; a static record naming the deploy's own -host already
+// knows its channel (and saves the round trip); otherwise the app's
+// channels decide, with a one-channel pipeline's entry standing in when
+// nothing has been promoted yet.
+func staticChannel(channelFlag, hostFlag string, recs []apiInstanceRecord,
+ channels func() (apiChannelsResult, error)) (string, error) {
+ if channelFlag != "" {
+ return channelFlag, nil
+ }
+ if ch := staticRecordChannel(recs, hostFlag); ch != "" {
+ return ch, nil
+ }
+ res, err := channels()
+ if err != nil {
+ return "", fmt.Errorf("list channels: %w", err)
+ }
+ if len(res.Channels) == 0 && res.PipelineEntry != "" {
+ return res.PipelineEntry, nil
+ }
+ return staticDeployChannel(res.Channels)
+}
+```
+
+Rewrite the static branch of `deployTargets` to resolve hosts and channel independently, documenting the dual return:
+
+```go
+ if kind == "static" && staticOnlyRecords(recs) {
+ // hosts and channel are resolved independently, because the mint
+ // path needs the CHANNEL precisely when the host is missing.
+ // errNoStaticHost therefore travels WITH a usable channel — the one
+ // place this function returns both a value and an error.
+ hosts, herr := staticDeployHosts(hostFlag, recs)
+ channel, cerr := staticChannel(channelFlag, hostFlag, recs, channels)
+ if cerr != nil {
+ // Both unresolved: the host refusal names something the member
+ // can supply, so it is the more useful of the two.
+ if herr != nil {
+ return "", nil, true, herr
+ }
+ return "", nil, true, cerr
+ }
+ return channel, hosts, true, herr
+ }
+```
+
+- [ ] **Step 4: Run the tests**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -count=1`
+Expected: PASS, including every pre-existing deploy test.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add deploy.go deploy_test.go
+git commit -m "deploy: type the no-static-host refusal, resolve its channel alongside"
+```
+
+---
+
+### Task 2: `-kind` on `carlos instances create`
+
+**Files:**
+- Modify: `apimode.go:2026` (`instanceCreate`), `instances_cli.go` (flag + usage)
+- Test: `instances_cli_test.go`, `internal/console/apiinstances_test.go`
+
+**Interfaces:**
+- Consumes: nothing from Task 1.
+- Produces: `instanceCreate(ctx, a, app, host, region, channel, kind string, addrs []string, probe *instanceProbeReq)` — `kind` inserted after `channel`, empty meaning the door's own instance default.
+
+- [ ] **Step 1: Write the failing tests**
+
+Console door, in `internal/console/apiinstances_test.go` — the app's own apex is the shape the mint depends on:
+
+```go
+func TestAPIInstanceCreateStaticAtOwnApex(t *testing.T) {
+ // harness: account sqid "bab", app "mysite", manifest domain
+ // "bab.oncarlos.com" (see the existing create tests for the fixture).
+ rec := createInstance(t, srv, "bab", "mysite", map[string]any{
+ "host": "mysite.bab.oncarlos.com", "kind": "static", "channel": "edge",
+ })
+ if rec.Kind != "static" {
+ t.Fatalf("Kind = %q, want static", rec.Kind)
+ }
+ if rec.Channel != "edge" {
+ t.Fatalf("Channel = %q, want edge — an omitted channel defaults to stable", rec.Channel)
+ }
+}
+
+func TestAPIInstanceCreateStaticAtSiblingApexIs409(t *testing.T) {
+ // "other" is a second claimed app in the same account.
+ status, body := createInstanceRaw(t, srv, "bab", "mysite", map[string]any{
+ "host": "other.bab.oncarlos.com", "kind": "static", "channel": "edge",
+ })
+ if status != http.StatusConflict {
+ t.Fatalf("status = %d, want 409: %s", status, body)
+ }
+}
+
+func TestAPIInstanceCreateStaticOutsideDomainIs400(t *testing.T) {
+ status, _ := createInstanceRaw(t, srv, "bab", "mysite", map[string]any{
+ "host": "mysite.example.com", "kind": "static", "channel": "edge",
+ })
+ if status != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", status)
+ }
+}
+```
+
+CLI, in `instances_cli_test.go`: `-kind static` reaches the wire.
+
+```go
+func TestInstancesCreateSendsKind(t *testing.T) {
+ var got map[string]any
+ srv := fakeConsole(t, func(w http.ResponseWriter, r *http.Request) {
+ json.NewDecoder(r.Body).Decode(&got)
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]any{"host": "mysite.bab.oncarlos.com", "kind": "static"})
+ })
+ runCLI(t, srv, "instances", "create", "-app", "mysite", "-host", "mysite.bab.oncarlos.com", "-kind", "static")
+ if got["kind"] != "static" {
+ t.Fatalf("kind = %v, want static", got["kind"])
+ }
+}
+```
+
+- [ ] **Step 2: Run them and watch them fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . ./internal/console -run 'InstanceCreateStatic|InstancesCreateSendsKind' -count=1`
+Expected: FAIL — `flag provided but not defined: -kind` and a nil `Kind` on the console side if the fixture omits it.
+
+- [ ] **Step 3: Implement**
+
+`apimode.go`: add `Kind string \`json:"kind,omitempty"\`` to `instanceCreate`'s anonymous request struct, take `kind` as a parameter, and update the one existing call site in `instances_cli.go`.
+
+`instances_cli.go`: register the flag and document it in `instancesRecordsUsage`:
+
+```go
+ kind := fs.String("kind", "", "route class for the record: instance (default) | service | static")
+```
+
+- [ ] **Step 4: Run the tests**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . ./internal/console -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apimode.go instances_cli.go instances_cli_test.go internal/console/apiinstances_test.go
+git commit -m "instances: -kind reaches the door the record shape already had"
+```
+
+---
+
+### Task 3: `mintPlatformHost`
+
+**Files:**
+- Modify: `apimode.go` (typed error on `instancesEnable`), `deploy.go` (new function)
+- Test: `deploy_test.go`
+
+**Interfaces:**
+- Consumes: `instanceCreate(..., kind, ...)` from Task 2.
+- Produces: `mintPlatformHost(ctx context.Context, sess *apiSession, acct apiAcct, app, channel string) (string, error)`; `type enableError struct { status int; msg string }` with `Error() string` and `func enableUnavailable(err error) bool`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestMintPlatformHostSendsKindAndChannel(t *testing.T) {
+ var create map[string]any
+ srv := fakeConsole(t, routes{
+ "PUT /instances-manifest": jsonOK(map[string]any{"app": "mysite", "domain": "bab.oncarlos.com"}),
+ "POST /instances": func(w http.ResponseWriter, r *http.Request) {
+ json.NewDecoder(r.Body).Decode(&create)
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]any{
+ "host": "mysite.bab.oncarlos.com", "kind": "static", "channel": "edge"})
+ },
+ })
+ host, err := mintPlatformHost(context.Background(), sessFor(srv), acct, "mysite", "edge")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if host != "mysite.bab.oncarlos.com" {
+ t.Fatalf("host = %q", host)
+ }
+ if create["kind"] != "static" || create["channel"] != "edge" {
+ t.Fatalf("create body = %v, want kind static and an EXPLICIT channel", create)
+ }
+}
+
+// A deployment with no platform namespace answers 503; the mint must report
+// it as unavailable so deploy can fall back to its own -host refusal rather
+// than hand a member console-configuration language.
+func TestMintPlatformHostAppsDomainUnsetIsUnavailable(t *testing.T) {
+ srv := fakeConsole(t, routes{
+ "PUT /instances-manifest": jsonStatus(http.StatusServiceUnavailable,
+ map[string]any{"error": "this console has no platform apps domain configured"}),
+ })
+ _, err := mintPlatformHost(context.Background(), sessFor(srv), acct, "mysite", "edge")
+ if !enableUnavailable(err) {
+ t.Fatalf("err = %v, want an unavailable-classified error", err)
+ }
+}
+
+// An operator-pinned instance domain answers 409. Same fallback: this
+// deployment will not mint a platform host for this app.
+func TestMintPlatformHostOperatorPinnedDomainIsUnavailable(t *testing.T) {
+ srv := fakeConsole(t, routes{
+ "PUT /instances-manifest": jsonStatus(http.StatusConflict,
+ map[string]any{"error": "instances for mysite are managed under go.tito.io"}),
+ })
+ _, err := mintPlatformHost(context.Background(), sessFor(srv), acct, "mysite", "edge")
+ if !enableUnavailable(err) {
+ t.Fatalf("err = %v, want an unavailable-classified error", err)
+ }
+}
+```
+
+- [ ] **Step 2: Run them and watch them fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run TestMintPlatformHost -count=1`
+Expected: FAIL — `undefined: mintPlatformHost`.
+
+- [ ] **Step 3: Implement**
+
+`apimode.go` — give `instancesEnable` a status-carrying error, the `domainListError` pattern:
+
+```go
+// enableError carries the instances-manifest door's STATUS alongside its
+// message, because two of its refusals (503 no platform apps domain, 409 an
+// operator-pinned instance domain) mean "this deployment will not mint a
+// platform host for this app" and one caller — the deploy mint — must tell
+// them from a real failure without reading English.
+type enableError struct {
+ status int
+ msg string
+}
+
+func (e *enableError) Error() string { return e.msg }
+
+// enableUnavailable reports whether the deployment simply has no platform
+// host to give: no apps domain configured (503), or an operator-pinned
+// instance domain this door refuses to speak for (409).
+func enableUnavailable(err error) bool {
+ var ee *enableError
+ return errors.As(err, &ee) && (ee.status == http.StatusServiceUnavailable || ee.status == http.StatusConflict)
+}
+```
+
+Return `&enableError{status, apiErrorFromBody(status, body).Error()}` from `instancesEnable`'s non-200 branch.
+
+`deploy.go`:
+
+```go
+// mintPlatformHost gives an app its platform hostname —
+// <app>.<sqid>.<apps-domain> — and the static route record that serves it.
+//
+// Two existing doors, in order. The manifest PUT is an idempotent upsert
+// that CREATES when absent and otherwise preserves every field (nil channel
+// and nil seed are "preserve"), and it answers with the domain the console
+// pins to the account, so the CLI never has to know the deployment's apps
+// domain to build the host. Then the record, carrying kind and — the rail
+// that must not bend — an EXPLICIT channel: an omitted one defaults the
+// record to stable (internal/console/apiinstances.go), and no door can
+// change a record's channel afterwards.
+func mintPlatformHost(ctx context.Context, sess *apiSession, acct apiAcct, app, channel string) (string, error) {
+ m, err := sess.instancesEnable(ctx, acct, app, nil, nil)
+ if err != nil {
+ return "", err
+ }
+ rec, err := sess.instanceCreate(ctx, acct, app, app+"."+m.Domain, "", channel, "static", nil, nil)
+ if err != nil {
+ return "", err
+ }
+ fmt.Fprintf(stdout, "%s %s (static, channel %s)\n",
+ colorFor(stdout).ok("minted"), rec.Host, rec.Channel)
+ return rec.Host, nil
+}
+```
+
+- [ ] **Step 4: Run the tests**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apimode.go deploy.go deploy_test.go
+git commit -m "deploy: mint an app's platform host through the instances door"
+```
+
+---
+
+### Task 4: Wire the mint into `runDeploy`
+
+**Files:**
+- Modify: `deploy.go` (`runDeploy`, `-host` help text)
+- Test: `deploy_test.go`, `deploy_e2e_test.go`
+
+**Interfaces:**
+- Consumes: `errNoStaticHost` (Task 1), `mintPlatformHost` / `enableUnavailable` (Task 3).
+
+- [ ] **Step 1: Write the failing test**
+
+End-to-end through the fake console: a fresh static app deploys with no `-host` and the calls land in order.
+
+```go
+func TestDeployStaticFreshAppMintsThenShips(t *testing.T) {
+ var calls []string
+ srv := fakeConsole(t, recordCalls(&calls, routes{
+ "GET /instances": jsonOK(map[string]any{"instances": []any{}}),
+ "GET /channels": jsonOK(map[string]any{"channels": []any{}, "pipeline_entry": "edge"}),
+ "PUT /instances-manifest": jsonOK(map[string]any{"app": "mysite", "domain": "bab.oncarlos.com"}),
+ "POST /instances": jsonStatus(http.StatusCreated, map[string]any{
+ "host": "mysite.bab.oncarlos.com", "kind": "static", "channel": "edge"}),
+ "POST /ship": jsonOK(map[string]any{"version": "a1b2c3d"}),
+ "POST /promote": jsonOK(map[string]any{}),
+ }))
+ out := runCLI(t, srv, "deploy", "-app", "mysite", "-kind", "static", siteDir(t))
+ if !strings.Contains(out, "minted mysite.bab.oncarlos.com") {
+ t.Fatalf("output did not name the minted host:\n%s", out)
+ }
+ if got := callOrder(calls); !reflect.DeepEqual(got, []string{
+ "GET /instances", "GET /channels", "PUT /instances-manifest", "POST /instances", "POST /ship", "POST /promote",
+ }) {
+ t.Fatalf("call order = %v — the mint must precede the ship", got)
+ }
+}
+
+// No platform namespace: the member gets the actionable -host refusal, with
+// the door's own reason kept as a note rather than swallowed.
+func TestDeployStaticFallsBackWhenNoAppsDomain(t *testing.T) {
+ srv := fakeConsole(t, routes{
+ "GET /instances": jsonOK(map[string]any{"instances": []any{}}),
+ "GET /channels": jsonOK(map[string]any{"channels": []any{}, "pipeline_entry": "edge"}),
+ "PUT /instances-manifest": jsonStatus(http.StatusServiceUnavailable,
+ map[string]any{"error": "this console has no platform apps domain configured"}),
+ })
+ _, err := runCLIErr(t, srv, "deploy", "-app", "mysite", "-kind", "static", siteDir(t))
+ if err == nil {
+ t.Fatal("want a refusal")
+ }
+ if !strings.Contains(err.Error(), "-host") {
+ t.Fatalf("refusal must name -host: %v", err)
+ }
+}
+
+// A redeploy has a record and must not mint again.
+func TestDeployStaticRedeployDoesNotMint(t *testing.T) {
+ var calls []string
+ srv := fakeConsole(t, recordCalls(&calls, routes{
+ "GET /instances": jsonOK(map[string]any{"instances": []any{
+ map[string]any{"host": "mysite.bab.oncarlos.com", "kind": "static", "channel": "edge"}}}),
+ "POST /ship": jsonOK(map[string]any{"version": "a1b2c3d"}),
+ "POST /promote": jsonOK(map[string]any{}),
+ }))
+ runCLI(t, srv, "deploy", "-app", "mysite", "-kind", "static", siteDir(t))
+ for _, c := range calls {
+ if strings.HasPrefix(c, "PUT /instances-manifest") || strings.HasPrefix(c, "POST /instances") {
+ t.Fatalf("redeploy minted again: %v", calls)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run them and watch them fail**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run TestDeployStatic -count=1`
+Expected: FAIL — the deploy still exits with "this app has no instance records".
+
+- [ ] **Step 3: Implement**
+
+In `runDeploy`, directly after the `errAmbiguousChannel` retry block and before the `if err != nil` fatal:
+
+```go
+ // A static app with nothing serving it yet: mint its platform host
+ // rather than refusing. Typed, never text-matched (errNoStaticHost's own
+ // comment), and BEFORE the ship — matching attachUnclaimedHost, so a
+ // failed mint never leaves a published version with nothing serving it.
+ if errors.Is(err, errNoStaticHost) {
+ host, mErr := mintPlatformHost(ctx, sess, acct, *app, channel)
+ switch {
+ case mErr == nil:
+ hosts, err = []string{host}, nil
+ case enableUnavailable(mErr):
+ // This deployment has no platform host to give. The original
+ // refusal is the actionable one; the door's reason rides along
+ // so nothing is hidden.
+ mutedf(stdout, "%v\n", mErr)
+ default:
+ log.Fatalf("deploy: mint the platform host: %v", mErr)
+ }
+ }
+ if err != nil {
+ log.Fatalf("deploy: %v", err)
+ }
+```
+
+Update the `-host` flag help:
+
+```go
+ hostFlag := fs.String("host", "", "serve on this hostname instead of the app's platform host (also scopes channel resolution and the watch to it)")
+```
+
+- [ ] **Step 4: Run the tests**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1`
+Expected: PASS across every package.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add deploy.go deploy_test.go deploy_e2e_test.go
+git commit -m "deploy: a static app with no route mints its platform host"
+```
+
+---
+
+### Task 5: Gate and live verification
+
+**Files:** none changed unless the gate finds something.
+
+- [ ] **Step 1: Full gate**
+
+```bash
+export GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off
+go build ./... && go vet ./... && go test ./... -count=1
+gofmt -l deploy.go apimode.go instances_cli.go
+GOOS=darwin go build ./...
+```
+Expected: all clean, `gofmt -l` prints nothing, the darwin cross-compile succeeds (the CI gate is not Linux-only).
+
+- [ ] **Step 2: Live verification**
+
+Build this branch's binary and deploy a throwaway static app on flagship, then prove it with the header — never a cached answer, and with the binary just built:
+
+```bash
+go build -o /tmp/claude-1001/carlos . && CARLOS_BIN=/tmp/claude-1001/carlos
+/tmp/claude-1001/carlos deploy -app <throwaway> -kind static <site-dir>
+curl -sI https://<throwaway>.<sqid>.oncarlos.com | grep -i x-carlos-version
+```
+Expected: the minted URL serves 2xx with `X-Carlos-Version` naming the shipped build. Allow up to the 60s static recheck window.
+
+- [ ] **Step 3: Record the result in the PR body, including anything that did not work.**
diff --git a/docs/superpowers/specs/2026-08-23-record-channel-change-design.md b/docs/superpowers/specs/2026-08-23-record-channel-change-design.md
new file mode 100644
index 000000000..3876b70fc
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-23-record-channel-change-design.md
@@ -0,0 +1,179 @@
+# Changing which channel a route follows — design
+
+The second half of the `hello.bdn` incident (2026-08-22). The repair
+needed a raw S3 write to an unsigned bucket object and a hand-bumped
+serial, because no door exists.
+
+## 1. The defect
+
+A route's channel belongs to its instance record. That ruling is right and
+is not in question: the provisioning reconciler converges `Channel` from
+`apps/<sqid>/<app>/instances/<host>.json` on every pass, which is what
+makes a route's channel durable across registry rebuilds and second boxes.
+
+The consequence is that **nothing can change it**:
+
+- `carlos route -channel` on the box refuses a provisioned row and says
+ why (`cli.go:772`) — correctly, since the reconciler reverts it "within
+ seconds of the next serial bump".
+- `PATCH /a/<sqid>/apps/<app>/instances/{host}` carries `addrs` and
+ `probe`, and its doc states the rest of the record is fixed at create:
+ identity, policy, region.
+- The console's instances page is read-only on this axis.
+
+So a member who lands a route on the wrong channel — which the platform
+makes easy, since an omitted channel defaults a record to `stable`
+(`apiinstances.go:575`) — has a URL that serves nothing and no way back.
+Tony's `hello` was unreachable for the three days it took to notice, and
+the fix was performed with credentials no tenant has and no tenant should.
+
+This is the CLAUDE.md rule verbatim: a day-to-day task with no API/CLI
+command is a product gap.
+
+## 2. The door
+
+`POST /a/<sqid>/apps/<app>/instances/{host}/channel`, body
+`{"channel": "<name>"}`.
+
+Its own door rather than a new field on `PATCH /instances/{host}`. The
+PATCH door is deliberately ungated — upstreams and probes are operational
+fields any member may move — and channel is the one record column with
+release-authority consequences. Widening PATCH would put a gated field
+behind an ungated handler and make the authority story of that door
+conditional on which key the body happened to carry. One door, one
+authority story.
+
+The write is `provision.UpdateRecord` under the etag the record was read
+with, then `bumpSerial` and an audit line, in that order — the ordering
+every console-mediated write in this codebase already uses. Boxes converge
+the route's `Channel` column on the next pass, seconds later.
+
+## 3. The gate
+
+A channel move is not a promotion, but it can achieve one. This is the
+hole worth naming precisely, because guarding the wrong end leaves it
+open:
+
+> An app has `edge` (no rules) and `production` (`promote_approvals: 2`).
+> Promoting `v5` onto `production` needs two approvals. Instead, move the
+> production route onto `edge`. Nobody approved anything, and the
+> production URL now serves `v5`.
+
+Guarding only the **destination** misses this entirely — the destination
+is the unguarded channel. Guarding only the **source** lets a route be
+walked onto a channel whose policy the mover never met. So:
+
+**A channel move must clear the rules of BOTH ends, each at its
+strictest.**
+
+- passkey required if either the from-channel or the to-channel requires it;
+- approvals = the maximum, across both channels, of that channel's
+ `promote_approvals` and `change_approvals`.
+
+`promote_approvals` is included deliberately even though this is not a
+promotion: it is the number that expresses "this channel's URL only serves
+what N people agreed to", and a move is exactly a way to change what that
+URL serves.
+
+For a **legacy app** (no `pipeline.json`) the rule collapses to the
+console's existing one: either end on the `stable` rung needs a passkey —
+the byte-identical 403 with `console_url` that the promote door already
+returns — and `edge`/`beta` are free.
+
+`canary/*` stays outside the pipeline exactly as the pipelines spec
+reserves it: always allowed, no rules, no bake.
+
+Mechanically this reuses what exists: `channelRules(pipe, channel)` for
+each end, `proposeOverAPI` with the computed quorum when approvals are
+required, and the passkey 403 naming the console when a passkey rule
+stands alone with nothing to propose. The CLI proposes and never confirms,
+per §4 of the pipelines spec.
+
+### 3.1 The target must be a channel the app has
+
+Refused otherwise, naming the app's channels — the same shape as the
+promote door's refusal for a channel the pipeline does not list. A
+pipelined app is a reason to govern its own channels, never a way to reach
+one it left out.
+
+### 3.2 Bake windows need nothing here
+
+A route moved onto a channel whose head is inside its bake window simply
+does not adopt until the window expires: `HoldFor` is enforced box-side,
+against the box's own clock, and the ratchet is unchanged by anything this
+door writes. No console-side bake check is added, and none should be —
+duplicating that decision in a second place is how the two drift.
+
+## 4. The proposal payload
+
+`{host, from, to, etag}`. Execution re-reads the record and refuses with
+the shared `staleProposal` error if the record moved underneath the
+proposal — the pipelines-v2 rule that a proposal pins its exact payload,
+and that a world which moved invalidates it. The etag is the pin; `from`
+is what the audit line and the console summary read back to a confirmer,
+so what they approve is stated in the terms they were shown.
+
+`kindRouteChannel = "route-channel"` joins the three existing kinds in
+`executePending`'s switch and in `pendingSummary`.
+
+## 5. CLI
+
+```
+carlos instances set-channel -app <a> -host <h> -channel <c>
+```
+
+Mirrors `set-upstreams`, which is the existing verb for "change one field
+of one record". Output names both ends:
+
+```
+moved mysite.bab.oncarlos.com from stable to edge
+```
+
+For a `kind:static` record it adds the note deploy already prints — a
+static route re-checks its channel about every 60s, so the URL can take
+that long to flip. A gated move prints the console URL and exits cleanly,
+the shape `carlos deploy` uses for a proposed promote.
+
+`carlos route`'s existing `vRecordOwned` refusal gains one line naming
+this command. That refusal already explains why the box cannot do it; the
+missing half was where the member should go instead.
+
+## 6. Not in scope
+
+- **`Environment`.** The same record-owned shape and the same missing
+ door, but a different defect with its own issue (#196: binding a route
+ to an environment does not re-materialize its delivered config). Adding
+ the axis here without that fix would ship a door whose effect is
+ silently partial.
+- **Platform+Canary rows.** The domains reconciler derives and re-asserts
+ those channels; `carlos route` already refuses them and this door
+ refuses them for the same reason, with the same message.
+
+## 7. Testing
+
+Console:
+
+- A move on a legacy app between `edge` and `beta` lands; either end on
+ `stable` is the passkey 403 with `console_url`.
+- A pipelined app with `promote_approvals: 2` on `production`: moving the
+ production route to `edge` proposes with quorum 2 — the bypass in §3,
+ asserted as a test, from the unguarded destination.
+- The union takes the maximum: `change_approvals: 3` on the source with
+ `promote_approvals: 1` on the target proposes with quorum 3.
+- A target the pipeline does not name is refused, and the message lists
+ the app's channels.
+- A stale etag at execution returns `staleProposal`.
+- The write bumps the serial and writes exactly one audit line.
+
+CLI: `set-channel` sends host and channel; a 202 prints the console URL
+and exits 0; a static record prints the recheck note. Tests that shell out
+build their environment through `carlosTestEnv` with a scratch `cmd.Dir`.
+
+Live: move a throwaway app's route between channels on flagship and
+confirm with `carlos routes` and the `X-Carlos-Version` header that the
+box converged.
+
+## 8. What this closes
+
+`hello.bdn` is repairable by its owner from a terminal, with no bucket
+credentials — which was the whole complaint.
diff --git a/docs/superpowers/specs/2026-08-23-static-platform-host-design.md b/docs/superpowers/specs/2026-08-23-static-platform-host-design.md
new file mode 100644
index 000000000..be62014d7
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-23-static-platform-host-design.md
@@ -0,0 +1,201 @@
+# A static site gets a URL from the CLI alone — design
+
+Issue #186 (first-route provisioning), narrowed to its static half.
+
+`carlos deploy` in a fresh directory logs you in, resolves your account,
+creates the app, takes the artifact and saves the project defaults — and
+then stops. A static app has no instance records and no `-host`, so
+`staticDeployHosts` refuses:
+
+> this app has no instance records — a static site is served by the edge
+> straight from its channel pointer … Name the hostname the site answers on
+
+The member's answer to "what hostname?" is supposed to be the platform's
+own: `<app>.<sqid>.<apps-domain>`. Every mechanism that serves that name
+already exists. Nothing mints it.
+
+## 1. What is already true
+
+Measured on main at `a347034`, not assumed:
+
+- **DNS costs nothing.** `*.oncarlos.com` synthesises at any depth — no
+ intervening node exists in the zone — so `a.b.c.oncarlos.com` already
+ answers `99.81.104.219`. A brand-new `<app>.<sqid>.oncarlos.com` resolves
+ to the edge the moment it is typed. There is no per-account record to
+ write and no DNS seam to build.
+- **TLS costs nothing.** `internal/edge/wildcard.go` derives per-account
+ wildcard parents `*.<sqid>.<apps-domain>` from the live route table and
+ obtains them over DNS-01. A minted route makes its own parent appear.
+- **The door already accepts the shape.** `handleAPIInstanceCreate`
+ (`internal/console/apiinstances.go:495`) reads a `kind` field, validates
+ the host as one DNS label plus the app's own manifest domain, and refuses
+ a sibling app's apex with a 409. Its own comment anticipates this exact
+ request: *"a `"kind":"static"` body cannot reach for a hostname outside
+ the app's own domain"*. `provision.ValidateShape` has carried the static
+ rails since #137.
+- **The channel is no longer a trap.** Release pipelines v2 gives a fresh
+ app a one-channel `edge` pipeline at create
+ (`release.DefaultPipelineChannels`), and `deployTargets`
+ (`deploy.go:534`) already resolves that entry channel when an app has no
+ channels yet. `edge` holds for `0s`, so the first-stable-promote bake
+ window that made `hello.bdn` unreachable for three days cannot recur on
+ this path.
+
+So the gap is one missing call, not a missing subsystem.
+
+## 2. What the member sees
+
+```
+$ carlos deploy # zero-arg, fresh directory, ./dist
+using console.carloku.com as paul@keymail.dev
+created app mysite
+minted mysite.bab.oncarlos.com (static, channel edge)
+shipped mysite a1b2c3d
+promoted mysite a1b2c3d -> edge
+deploying https://mysite.bab.oncarlos.com
+a static route re-checks its channel about every 60s, so the URL can take that long to flip
+mysite.bab.oncarlos.com serving a1b2c3d
+```
+
+`carlos deploy -kind static ./dist` against a named app with no route
+behaves identically. The mint is silent — one line in the same voice as
+today's `attached <host>` from `attachUnclaimedHost` — because a deploy
+that already auto-attaches a hostname you named should not interrogate you
+about the one it can derive.
+
+## 3. Mechanism
+
+Two existing doors, called in order, before the ship:
+
+1. **`POST /a/<sqid>/apps/<app>/instances/enable`** — only when the app has
+ no manifest. Idempotent upsert; in signed-in mode the console pins
+ `Domain = <sqid>.<apps-domain>` and refuses the operator fields
+ (`-domain`, `-pool`, `-regions`). Writing this manifest is the app's
+ opt-in to instance provisioning (§3.1 of the instance-provisioning
+ spec), and "this app may hold names in the platform namespace" is
+ exactly what we mean by it.
+2. **`POST /a/<sqid>/apps/<app>/instances`** with
+
+ ```json
+ {"host": "<app>.<sqid>.<apps-domain>", "kind": "static", "channel": "<resolved>"}
+ ```
+
+Everything downstream is untouched: the provisioning reconciler mints a
+`kind:static` route from the record, the edge serves that channel's tar,
+and the wildcard parent follows.
+
+### 3.1 The channel is explicit, always
+
+This is the one rail that must not bend. With no channel in the body and
+no channel on the manifest, the door defaults the record to **`stable`**
+(`apiinstances.go:575`) — while a fresh app's pipeline entry is `edge`.
+The record would then follow a channel nothing ever promotes to, and
+**there is no door to change a record's channel afterwards**: `carlos
+route -channel` on the box is reverted by the provisioning reconciler
+within seconds of the next serial bump, and `PATCH /instances/{host}`
+covers only `addrs` and `probe`. Repairing `hello.bdn` took a raw bucket
+write.
+
+So the mint sends the channel `deployTargets` resolved, and a test asserts
+the body carries it. (The missing door is real and is fixed in its own
+spec, `2026-08-23-record-channel-change-design.md`; this rail stands even
+once that lands, because a record born on the wrong channel is still a
+route that serves nothing until someone notices.)
+
+### 3.2 Where it hooks into deploy
+
+`staticDeployHosts`'s refusal becomes a **typed** `errNoStaticHost`,
+following the `errAmbiguousChannel` precedent. The reason is recorded in
+that precedent's own comment: matching on error text once turned the
+no-instances refusal — which merely *mentions* `-channel` — into a ship and
+promote after an unrelated question. Deploy acts on the type or not at all.
+
+`runDeploy` catches it, resolves the channel, mints, and continues with
+`hosts = [host]` and `channel = record.Channel`. The mint runs **before**
+the ship, matching `attachUnclaimedHost`'s ordering: a route whose channel
+pointer does not exist yet is inert, not broken, and the alternative — ship
+first, mint after — leaves a published version with nothing serving it if
+the mint fails.
+
+Idempotency is free. After the first deploy the static record is in the
+instance list, so `staticDeployHosts` resolves the host from it and the
+mint is never reached again.
+
+## 4. Refusals
+
+Surfaced verbatim, never papered over: the 409 sibling apex ("`x` is app
+`y`'s platform apex — create it as an instance of `y` instead"), the
+instance-budget refusal, and every shape refusal `ValidateShape` owns. An
+app that already has a non-static record is not on this path at all
+(`staticOnlyRecords`), and an explicit `-host` keeps today's
+`attachUnclaimedHost` behaviour unchanged.
+
+One case needs a deliberate fallback: **a deployment with no
+`PLATFORM_APPS_DOMAIN`** — a self-hosted box, tito's shape. The enable
+door answers 503 naming console configuration. Deploy must not surface
+that to somebody who only wanted a URL: it falls back to today's "name the
+hostname with `-host`" refusal, which is the honest answer on a deployment
+that has no platform namespace to mint from.
+
+## 5. CLI surface
+
+- `carlos instances create -kind static` exposes the kind the door already
+ accepts (`instanceCreate` in `apimode.go:2026` gains a `kind`
+ parameter; empty keeps today's instance default).
+- `carlos deploy -host` changes meaning in its help text only: from
+ "required for an app with no instances" to "serve on this hostname
+ instead of the app's platform host".
+
+No new verb. "Instances" remains odd vocabulary for a site with no
+process; the member never types it, because deploy does. Recorded as a
+naming debt, not fixed here.
+
+## 6. What this deliberately does not touch
+
+- **`deniedDomains` stays absolute.** The apps domain and everything under
+ it remain unclaimable through `carlos domains attach`. That door is for
+ hostnames the caller's own DNS controls; the platform namespace belongs
+ to the instances door, exactly as its comment says.
+- **The domains reconcile pass is unchanged.** Its apex mint is skipped by
+ the existing `byHost` guard, and `routeFor`
+ (`internal/provision/reconcile.go:71`) sets no `Platform` column — so the
+ pass never gains removal authority over a record-minted route. Both
+ checked against the code, not reasoned about.
+- **Binary apps keep their dead-end.** #186's other half needs region,
+ pool and upstream-shape decisions a static route does not have. Left
+ open, and the issue says so.
+
+## 7. Testing
+
+Unit, in `deploy_test.go`:
+
+- `deployTargets` returns `errNoStaticHost` (typed) for a static kind with
+ no `-host` and no records; still returns it when records exist but are
+ all non-static.
+- The mint body carries both `kind: "static"` and the resolved channel.
+- No mint when a static record already exists, and none when `-host` was
+ given.
+- The apps-domain-less deployment falls back to the `-host` refusal.
+
+Console, in `apiinstances_test.go`:
+
+- `kind: "static"` at the app's own apex is created, and the stored record
+ round-trips its kind and channel.
+- The same body at a sibling app's apex is 409.
+- A host outside the manifest domain is 400.
+
+End-to-end, through the existing fake-console harness: the three calls in
+order, and the printed lines. Every test that shells out to the binary
+builds its environment through `carlosTestEnv` with a scratch `cmd.Dir` —
+the rule this repo paid for with 40 real accounts on the production
+console.
+
+Live: a throwaway static app deployed on flagship and verified with `curl
+-sI --resolve` for `X-Carlos-Version`, using the binary built from this
+branch.
+
+## 8. Follow-ups filed, not fixed
+
+- A record's channel is unchangeable through any door — the second half of
+ tonight's work, spec'd separately.
+- "Instance" is the wrong member-facing word for a static route.
diff --git a/instances_cli.go b/instances_cli.go
index 951f87137..f4859da26 100644
--- a/instances_cli.go
+++ b/instances_cli.go
@@ -23,6 +23,7 @@ package main
import (
"context"
+ "errors"
"flag"
"fmt"
"log"
@@ -203,11 +204,13 @@ func instancesEnable(ctx context.Context, s release.Store, acct, app string, m p
}
const instancesRecordsUsage = `usage: carlos instances create -app <a> -host <h> [-region <r>] [-channel <c>]
+ [-kind instance|service|static]
[-addr <a:p,...>] [-health-path <p>] [-health-status <n>]
carlos instances list -app <a> [-match <substr>] [-health <slug>]
carlos instances delete -app <a> -host <h>
carlos instances set-upstreams -app <a> -host <h> [-addrs <a:p,...>]
[-health-path <p>] [-health-status <n>]
+ carlos instances set-channel -app <a> -host <h> -channel <c>
Signed-in only (carlos auth login): instance records are console-authorized
writes — the console checks the host against the app's manifest and the
@@ -219,6 +222,11 @@ box's reconcile pass materializes the route. Common flags:
create-only flags:
+ -kind <k> route class: instance (default) | service | static.
+ A static record serves a file tree straight off
+ its channel pointer — no process, no upstream —
+ which is what "carlos deploy -kind static" mints
+ for an app's platform host
-addr <a:p,...> comma-separated TCP upstream set, host:port each
(e.g. 10.0.0.1:8090,10.0.0.2:8090); omitted means
the platform's own default upstream
@@ -235,6 +243,12 @@ list-only flags:
-health <slug> only this state: running, serving, setting-up, asleep,
no-readings, not-responding, failed
+set-channel moves ONE route onto a different release channel. The channel
+lives in the instance record, which the box converges the route from every
+pass — so this is the verb that changes it, not "carlos route -channel",
+which edits a row the next reconcile overwrites. A move that the app's
+pipeline gates is proposed and finished in the console, like a promote.
+
set-upstreams-only flags:
-addrs <a:p,...> comma-separated TCP upstream set, host:port each,
@@ -282,6 +296,7 @@ func runInstancesCreate(args []string) {
host := fs.String("host", "", "instance host — one DNS label + \".\" + the app's instance domain")
region := fs.String("region", "", "region code for the record (subject to the manifest's allow-list)")
channel := fs.String("channel", "", "release channel the instance follows (default: the manifest's, then stable)")
+ kind := fs.String("kind", "", "route class: instance (default) | service | static — a static record serves a file tree off the channel pointer and has no process")
addr := fs.String("addr", "", "comma-separated TCP upstream set, host:port each (e.g. 10.0.0.1:8090,10.0.0.2:8090); omitted means the platform's own default upstream")
healthPath := fs.String("health-path", "", "states the whole probe alongside -health-status: request path (default \"/\"); honoured for a single -addr too")
healthStatus := fs.Int("health-status", 0, "probe's expected response status (default 200); fields neither flag names take the platform's own defaults")
@@ -301,7 +316,8 @@ func runInstancesCreate(args []string) {
acct := resolvedAccount(sess, acctRaw, acctIDRaw)
app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage)
rec, err := sess.instanceCreate(context.Background(), acct, app2,
- strings.TrimSpace(*host), strings.TrimSpace(*region), strings.TrimSpace(*channel), addrs, probe)
+ strings.TrimSpace(*host), strings.TrimSpace(*region), strings.TrimSpace(*channel),
+ strings.TrimSpace(*kind), addrs, probe)
if err != nil {
log.Fatalf("instances create: %v", err)
}
@@ -583,3 +599,54 @@ func runSteering(args []string) {
mutedf(stdout, " the record and its registry row update within seconds of the next converge tick;")
mutedf(stdout, " the fleet only ACTS on this once a deployment arms its Route53 steering converger")
}
+
+// runInstancesSetChannel is `carlos instances set-channel` — move one route
+// onto a different release channel, the door that did not exist when
+// hello.bdn landed on stable and could not be moved off it without a raw
+// bucket write.
+//
+// The record is where a route's channel LIVES (the provisioning reconciler
+// converges the registry row from it every pass), which is why this is an
+// instances verb and not `carlos route -channel`: that one edits a row the
+// next reconcile overwrites, and says so.
+func runInstancesSetChannel(args []string) {
+ fs := flag.NewFlagSet("instances set-channel", flag.ExitOnError)
+ app := fs.String("app", "", "app the instance belongs to")
+ host := fs.String("host", "", "instance host whose channel to change")
+ channel := fs.String("channel", "", "release channel the route should follow")
+ consoleFlag := fs.String("console", "", "console to act through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ fs.Parse(args)
+ if *host == "" || *channel == "" {
+ log.Fatal(instancesRecordsUsage)
+ }
+ sess := requireAPISession("set-channel", *consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage)
+ rec, err := sess.instanceSetChannel(context.Background(), acct, app2,
+ strings.TrimSpace(*host), strings.TrimSpace(*channel))
+ if err != nil {
+ // A gated move is not a failure: the change is recorded and waiting
+ // for people, so this exits 0 naming where to finish — the shape
+ // `carlos deploy` uses for a proposed promote.
+ var prop *apiProposedError
+ if errors.As(err, &prop) {
+ fmt.Fprintf(stdout, "moving %s to %s is proposed and needs %d confirmation(s) at %s\n",
+ strings.TrimSpace(*host), strings.TrimSpace(*channel), prop.Needed, prop.ConsoleURL)
+ return
+ }
+ log.Fatalf("instances set-channel: %v", err)
+ }
+ fmt.Fprintf(stdout, "%s %s to %s\n", colorFor(stdout).ok("moved"), rec.Host, rec.Channel)
+ if rec.Kind == "static" {
+ // The one wait worth naming: a static route trusts a resolved
+ // channel pointer for a while before re-reading it, so the URL can
+ // lag the record.
+ mutedf(stdout, "a static route re-checks its channel about every %ds, so the URL can take that long to flip\n",
+ int(staticRecheckWindow.Seconds()))
+ } else {
+ mutedf(stdout, " the change lands on its box within seconds of the next converge tick")
+ }
+}
diff --git a/instances_e2e_test.go b/instances_e2e_test.go
index 12968ff74..35c15bacf 100644
--- a/instances_e2e_test.go
+++ b/instances_e2e_test.go
@@ -119,6 +119,38 @@ func TestE2EInstancesThroughConsole(t *testing.T) {
t.Fatalf("record = %+v, want beta/titogo created by %s", rec, addr)
}
+ // --- create -kind static: the flag reaches the record ------------------
+ // The shape `carlos deploy` mints for a static app's platform host. Here
+ // only to prove the FLAG is wired to the wire field the door has always
+ // read; the door's own acceptance of it is pinned console-side.
+ staticHost := "site." + domain
+ buf.Reset()
+ runInstancesCreate([]string{"-app", "titogo", "-account", "ops", "-host", staticHost, "-kind", "static"})
+ staticRec, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", staticHost)
+ if err != nil {
+ t.Fatalf("record after create -kind static: %v", err)
+ }
+ if staticRec.Kind != "static" {
+ t.Fatalf("record = %+v, want kind static", staticRec)
+ }
+
+ // --- set-channel: the record is where a route's channel lives ----------
+ buf.Reset()
+ runInstancesSetChannel([]string{"-app", "titogo", "-account", "ops", "-host", host, "-channel", "edge"})
+ if want := "moved " + host + " to edge"; !strings.Contains(buf.String(), want) {
+ t.Fatalf("set-channel stdout = %q, want %q", buf.String(), want)
+ }
+ moved, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", host)
+ if err != nil {
+ t.Fatalf("record after set-channel: %v", err)
+ }
+ if moved.Channel != "edge" {
+ t.Fatalf("record channel = %q, want edge", moved.Channel)
+ }
+ // And back, so the rest of this test still sees the channel it set up.
+ buf.Reset()
+ runInstancesSetChannel([]string{"-app", "titogo", "-account", "ops", "-host", host, "-channel", "beta"})
+
// --- list ----------------------------------------------------------------
buf.Reset()
runInstancesList([]string{"-app", "titogo", "-account", "ops"})
diff --git a/internal/console/apiinstances_test.go b/internal/console/apiinstances_test.go
index 8da4b3b74..259ee6431 100644
--- a/internal/console/apiinstances_test.go
+++ b/internal/console/apiinstances_test.go
@@ -1978,3 +1978,53 @@ func TestAPIInstanceStoreFailure(t *testing.T) {
t.Fatalf("delete refusal leaks the store's error text: %s", out)
}
}
+
+// TestAPIInstanceCreateStaticAtOwnApex pins the shape `carlos deploy -kind
+// static` mints for an app with no route: a kind:static record at the app's
+// OWN platform apex, carrying an explicit channel.
+//
+// The channel is the half worth a test of its own. An omitted channel is
+// defaulted to the manifest's, then to "stable" — and a fresh app's pipeline
+// entry is "edge", so a mint that let the default stand would write a route
+// that follows a channel nothing ever promotes to, with no door able to
+// change it afterwards (the hello.bdn incident, 2026-08-22).
+func TestAPIInstanceCreateStaticAtOwnApex(t *testing.T) {
+ fx, member := instanceFixture(t, "code-inst-static-apex")
+ fx.srv.cfg.AppsDomain = "oncarlos.test"
+ if status, _, out := rawReq(t, http.MethodPut, enableURL(fx, "bab", "titogo"), member, `{}`); status != http.StatusOK {
+ t.Fatalf("enable = %d %s", status, out)
+ }
+ host := "titogo." + fx.sqid + ".oncarlos.test"
+
+ status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member,
+ `{"host":"`+host+`","kind":"static","channel":"edge"}`)
+ if status != http.StatusCreated {
+ t.Fatalf("static apex create = %d %s, want 201", status, out)
+ }
+ var rec provision.Record
+ if err := json.Unmarshal([]byte(out), &rec); err != nil {
+ t.Fatalf("create body %s: %v", out, err)
+ }
+ if rec.Kind != "static" {
+ t.Fatalf("echoed Kind = %q, want static", rec.Kind)
+ }
+ if rec.Channel != "edge" {
+ t.Fatalf("echoed Channel = %q, want the requested edge — an omitted channel falls to stable", rec.Channel)
+ }
+
+ // The bucket copy is what the reconcile pass mints the route from, so it
+ // is the one that has to carry both.
+ stored, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "titogo", host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.Kind != "static" || stored.Channel != "edge" {
+ t.Fatalf("stored record = %+v, want kind static on channel edge", stored)
+ }
+ // A static record with no upstream and no unit is the shape ValidateShape
+ // requires; anything the door let through that carried one would wedge
+ // the host.
+ if len(stored.Addrs) != 0 || stored.Unit != "" {
+ t.Fatalf("stored record = %+v, want no upstream and no unit", stored)
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 292d58812..318e80a6e 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -567,6 +567,12 @@ func (s *Server) registerCLIRoutes() {
// door, the ANSWER a credential gets here moves from 401 to 403.
s.cliRoute("PUT /api/cli/apps/{account}/{app}/instances-manifest", svcrole.Set{}, s.handleAPIInstanceEnable)
s.cliRoute("GET /api/cli/apps/{account}/{app}/instances", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceList)
+ // Moving a route between channels (channelmove.go). The zero Set, for
+ // the manifest door's reason: this decides what a route SERVES, and a
+ // credential the app itself holds must not be able to walk its own route
+ // onto a channel whose rules it never met. Widening it later is easy;
+ // narrowing an authority already granted is not.
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/channel", svcrole.Set{}, s.handleAPIInstanceChannel)
// The logs read (`carlos logs`, apilogs.go) — same gate, same 404
// discipline, and an app token may read its own app's logs.
s.cliRoute("GET /api/cli/apps/{account}/{app}/logs", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPILogs)
diff --git a/internal/console/channelmove.go b/internal/console/channelmove.go
new file mode 100644
index 000000000..d5cbffd15
--- /dev/null
+++ b/internal/console/channelmove.go
@@ -0,0 +1,229 @@
+// Changing which channel one route follows — the gate, and the door.
+//
+// A route's channel belongs to its instance record: the provisioning
+// reconciler converges it from apps/<sqid>/<app>/instances/<host>.json on
+// every pass, which is what makes it durable across registry rebuilds and
+// second boxes. The consequence was that nothing could change it. `carlos
+// route -channel` on a box is reverted within seconds, PATCH /instances/
+// {host} carries only addrs and probe, and the console page is read-only —
+// so a route that landed on the wrong channel had a URL that served nothing
+// and no way back. Repairing one took a raw S3 write and a hand-bumped
+// serial (hello.bdn, 2026-08-22).
+//
+// This is its own door rather than a field on the PATCH. That door is
+// deliberately ungated — upstreams and probes are operational fields any
+// member may move — and channel is the one record column with release
+// authority behind it. Widening PATCH would put a gated field behind an
+// ungated handler and make that door's authority story depend on which key
+// the body happened to carry.
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// moveGate is what a channel move must clear: the rules of BOTH ends, each
+// at its strictest.
+//
+// A move is not a promotion, but it can achieve one, and that is the whole
+// reason this is not simply "the destination's rules". Given `edge` (no
+// rules) and `production` (promote_approvals 2), promoting v5 onto
+// production takes two approvals — or the production route is moved onto
+// edge instead, and production's URL serves v5 with nobody's approval.
+// Guarding only the destination misses that entirely, because the
+// destination is the unguarded channel; guarding only the source lets a
+// route be walked ONTO a channel whose policy the mover never met.
+//
+// promote_approvals counts here even though nothing is being promoted: it is
+// the number that expresses "this channel's URL only serves what N people
+// agreed to", and a move is a way to change what that URL serves.
+//
+// A channel the pipeline does not name falls back to its LEGACY rung, so an
+// app cannot buy free movement off stable by declaring a pipeline that omits
+// it. A canary/* end is reserved platform namespace, outside every pipeline:
+// ungated at that end, and it never launders the other end's rules.
+func moveGate(p *release.Pipeline, from, to string) (passkey bool, approvals int) {
+ for _, ch := range []string{from, to} {
+ if rung, ok := release.Rung(ch); ok && rung == release.RungCanary {
+ continue
+ }
+ if p != nil {
+ if c, named := p.ChannelNamed(ch); named {
+ if c.Rules.Passkey {
+ passkey = true
+ }
+ if c.Rules.PromoteApprovals > approvals {
+ approvals = c.Rules.PromoteApprovals
+ }
+ if c.Rules.ChangeApprovals > approvals {
+ approvals = c.Rules.ChangeApprovals
+ }
+ continue
+ }
+ }
+ // No pipeline, or a channel outside the one this app declared: the
+ // legacy ladder governs, which means the console's standing
+ // unconditional passkey on the stable rung — in both directions,
+ // since moving OFF stable changes what stable's URL serves just as
+ // surely as moving onto it.
+ //
+ // An EMPTY channel reaches here and is free, which is right rather
+ // than an oversight: the provisioning reconciler refuses to mint a
+ // route from a record with no channel at all (reconcile.go's
+ // "skipping record: empty channel"), so such a record serves no
+ // traffic to protect — and naming one is precisely the repair this
+ // door exists for.
+ if rung, ok := release.Rung(ch); ok && rung == release.RungStable {
+ passkey = true
+ }
+ }
+ return passkey, approvals
+}
+
+// channelMovePayload is what a gated move pins for its confirmers. The etag
+// is the pin proper — execution refuses if the record moved underneath the
+// proposal — and From is what the summary and the audit line read back, so
+// what a confirmer approves is stated in the terms they were shown.
+type channelMovePayload struct {
+ Host string `json:"host"`
+ From string `json:"from"`
+ To string `json:"to"`
+ ETag string `json:"etag"`
+}
+
+// handleAPIInstanceChannel is POST /instances/{host}/channel: move one route
+// onto a different release channel. Refusal order mirrors the family's —
+// gate, record, shape, no-op, pipeline membership, rules, write.
+func (s *Server) handleAPIInstanceChannel(w http.ResponseWriter, r *http.Request) {
+ principal, acct, app, ok := s.instanceGate(w, r)
+ if !ok {
+ return
+ }
+ host := r.PathValue("host")
+ var req struct {
+ Channel string `json:"channel"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ to := strings.TrimSpace(req.Channel)
+ if !release.ValidChannel(to) {
+ jsonError(w, http.StatusBadRequest, "invalid channel name")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, host)
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ // The family's byte-identical 404: a record's existence is not a
+ // thing to probe for here any more than it is on GET.
+ http.NotFound(w, r)
+ return
+ case err != nil:
+ log.Printf("console: api instance channel %s/%s/%s: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not read the instance")
+ return
+ }
+ from := rec.Channel
+ if from == to {
+ // Nothing changed: no write, no serial bump, no audit line. A serial
+ // that names no change sends every box in the fleet converging for
+ // nothing, and buries the tenant's audit log under no-ops.
+ writeJSON(w, http.StatusOK, rec)
+ return
+ }
+ pipe, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
+ if err != nil {
+ log.Printf("console: api instance channel %s/%s: read pipeline: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not read the pipeline")
+ return
+ }
+ // A pipelined app may only reach a channel its pipeline names. An
+ // app-defined pipeline is a reason to govern its own channels, never a
+ // way to reach one it left out — the promote door's own ruling, at the
+ // same 409.
+ if pipe != nil && pipe.Index(to) < 0 && !isCanaryChannel(to) {
+ jsonError(w, http.StatusConflict,
+ "this app's pipeline has no channel called "+to+" — it has "+strings.Join(pipe.Names(), ", "))
+ return
+ }
+ switch passkey, approvals := moveGate(pipe, from, to); {
+ case approvals >= 1:
+ s.proposeOverAPI(w, acct, app, kindRouteChannel,
+ channelMovePayload{Host: host, From: from, To: to, ETag: etag}, approvals, principal)
+ return
+ case passkey:
+ // A passkey rule with nothing to propose alongside it: one person's
+ // presence IS the whole gate, so this door refuses and names the one
+ // that can ask for it. (An approvals rule above subsumes this —
+ // every confirmation counted toward a quorum is passkey-backed.)
+ writeJSON(w, http.StatusForbidden, map[string]string{
+ "error": "moving " + host + " between " + from + " and " + to + " needs a passkey — finish in the console",
+ "console_url": s.appConsoleURL(acct, app),
+ })
+ return
+ }
+ rec.Channel = to
+ if err := provision.UpdateRecord(ctx, s.cfg.Releases, *rec, etag); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ jsonError(w, http.StatusConflict, "this instance changed while the request was in flight — try again")
+ return
+ }
+ log.Printf("console: api instance channel %s/%s/%s: %v", acct.Sqid, app, host, err)
+ jsonError(w, http.StatusBadGateway, "could not change the channel — try again")
+ return
+ }
+ s.audit(acct, app, principal, "instance-channel", "moved "+host+" from "+from+" to "+to+" (cli)")
+ // A box-consumed write: the reconcile pass converges the route's Channel
+ // column from this record, so announcing it is what makes the move take
+ // seconds instead of an hour.
+ s.bumpSerial(ctx)
+ writeJSON(w, http.StatusOK, rec)
+}
+
+// isCanaryChannel reports whether a name is in the reserved canary
+// namespace, which sits outside every pipeline by design.
+func isCanaryChannel(channel string) bool {
+ rung, ok := release.Rung(channel)
+ return ok && rung == release.RungCanary
+}
+
+// executePendingRouteChannel carries out a confirmed move. The record is
+// re-read and the proposal's etag is the pin: a record that moved underneath
+// the proposal is a decision made about bytes that no longer exist, which is
+// exactly what staleProposal says.
+func (s *Server) executePendingRouteChannel(ctx context.Context, acct *Account, app, actor string, pc *PendingChange) (string, error) {
+ var p channelMovePayload
+ if err := json.Unmarshal(pc.Payload, &p); err != nil {
+ return "", errors.New("this proposal's payload cannot be read — reject it and propose again")
+ }
+ rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, p.Host)
+ if err != nil {
+ log.Printf("console: execute route-channel %s/%s/%s: %v", acct.Sqid, app, p.Host, err)
+ return "", errors.New("could not read the instance")
+ }
+ if etag != p.ETag || rec.Channel != p.From {
+ return "", staleProposal("the instance is on " + rec.Channel + " now")
+ }
+ rec.Channel = p.To
+ if err := provision.UpdateRecord(ctx, s.cfg.Releases, *rec, etag); err != nil {
+ log.Printf("console: execute route-channel %s/%s/%s: %v", acct.Sqid, app, p.Host, err)
+ return "", errors.New("could not change the channel — try again")
+ }
+ s.audit(acct, app, actor, "instance-channel", fmt.Sprintf(
+ "moved %s from %s to %s (proposed by %s, %d confirmations)",
+ p.Host, p.From, p.To, pc.Proposer, len(pc.Confirms)))
+ s.bumpSerial(ctx)
+ return "moved " + p.Host + " to " + p.To, nil
+}
diff --git a/internal/console/channelmove_test.go b/internal/console/channelmove_test.go
new file mode 100644
index 000000000..f3165c061
--- /dev/null
+++ b/internal/console/channelmove_test.go
@@ -0,0 +1,343 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func movePipe(chans ...release.PipelineChannel) *release.Pipeline {
+ return &release.Pipeline{Schema: 1, Channels: chans}
+}
+
+// The bypass the union exists to close, and the reason guarding only the
+// DESTINATION is not enough: the guarded channel is the one being LEFT, and
+// the destination carries no rules at all.
+func TestMoveGateTakesRulesFromTheChannelBeingLeft(t *testing.T) {
+ p := movePipe(
+ release.PipelineChannel{Name: "edge"},
+ release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}},
+ )
+ passkey, approvals := moveGate(p, "production", "edge")
+ if approvals != 2 {
+ t.Fatalf("approvals = %d, want 2 — moving OFF a guarded channel serves its URL unreviewed code", approvals)
+ }
+ if passkey {
+ t.Fatalf("passkey = true, want false — neither channel asks for one")
+ }
+}
+
+// And the other half: guarding only the source would let a route be walked
+// ONTO a channel whose policy the mover never met.
+func TestMoveGateTakesRulesFromTheChannelBeingJoined(t *testing.T) {
+ p := movePipe(
+ release.PipelineChannel{Name: "edge"},
+ release.PipelineChannel{Name: "production", Rules: release.ChannelRules{Passkey: true}},
+ )
+ passkey, approvals := moveGate(p, "edge", "production")
+ if !passkey || approvals != 0 {
+ t.Fatalf("passkey=%v approvals=%d, want true/0", passkey, approvals)
+ }
+}
+
+// Each rule at its strictest, across both ends and both approval fields.
+func TestMoveGateTakesTheMaximumAcrossBothEndsAndBothRules(t *testing.T) {
+ p := movePipe(
+ release.PipelineChannel{Name: "a", Rules: release.ChannelRules{ChangeApprovals: 3}},
+ release.PipelineChannel{Name: "b", Rules: release.ChannelRules{PromoteApprovals: 1}},
+ )
+ if _, approvals := moveGate(p, "a", "b"); approvals != 3 {
+ t.Fatalf("approvals = %d, want 3", approvals)
+ }
+ if _, approvals := moveGate(p, "b", "a"); approvals != 3 {
+ t.Fatalf("approvals = %d (reversed), want 3 — the gate is symmetric", approvals)
+ }
+}
+
+// A legacy app has no pipeline: the stable RUNG keeps the console's standing
+// unconditional passkey, in BOTH directions, and nothing else is gated.
+func TestMoveGateLegacyStableNeedsPasskeyBothDirections(t *testing.T) {
+ if passkey, _ := moveGate(nil, "edge", "stable"); !passkey {
+ t.Fatal("moving ONTO stable must need a passkey")
+ }
+ if passkey, _ := moveGate(nil, "stable", "edge"); !passkey {
+ t.Fatal("moving OFF stable must need a passkey")
+ }
+ if passkey, approvals := moveGate(nil, "edge", "beta"); passkey || approvals != 0 {
+ t.Fatalf("edge→beta on a legacy app must be free, got %v/%d", passkey, approvals)
+ }
+}
+
+// canary/* is reserved platform namespace, outside every pipeline: always
+// allowed, never gated, even when the app's real channels are strict.
+func TestMoveGateCanaryIsUngated(t *testing.T) {
+ p := movePipe(release.PipelineChannel{Name: "edge", Rules: release.ChannelRules{Passkey: true, PromoteApprovals: 2}})
+ if passkey, approvals := moveGate(p, "canary/x", "canary/y"); passkey || approvals != 0 {
+ t.Fatalf("canary→canary must be ungated, got %v/%d", passkey, approvals)
+ }
+ // But a canary END does not launder the OTHER end's rules.
+ if passkey, approvals := moveGate(p, "canary/x", "edge"); !passkey || approvals != 2 {
+ t.Fatalf("canary→edge must still meet edge's rules, got %v/%d", passkey, approvals)
+ }
+}
+
+// A pipelined app whose channel names sit outside the legacy vocabulary must
+// not pick up legacy rungs by accident: "production" is not "stable".
+func TestMoveGatePipelinedNameIsNotALegacyRung(t *testing.T) {
+ p := movePipe(
+ release.PipelineChannel{Name: "edge"},
+ release.PipelineChannel{Name: "stable"}, // named like the rung, no rules declared
+ )
+ if passkey, approvals := moveGate(p, "edge", "stable"); passkey || approvals != 0 {
+ t.Fatalf("a pipelined app governs its OWN channels: got %v/%d, want free", passkey, approvals)
+ }
+}
+
+// A route sitting on a channel the app's pipeline does NOT name — a legacy
+// route on stable, on an app that has since declared a pipeline without it —
+// keeps the legacy rung's protection. Anything else would make "declare a
+// pipeline that omits stable" a way to move production routes for free.
+func TestMoveGateChannelOutsideThePipelineKeepsItsLegacyRung(t *testing.T) {
+ p := movePipe(release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production"})
+ if passkey, _ := moveGate(p, "stable", "edge"); !passkey {
+ t.Fatal("a route on stable, outside the pipeline, must still need a passkey to move off it")
+ }
+}
+
+// channelURL is the move door for one host.
+func channelURL(fx *consoleFixture, acct, app, host string) string {
+ return fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/instances/" + host + "/channel"
+}
+
+// recordOn puts one instance record in the bucket, on the named channel —
+// fixture state standing in for whatever created the route.
+func recordOn(t *testing.T, fx *consoleFixture, app, host, channel string) {
+ t.Helper()
+ rec := provision.Record{
+ Schema: 1, Host: host, Slug: strings.SplitN(host, ".", 2)[0], App: app,
+ Account: release.OpsAccount, Channel: channel, CreatedAt: time.Now().UTC(),
+ }
+ if err := provision.CreateRecord(context.Background(), fx.rel, rec); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func recordChannel(t *testing.T, fx *consoleFixture, app, host string) string {
+ t.Helper()
+ rec, err := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, app, host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return rec.Channel
+}
+
+// A legacy app, neither end on stable: the move just lands, and the fleet is
+// told once.
+func TestAPIInstanceChannelLegacyMoveLands(t *testing.T) {
+ fx, member := instanceFixture(t, "code-chan-move")
+ const host = "web.go.tito.io"
+ recordOn(t, fx, "titogo", host, "edge")
+ before := serial(t, fx)
+
+ status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), member, `{"channel":"beta"}`)
+ if status != http.StatusOK {
+ t.Fatalf("move = %d %s, want 200", status, out)
+ }
+ if got := recordChannel(t, fx, "titogo", host); got != "beta" {
+ t.Fatalf("record channel = %q, want beta", got)
+ }
+ if got := serial(t, fx); got != before+1 {
+ t.Fatalf("serial = %d, want exactly one bump from %d", got, before)
+ }
+}
+
+// Either end on the stable rung is the console's standing passkey refusal,
+// and it names where to finish.
+func TestAPIInstanceChannelLegacyStableNeedsPasskey(t *testing.T) {
+ fx, member := instanceFixture(t, "code-chan-stable")
+ const host = "web.go.tito.io"
+ recordOn(t, fx, "titogo", host, "edge")
+
+ status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), member, `{"channel":"stable"}`)
+ if status != http.StatusForbidden {
+ t.Fatalf("move onto stable = %d %s, want 403", status, out)
+ }
+ if !strings.Contains(out, "console_url") {
+ t.Fatalf("403 body = %s, want it to name where to finish", out)
+ }
+ if got := recordChannel(t, fx, "titogo", host); got != "edge" {
+ t.Fatalf("record moved anyway: %q", got)
+ }
+
+ // And OFF stable, which is the direction a naive gate forgets.
+ recordOn(t, fx, "titogo", "other.go.tito.io", "stable")
+ if status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", "other.go.tito.io"),
+ member, `{"channel":"edge"}`); status != http.StatusForbidden {
+ t.Fatalf("move OFF stable = %d %s, want 403", status, out)
+ }
+}
+
+// The bypass, at the door: moving OFF a guarded channel onto an unguarded
+// one must meet the guarded channel's quorum, and must not write yet.
+func TestAPIInstanceChannelOffGuardedChannelProposes(t *testing.T) {
+ fx, member := instanceFixture(t, "code-chan-guarded")
+ const host = "web.go.tito.io"
+ recordOn(t, fx, "titogo", host, "production")
+ setPipeline(t, fx, "titogo",
+ release.PipelineChannel{Name: "edge"},
+ release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}})
+
+ status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), member, `{"channel":"edge"}`)
+ if status != http.StatusAccepted {
+ t.Fatalf("move off a guarded channel = %d %s, want 202 (proposed)", status, out)
+ }
+ if got := recordChannel(t, fx, "titogo", host); got != "production" {
+ t.Fatalf("record channel = %q — a proposal must not have written", got)
+ }
+ if !strings.Contains(out, `"needed":2`) || !strings.Contains(out, `"result":"proposed"`) {
+ t.Fatalf("proposal body = %s, want needed:2 and result:proposed", out)
+ }
+}
+
+// A pipelined app may not reach a channel its pipeline left out, and the
+// refusal lists what it does have.
+func TestAPIInstanceChannelUnknownChannelNamesTheApps(t *testing.T) {
+ fx, member := instanceFixture(t, "code-chan-unknown")
+ const host = "web.go.tito.io"
+ recordOn(t, fx, "titogo", host, "edge")
+ setPipeline(t, fx, "titogo", release.PipelineChannel{Name: "edge"})
+
+ status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), member, `{"channel":"production"}`)
+ if status != http.StatusConflict {
+ t.Fatalf("unknown channel = %d %s, want 409", status, out)
+ }
+ if !strings.Contains(out, "edge") {
+ t.Fatalf("refusal = %s, want it to list the app's channels", out)
+ }
+}
+
+// A no-op must not send the whole fleet converging for nothing.
+func TestAPIInstanceChannelNoOpDoesNotBumpTheSerial(t *testing.T) {
+ fx, member := instanceFixture(t, "code-chan-noop")
+ const host = "web.go.tito.io"
+ recordOn(t, fx, "titogo", host, "edge")
+ before := serial(t, fx)
+
+ if status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host),
+ member, `{"channel":"edge"}`); status != http.StatusOK {
+ t.Fatalf("no-op move = %d %s, want 200", status, out)
+ }
+ if got := serial(t, fx); got != before {
+ t.Fatalf("serial = %d, want it unchanged at %d", got, before)
+ }
+}
+
+// A host with no record is the family's 404, not an existence oracle.
+func TestAPIInstanceChannelUnknownHostIs404(t *testing.T) {
+ fx, member := instanceFixture(t, "code-chan-404")
+ if status, _, _ := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", "nope.go.tito.io"),
+ member, `{"channel":"edge"}`); status != http.StatusNotFound {
+ t.Fatalf("unknown host = %d, want 404", status)
+ }
+}
+
+// An app token is a credential the app itself holds: it must not be able to
+// walk its own route onto another channel.
+func TestAPIInstanceChannelRefusesAppToken(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-chan-apptok")
+ const host = "web.go.tito.io"
+ recordOn(t, fx, "titogo", host, "edge")
+ appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), appTok, `{"channel":"beta"}`)
+ if status != http.StatusForbidden {
+ t.Fatalf("app token = %d %s, want 403 from the role gate", status, out)
+ }
+ if !strings.Contains(out, "needs a person") {
+ t.Fatalf("app-token refusal = %s, want the role gate's own sentence", out)
+ }
+ if got := recordChannel(t, fx, "titogo", host); got != "edge" {
+ t.Fatalf("record channel = %q, want edge", got)
+ }
+}
+
+// The pin: a confirmed move writes, and a record that moved underneath the
+// proposal refuses instead.
+func TestExecutePendingRouteChannelWritesAndPins(t *testing.T) {
+ fx, member := instanceFixture(t, "code-chan-exec")
+ const host = "web.go.tito.io"
+ recordOn(t, fx, "titogo", host, "production")
+ setPipeline(t, fx, "titogo",
+ release.PipelineChannel{Name: "edge"},
+ release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 1}})
+
+ status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), member, `{"channel":"edge"}`)
+ if status != http.StatusAccepted {
+ t.Fatalf("propose = %d %s, want 202", status, out)
+ }
+ var body map[string]any
+ if err := json.Unmarshal([]byte(out), &body); err != nil {
+ t.Fatal(err)
+ }
+ id, _ := body["pending_id"].(string)
+ pc, err := fx.st.PendingChange(id, time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ acct, err := fx.srv.store.AccountBySqid(release.OpsAccount)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // The summary a confirmer reads names BOTH ends — what the URL serves
+ // now matters as much as what it would serve after.
+ if got := pendingSummary(*pc); got != "move "+host+" from production to edge" {
+ t.Fatalf("summary = %q", got)
+ }
+
+ // Stale first: the record moves underneath the proposal.
+ rec, etag, err := provision.GetRecordETag(context.Background(), fx.rel, release.OpsAccount, "titogo", host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec.Channel = "beta"
+ if err := provision.UpdateRecord(context.Background(), fx.rel, *rec, etag); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.srv.executePendingRouteChannel(context.Background(), acct, "titogo", "alice@keymail.dev", pc); err == nil ||
+ !strings.Contains(err.Error(), "re-propose") {
+ t.Fatalf("stale execute err = %v, want the stale-proposal refusal", err)
+ }
+ if got := recordChannel(t, fx, "titogo", host); got != "beta" {
+ t.Fatalf("a refused execution wrote anyway: %q", got)
+ }
+
+ // Now a proposal whose pin still holds.
+ recordOn(t, fx, "titogo", "two.go.tito.io", "production")
+ status, _, out = rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", "two.go.tito.io"), member, `{"channel":"edge"}`)
+ if status != http.StatusAccepted {
+ t.Fatalf("second propose = %d %s", status, out)
+ }
+ if err := json.Unmarshal([]byte(out), &body); err != nil {
+ t.Fatal(err)
+ }
+ id, _ = body["pending_id"].(string)
+ pc2, err := fx.st.PendingChange(id, time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.srv.executePendingRouteChannel(context.Background(), acct, "titogo", "alice@keymail.dev", pc2); err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+ if got := recordChannel(t, fx, "titogo", "two.go.tito.io"); got != "edge" {
+ t.Fatalf("record channel = %q, want edge", got)
+ }
+}
diff --git a/internal/console/pendinghandlers.go b/internal/console/pendinghandlers.go
index 7e783e49e..16f0bb5f7 100644
--- a/internal/console/pendinghandlers.go
+++ b/internal/console/pendinghandlers.go
@@ -46,6 +46,11 @@ const (
kindPromote = "promote"
kindPolicy = "policy"
kindFastTrack = "fasttrack"
+ // kindRouteChannel moves one ROUTE between channels — not a release
+ // anywhere, which is why it is its own kind rather than a promote with a
+ // different payload: nothing is published, and what changes is which
+ // pointer one host follows.
+ kindRouteChannel = "route-channel"
)
// pendingTimeout bounds the store round trips an execution makes — the same
@@ -168,6 +173,14 @@ func pendingSummary(pc PendingChange) string {
return "skip the safety delay on " + pc.App
}
return "skip the safety delay for " + p.Version + " on " + p.Channel
+ case kindRouteChannel:
+ var p channelMovePayload
+ if err := json.Unmarshal(pc.Payload, &p); err != nil {
+ return "move a route of " + pc.App
+ }
+ // Both ends, because a confirmer judging this needs to know what the
+ // URL serves NOW as much as what it would serve after.
+ return "move " + p.Host + " from " + p.From + " to " + p.To
}
return pc.Kind + " on " + pc.App
}
@@ -304,6 +317,8 @@ func (s *Server) executePending(ctx context.Context, acct *Account, app, actor s
msg, err = s.executePendingPolicy(ctx, acct, app, actor, pc)
case kindFastTrack:
msg, err = s.executePendingFastTrack(ctx, acct, app, actor, pc)
+ case kindRouteChannel:
+ msg, err = s.executePendingRouteChannel(ctx, acct, app, actor, pc)
default:
return "", fmt.Errorf("this console does not know how to carry out a %q change", pc.Kind)
}
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go
index 3bcf125d1..b8b548fbf 100644
--- a/internal/console/svcroutes_test.go
+++ b/internal/console/svcroutes_test.go
@@ -124,6 +124,12 @@ var closedDoors = []string{
// It also raises the daily cap in the same request, which is an
// authority grant rather than a runtime toggle.
"POST /api/cli/apps/{account}/{app}/email/pause",
+ // Moving a route between channels decides what that route SERVES, which
+ // is release authority wearing a config-shaped hat: a credential the app
+ // itself holds must not be able to walk its own route onto a channel
+ // whose rules it never met. Shut for the same reason the manifest door
+ // is — an app may not rewrite the bounds it runs inside.
+ "POST /api/cli/apps/{account}/{app}/instances/{host}/channel",
}
// TestEveryCLIDoorNamesItsRoles is the default-deny guarantee for the
@@ -617,6 +623,7 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) {
"{fleet}", "edge",
"{label}", "box1",
"{name}", "hub",
+ "{host}", "web.go.tito.io",
).Replace(path)
if strings.Contains(u, "{") {
t.Fatalf("closedDoors entry %q has a wildcard this test does not fill", pattern)
@@ -634,8 +641,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) {
}
seen++
}
- if seen != len(closedDoors) || seen != 16 {
- t.Errorf("walked %d closed doors, want all %d (and 16 today: instance-token, the routes door, instances-manifest, six fleets, four services, two email credentials, email pause)",
+ if seen != len(closedDoors) || seen != 17 {
+ t.Errorf("walked %d closed doors, want all %d (and 17 today: instance-token, the routes door, instances-manifest, the route-channel move, six fleets, four services, two email credentials, email pause)",
seen, len(closedDoors))
}
}
diff --git a/internal/release/pipeline.go b/internal/release/pipeline.go
index cb7534373..d01983f27 100644
--- a/internal/release/pipeline.go
+++ b/internal/release/pipeline.go
@@ -97,6 +97,16 @@ func (p *Pipeline) ChannelNamed(name string) (PipelineChannel, bool) {
return PipelineChannel{}, false
}
+// Names is the pipeline's channel names in promotion order — what a refusal
+// lists when it has to say which channels an app actually has.
+func (p *Pipeline) Names() []string {
+ out := make([]string, len(p.Channels))
+ for i, c := range p.Channels {
+ out[i] = c.Name
+ }
+ return out
+}
+
// MaxApprovals is the largest quorum any channel demands — the console
// refuses to store a pipeline whose MaxApprovals exceeds the account's
// member count (spec §4).
diff --git a/main.go b/main.go
index d6b3600c8..7d7cd5dbf 100644
--- a/main.go
+++ b/main.go
@@ -173,7 +173,8 @@ Your apps — ship and run software on a CARLOS deployment
env plain per-app config vars: set | unset | list
secrets sealed per-app secrets: set | unset | list | genkey
instances an app's instances: enable | create | list | delete |
- set-upstreams
+ set-upstreams | set-channel — set-channel is the only
+ door onto which channel a route follows
steering opt an instance into DNS latency steering, or back off —
-app <a> -host <h> latency|off (shared pool only, v1)
routes an app's routes and what each is bound to: upstream,