| 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.** |