carlos / platform Public

plans: static platform host, and the record channel-change door

Two plans, one per spec. Both are TDD task-by-task with the actual test
bodies, because the load-bearing parts are exactly the ones a summary
would lose: the mint's explicit channel, and the move gate reading BOTH
ends of the move.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 2a2ef0133c8ffafa481b5d754a728074c320b4c9 parent 2261d88
2 files changed, +1159 −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
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.**