carlos / platform Public

plan: pipelines slice 1 — the ladder, enforced and visible

Six tasks: the rung vocabulary, ordered promotion with an explicit
hotfix bypass, an audit trail, the console ladder view behind step-up for
stable, the adopt-timer arming fix, and moving both production apps off
their migration canary channels.

Environments get their own plan — bucket format, secret references and
activator materialisation are a subsystem, not a task. Build-on-merge
needs a CI story this repo does not have yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell a106a581ca01215cb2abbac28ca9ccbb5f7900f6 parent 6258137
1 file changed, +174 −0
  • docs/superpowers/plans/2026-08-02-plan-pipelines.md +174 −0
diff --git a/docs/superpowers/plans/2026-08-02-plan-pipelines.md b/docs/superpowers/plans/2026-08-02-plan-pipelines.md
new file mode 100644
index 000000000..77a82c03f
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-02-plan-pipelines.md
@@ -0,0 +1,174 @@
+# Pipelines Implementation Plan — slice 1
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task.
+
+**Goal:** Make the channel ladder real — ordered promotion with an explicit
+hotfix bypass, an audit trail, a console view of what is on each rung, and
+production moved off its migration channels onto `stable`.
+
+**Architecture:** The ladder is platform vocabulary in `internal/release`.
+`carlos promote` consults it and refuses a skip. The console renders one ladder
+per app from the channel pointers it already reads. Nothing about the artifact
+format, the signing scheme or adoption changes.
+
+**Spec:** `docs/superpowers/specs/2026-08-02-pipelines-design.md`
+
+## Global Constraints
+
+- Rungs, in order: `canary` → `edge` → `beta` → `stable`. Frozen vocabulary.
+- Ordered promotion applies **from `edge` upward only**: `edge → beta`,
+ `beta → stable`. Shipping to a `canary/*` channel is never refused by
+ ordering, and nothing is ever promoted *from* canary to edge — merging is what
+ puts a change on edge.
+- `carlos promote -hotfix` may place a version on any rung and is recorded as a
+ hotfix.
+- Promotion moves a signed channel pointer only. It must never alter a route's
+ environment, and no task in this plan may add a code path that does.
+- Existing nested channel names (`canary/keymail`, `canary/platform-adoption`,
+ `canary/rehearsal`) must keep working — they are `canary` rung channels and
+ are what production currently runs on.
+- Every existing test must still pass. `go build ./...`, `go vet ./...`,
+ `go test ./... -count=1` clean.
+
+---
+
+### Task 1: The ladder as ordered vocabulary
+
+**Files:**
+- Create: `internal/release/ladder.go`, `internal/release/ladder_test.go`
+
+**Interfaces produced:**
+- `func Rung(channel string) (string, bool)` — the rung a channel name belongs
+ to. `"stable"` → `("stable", true)`; `"canary/keymail"` → `("canary", true)`;
+ an unknown name → `("", false)`.
+- `func RungIndex(rung string) int` — 0 canary, 1 edge, 2 beta, 3 stable; -1 unknown.
+- `func PromotionAllowed(from, to string) error` — nil when the transition is
+ legal, otherwise an error naming `-hotfix`. `from` is the rung a version
+ currently occupies (highest, if several), `to` is the target rung.
+- `const RungCanary/RungEdge/RungBeta/RungStable`
+
+Rules: `edge → beta` and `beta → stable` are legal. Any other pair is refused,
+including `edge → stable` (a skip), `canary → edge`, and same-rung
+re-promotion of a *different* version (re-promoting the SAME version to the rung
+it already holds is a no-op success, so a repeated command is not an error).
+
+- [ ] **Step 1: Write the failing test** covering: rung parsing for
+ `stable`, `beta`, `edge`, `canary/anything`, `canary`, and an unknown name;
+ every legal transition; every skip refused; the refusal text containing
+ `-hotfix`.
+- [ ] **Step 2: Run it — expect failure (package does not exist)**
+- [ ] **Step 3: Implement `ladder.go`**
+- [ ] **Step 4: Run tests — expect pass**
+- [ ] **Step 5: Commit**
+
+---
+
+### Task 2: `carlos promote` enforces the ladder
+
+**Files:**
+- Modify: `promote.go` (or wherever the promote verb lives — find it, do not
+ assume), `internal/release/` channel resolution as needed
+- Test: extend the existing promote/e2e tests
+
+**Interfaces consumed:** Task 1's `PromotionAllowed`, `Rung`.
+
+The verb must determine which rung the version currently occupies by reading the
+existing channel pointers for that app+account, then check the transition.
+
+- [ ] **Step 1: Write the failing e2e test** — ship a version, promote to
+ `edge`, then: promoting to `beta` succeeds; promoting a fresh version straight
+ to `stable` is refused with a message naming `-hotfix`; `-hotfix` makes it
+ succeed and the pointer records it.
+- [ ] **Step 2: Run it — expect failure**
+- [ ] **Step 3: Add the `-hotfix` flag and the ordering check**
+- [ ] **Step 4: Run the full suite**
+- [ ] **Step 5: Commit**
+
+**Constraint:** promoting to a `canary/*` channel must remain unconditionally
+allowed — production is on those channels right now and this must not break it.
+
+---
+
+### Task 3: Promotion audit trail
+
+**Files:**
+- Modify: the channel pointer payload (`internal/release/`), console store
+
+Each promotion records: version, from-rung, to-rung, actor, hotfix bool,
+timestamp. The channel pointer already carries `history`; extend the payload
+additively so old pointers still parse.
+
+- [ ] **Step 1: Write the failing test** — a promotion appends an audit entry;
+ a pointer written before this change still unmarshals with no audit data and
+ no error.
+- [ ] **Step 2: Run — expect failure**
+- [ ] **Step 3: Implement, additively**
+- [ ] **Step 4: Run the suite**
+- [ ] **Step 5: Commit**
+
+---
+
+### Task 4: Console ladder view
+
+**Files:**
+- Modify: `internal/console/server.go`, `internal/console/templates/`
+
+One section on the app page: four rungs in order, each showing the version it
+holds, when, and by whom; routes listed under the rung they subscribe to. A
+promote control between adjacent rungs, owner-only, and **`stable` promotion
+goes through `requireFreshAuth`** (the step-up gate shipped 2026-08-02).
+
+A route on a `canary/*` channel is badged; if its environment is a production
+one, badged distinctly and prominently.
+
+- [ ] **Step 1: Write the failing test** — the ladder renders four rungs with
+ the right versions; a non-owner sees no promote control; a POST to promote to
+ `stable` without step-up does not promote.
+- [ ] **Step 2: Run — expect failure**
+- [ ] **Step 3: Implement**
+- [ ] **Step 4: Run the suite**
+- [ ] **Step 5: Commit**
+
+---
+
+### Task 5: Adoption timer is armed, and its health is visible
+
+**Files:**
+- Modify: `internal/host/` (bootstrap unit installation),
+ `/tmp/claude-1001/pinfra/scripts/update-platform-binary.sh`,
+ `internal/status/`
+
+`carlos-adopt.timer` was `enabled` but never `active` on the flagship, so no
+release ever adopted automatically. Bootstrap and the unit-refresh path must
+arm timers (`enable --now` semantics), and the fleet status snapshot must report
+whether adoption is running, so a dead timer is a visible defect.
+
+- [ ] **Step 1: Write the failing test** for the status field
+- [ ] **Step 2: Run — expect failure**
+- [ ] **Step 3: Implement; make bootstrap start the timer**
+- [ ] **Step 4: Run the suite**
+- [ ] **Step 5: Commit**
+
+---
+
+### Task 6: Production onto `stable`
+
+**Not code — an operational task, run after 1-5 are deployed.**
+
+`go.tito.io` (channel `canary/platform-adoption`) and `keymail.dev` (channel
+`canary/keymail`) move onto `stable` at their current versions: promote the
+current version to `stable` with `-hotfix` (there is no `beta` history to come
+through), then update each route's channel subscription, then verify the site
+still serves and the version is unchanged.
+
+Tito has eighteen routes on that channel; all move together.
+
+---
+
+## Deferred, and named
+
+Environments as named bundles (spec §4) — bucket format, secret references and
+activator materialisation are a subsystem of their own and get their own plan.
+Build-on-merge and the `edge`-scoped signing identity (§6) need a CI story this
+repo does not have. Sub-hourly adoption (§6.3). Retiring `canary.sh` and the
+keymail dev box (§8.4).