carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev 7659241550395fdc0483b8df3cb66cbfc4c63e1b parent df9e89e
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.