spec: release pipelines v2 — one channel by default, pipelines opt-in
App-defined channels with per-channel opt-in policy (bake, passkey, approvals); edge as the sole default channel; canary/* unchanged; absence of pipeline.json = legacy ladder byte-for-byte; box-side loosening ratchet; pending-change primitive for N-member confirmation. Design reviewed section-by-section with Paul in chat, 2026-08-20.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+213
−0
docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md+213 −0
diff --git a/docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md b/docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md| new file mode 100644 |
| index 000000000..3e9e136f9 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md |
| @@ -0,0 +1,213 @@ |
| +# Release pipelines v2 — one channel by default, pipelines opt-in |
| + |
| +**Status:** approved 2026-08-20 (design review in chat with Paul). |
| +**Amends** the pipelines design (2026-08-02) §3: the ladder stops being the |
| +platform's frozen vocabulary and becomes the built-in *template*. **Extends** |
| +the console bake controls design (2026-08-04): the production flag becomes a |
| +legacy-boxes-only mechanism for pipelined apps. The channel/environment split |
| +from the pipelines design is untouched — channels remain maturity, |
| +environments remain glue. |
| + |
| +## 1. Why |
| + |
| +The ladder optimised for strict production when most apps — including ours, |
| +today — start in development. Three weeks of evidence: |
| + |
| +- **Production still runs on `canary/*` channels** (pinned in |
| + `internal/release/laddercheck_test.go`). The fleet never climbed the ladder |
| + the pipelines spec built; the on-ramp was wrong, not the users. |
| +- **Amadan Pages hit the unconditional stable passkey** trying to publish a |
| + static site, and worked around it by publishing to edge — which is the |
| + correct behaviour the platform should have offered, not a workaround. |
| +- The bake-controls design already conceded half the point: `production: |
| + false` is the default and skips bake windows. But the passkey-on-stable |
| + stayed unconditional — development mode was built for bake windows and |
| + forgotten for release rules. |
| + |
| +The concept (ordered channels, promotion, bake) works. The prescription |
| +(every app is born with a three-rung production ladder) is what confuses. |
| +So: **one channel by default, canaries always allowed, multi-channel |
| +pipelines opt-in, every rule per-channel and opt-in.** |
| + |
| +## 2. The pipeline object |
| + |
| +New, console-mediated file: `apps/<acct>/<app>/pipeline.json`. |
| + |
| +```json |
| +{ |
| + "schema": 1, |
| + "channels": [ |
| + {"name": "edge"}, |
| + {"name": "production", |
| + "bake": "24h", |
| + "rules": {"passkey": true, "promote_approvals": 2, "change_approvals": 2}} |
| + ] |
| +} |
| +``` |
| + |
| +- **The ordered list is the promotion order.** The first channel is the |
| + *entry channel*: where `carlos deploy` lands (the CLI's existing |
| + channel resolution — "the app's one channel if it has no instances" — |
| + already composes). Promotion onto channel N must come from channel N−1. |
| +- **Channel names are app-defined.** `edge`/`beta`/`stable` are just the |
| + names the built-in templates use. `canary` is reserved: `canary/*` stays a |
| + platform namespace outside the pipeline — always allowed, bake 0, TTL |
| + machinery untouched. No other pipeline channel name may contain `/`. |
| +- **All policy is per-channel and defaults permissive** (opt-in throughout): |
| + - `bake` — duration string, default `0s`. |
| + - `rules.passkey` — bool, default false: promotion onto this channel |
| + requires the member's passkey step-up. |
| + - `rules.promote_approvals` — int, default 0: promotion onto this channel |
| + requires N distinct members' confirmation. |
| + - `rules.change_approvals` — int, default 0: N distinct members must |
| + confirm (a) any edit to this channel's entry in the pipeline, and (b) any |
| + fast-track of a specific release through this channel's bake window. |
| + One field covers both "changing" and "overriding" the bake. Removing |
| + the channel or moving its position in the list counts as an edit to its |
| + entry — otherwise deletion is the loophole around every rule it carries. |
| + Self-protecting: an edit that lowers `change_approvals` must meet its |
| + *current* value. |
| +- **New apps** are created with `{"schema": 1, "channels": [{"name": |
| + "edge"}]}`. Creation offers `edge` as the default name with the ability |
| + to rename. |
| +- **Absence means the legacy ladder.** No `pipeline.json` → exactly today's |
| + behaviour: frozen `edge → beta → stable` vocabulary, `DefaultHolds` |
| + (edge 0 / beta 24h / stable 72h), unconditional stable passkey in the |
| + console. Every existing app, every direct-bucket app (e.g. apt), and every |
| + old binary behaves byte-identically until someone opts in. |
| +- Writes are console-mediated and bump `control/serial`, like every other |
| + control-plane write. |
| + |
| +## 3. Box-side enforcement |
| + |
| +Boxes read `pipeline.json` per app. `HoldFor` consults it when present; a |
| +channel with no pipeline entry, or an app with no pipeline on a channel name |
| +the box doesn't recognise, keeps today's fail-closed 72h. |
| + |
| +**The loosening ratchet** (same pattern as the production-flag memory in |
| +`FirstSeen.Production`): a box honours a *shorter* bake — or a fast-track of |
| +a held release — only after the *previously-known* window has elapsed since |
| +the box first observed the change. A *longer* bake is honoured immediately. |
| +The clock is the box's own observation, never a manifest timestamp. So a |
| +fully compromised console session cannot collapse a 72h window and ship in |
| +the same hour: the old window still runs once, locally, on clocks the |
| +attacker cannot backdate. There remains no publisher-side skip-the-hold flag; |
| +the local root-on-box bypass (`Hold`'s ignore parameter) is unchanged. |
| + |
| +The ratchet memory is a new **additive field on `FirstSeen`**, with the same |
| +one-pass-deep old-binary caveat as the `Production` field (an old binary's |
| +Save drops it; it re-latches from the bucket the next pass a new binary |
| +reads, and a restarted ratchet clock is the closed direction). |
| + |
| +**Old-binary edge and its mitigation:** an old binary ignores |
| +`pipeline.json` entirely, and if the app's legacy production flag is false it |
| +adopts immediately on any channel. Therefore: **enabling a pipeline with any |
| +nonzero bake also sets `production: true`**, so lagging boxes fall back to |
| +fail-closed legacy holds (unknown names bake 72h) rather than fail-open. For |
| +pipelined apps on new binaries, the production flag is ignored — bake policy |
| +comes solely from the pipeline; the flag remains meaningful for legacy apps |
| +and legacy boxes only. |
| + |
| +## 4. The pending-change primitive |
| + |
| +One object serves all three approval gates — promotion, policy edit, |
| +fast-track: |
| + |
| +- **Lives in the console DB** of the deployment. It is a workflow object, |
| + not a wire object: nothing on a box ever parses it. Only the outcome — the |
| + updated `pipeline.json`, the moved channel pointer — lands in the bucket, |
| + after quorum. |
| +- **A proposal pins its exact payload.** Promotions pin the version, not |
| + "current head"; policy edits pin the full new channel entry. Confirmations |
| + bind to that payload, so what was approved is what lands, and a head that |
| + moved underneath a proposal invalidates it. |
| +- **Confirming requires the member's passkey, in the console.** The CLI can |
| + propose and list, never confirm — the existing "finish in the console" |
| + shape, now uniform across every gated action. |
| +- **Quorum is N distinct members; the proposer counts as the first.** |
| + Setting any approvals value above the account's member count is refused at |
| + write time with an error that names the member count. |
| +- **Pending changes expire after 7 days.** A promotion confirmed against a |
| + weeks-old proposal is a hazard, not a convenience. |
| + |
| +## 5. CLI and console UX |
| + |
| +- **One-channel apps hide the vocabulary entirely.** The console app page |
| + shows a Releases list and a "live" marker — no channel column, no promote |
| + button, no ladder card. `carlos deploy` works as today. `carlos promote` |
| + on a one-channel app explains there is nothing downstream and points at |
| + pipeline setup. |
| +- **Opting in:** "Set up a pipeline" in console app settings, plus a |
| + `carlos pipeline` verb family (show / init-from-template / edit), all via |
| + the console API — no box access required, per the CLI rule. Templates at |
| + setup: |
| + - **Edge → Production** — `production`: bake 24h, passkey. |
| + - **Full ladder** — `edge → beta → stable` matching today's defaults |
| + (beta 24h; stable 72h + passkey). |
| + - Custom is start-from-a-template-and-edit, not a blank page. |
| +- **Promotion order comes from the pipeline.** Refusals name the required |
| + predecessor. **`-hotfix` bypasses ORDER only, never RULES**: passkey and |
| + approvals still gate a hotfix; it is simply recorded as out-of-order. |
| +- **Gated actions from the CLI propose; the console confirms.** A promote or |
| + fast-track that needs approvals or a passkey creates the pending change |
| + and prints the console URL to finish. |
| + |
| +## 6. Signing keys and direct-bucket writes |
| + |
| +- **Scoped keys fail closed for free in v1.** A `Grant` can only name frozen |
| + rungs (`grant.go`), and an app-defined channel name is not a rung — so no |
| + scoped key can ever cover `production`, and pointers for app-defined |
| + channels verify against the root key only. Extending grants to named |
| + channels is an explicit follow-up, not part of this design. |
| +- **Direct-bucket promotes** (pinfra scripts, root key in hand) bypass |
| + console gates — as they do today for stable's passkey. The backstop is |
| + root-key possession plus the box-side ratchet; the design does not pretend |
| + otherwise. |
| + |
| +## 7. Compatibility and the consumer list |
| + |
| +Everything that **parses** the shape (the rule this repo has paid for three |
| +times): |
| + |
| +- `internal/release/ladder.go` — grows a pipeline-aware `PromotionAllowed`; |
| + the frozen vocabulary and current switch are retained for the legacy path. |
| +- `internal/release/bake.go` — `HoldFor` becomes pipeline-aware; `FirstSeen` |
| + gains the additive ratchet field (§3). |
| +- `internal/release/` adoption / `VersionRung` — must handle channel names |
| + outside the frozen vocabulary wherever a rung index is assumed. |
| +- `internal/console/ladder.go` + `apicli.go` — the unconditional |
| + stable-passkey becomes legacy-path-only; pipeline rules gate instead. |
| +- Console UI (app pages, releases, settings) and the CLI |
| + (`deploy`/`promote`/new `pipeline` family). |
| +- `internal/release/laddercheck_test.go` — the pinned `canary/*` production |
| + channel names must stay green, untouched. |
| + |
| +Wire rules: every new thing is a new object or an additive field; absent |
| +`pipeline.json` reproduces today's behaviour byte-for-byte; no existing |
| +object changes shape; old binaries never see a shape they cannot parse. |
| + |
| +## 8. Testing |
| + |
| +TDD throughout, in the existing gate (`go build/vet/test ./...`, darwin |
| +cross-compile): |
| + |
| +- Ratchet clock: loosen-delayed, tighten-immediate, fast-track-delayed, |
| + observation-clock-not-manifest-timestamp, old-binary field-drop re-latch. |
| +- Pending changes: quorum with distinct members, proposer-counts-once, |
| + payload pinning (moved head invalidates), expiry, refusal of |
| + approvals > member count, `change_approvals` self-protection. |
| +- Promotion: order-from-pipeline, hotfix-bypasses-order-not-rules, |
| + entry-channel deploy resolution, one-channel promote refusal text. |
| +- Wire compat: absent pipeline = legacy behaviour, old `FirstSeen` parses, |
| + unknown-channel 72h fail-closed, scoped-key refusal on app-defined names. |
| +- `laddercheck_test.go` untouched and green. |
| + |
| +## 9. Explicitly out of scope (follow-ups) |
| + |
| +- Extending key grants to app-defined channel names. |
| +- Channel rename on an app with live routes (creation-time naming only in |
| + v1). |
| +- Auto-promotion rules (e.g. "promote edge → staging after N green hours"). |
| +- Migrating the existing fleet's `canary/*` production channels onto |
| + pipelines — this design makes that migration *possible*; performing it is |
| + its own operation. |