// The CLI's pipeline API: `carlos pipeline` reading and writing an app's // pipeline object through the console, the same door class as promote and // config — release pipelines v2 // (docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md). // // GET is Publish-reachable like channels/releases: a pipeline is release // metadata, not a secret. PUT is admin-only for service credentials // (svcrole.Allow(svcrole.Admin) at registration) — it rewrites the policy // that governs every promotion into every channel of the app, including // whether one needs a passkey or a quorum of humans, which is account // authority in the same register as ledgers and object storage grants, not // day-to-day deploy work. A human member reaches it regardless of role: the // role gate in cliRoute only ever narrows a SERVICE credential. // // An edit that touches a channel whose CURRENTLY STORED rules already demand // change_approvals does not land from here: it becomes a PROPOSAL (202, // "finish in the console"), pinned to the pipeline it was composed against // and starting at zero confirmations, because a member typing a bearer-token // PUT must never be able to strip or reorder past a rule that exists // specifically to require more than one bearer token's say-so. Tightening // (raising a channel's own approvals, or adding a guard where there was none) // still goes straight through — only a channel the stored pipeline ALREADY // guards takes the quorum. package console import ( "context" "errors" "fmt" "log" "net/http" "strings" "time" "github.com/carlosframework/platform/internal/release" ) // pipelineStoreTimeout bounds every store round trip these handlers make — // the same budget handleAPIChannels and the config doors give the bucket. const pipelineStoreTimeout = 15 * time.Second // handleAPIPipelineGet answers `carlos pipeline`: an app's declared // pipeline, or null when it has none. Absence is not an error — it is spec // §2's "no pipeline.json" case, meaning the legacy edge/beta/stable ladder // applies untouched — so it is a 200 with a null field, not a 404. // // GetPipeline's error return is NEVER treated as absence: an unreadable or // unverifiable object is a 502, distinct from the (nil, nil) genuinely-missing // case. Collapsing the two would tell a caller "this app has no pipeline" // when the truth is "the console could not tell you" — the exact trap // Task 1's review caught for this same function. func (s *Server) handleAPIPipelineGet(w http.ResponseWriter, r *http.Request) { _, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } ctx, cancel := context.WithTimeout(r.Context(), pipelineStoreTimeout) defer cancel() p, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) if err != nil { log.Printf("console: api pipeline get %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read the pipeline") return } writeJSON(w, http.StatusOK, map[string]any{"pipeline": p}) } // handleAPIPipelinePut answers `carlos pipeline set` (and the console's own // pipeline editor, once it exists): replace an app's channel list. // // Doors, in order, and why: // // token + account/app 404 — apiAccountApp, same byte-identical refusal // every other door in this family gives. // write budget — allowWrite, the same picker every other // mutating CLI door here uses (promote, // rollback, config, restart): a person keyed on // their address, a service on its name. // signing key — SetPipeline signs root-only, like promote and // production; a read-only console (no // CARLOS_RELEASE_KEY) refuses with 503 before // ever reading a body, the same shape // handleAPIPromote gives. // body + current read — decoded and the CURRENT pipeline read (needed // for the guarded-edit check below) before any // refusal that depends on either. // member-count ceiling — spec §4: no channel's approvals may exceed // the account's own member count, checked // against the PROPOSED shape. // guarded-edit refusal — the interim gate this door's doc comment // describes; Task 8 replaces it with the real // quorum flow. // SetPipeline — validates shape internally (canary reserved, // duplicate names, bad bake strings, negative // approvals) and signs. Its error is CLASSIFIED // (classifySetPipelineError, below) rather than // echoed wholesale: a validation refusal is 422, // the caller's to fix; a lost CAS race is 409, // re-ask; a store read/write failure is 502 and // never puts a bucket key path on the wire. // production side-write — spec §3's old-binary fallback, below. // audit + bump — after the write lands, never before or in // place of a gate above. func (s *Server) handleAPIPipelinePut(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } principal, _ := principalFrom(r.Context()) if !s.allowWrite(principal, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return } if s.cfg.Key == nil { jsonError(w, http.StatusServiceUnavailable, "no signing key on this host — the console is running read-only") return } var in struct { Channels []release.PipelineChannel `json:"channels"` } if !readJSON(w, r, &in) { return } ctx, cancel := context.WithTimeout(r.Context(), pipelineStoreTimeout) defer cancel() cur, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) if err != nil { log.Printf("console: api pipeline put %s/%s: read current: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "cannot read the current pipeline") return } proposed := &release.Pipeline{Schema: 1, Account: acct.Sqid, App: app, Channels: in.Channels} members, err := s.store.Members(acct.ID) if err != nil { log.Printf("console: api pipeline put %s/%s: members: %v", acct.Sqid, app, err) jsonError(w, http.StatusInternalServerError, "members lookup failed") return } if n := proposed.MaxApprovals(); n > len(members) { jsonError(w, http.StatusUnprocessableEntity, fmt.Sprintf( "approvals of %d exceed the account's %d member(s) — add members first", n, len(members))) return } if _, n := guardedEdit(cur, in.Channels); n > 0 { // The guard is met by a QUORUM, not refused outright (release // pipelines v2 §4): the edit becomes a proposal pinned to the // pipeline it was composed against, at zero confirmations, and the // members finish it in the console with their passkeys. s.proposeOverAPI(w, acct, app, kindPolicy, policyProposal(cur, in.Channels), n, addr) return } // writePipeline (pipelinepage.go) is the shared write tail: SetPipeline, // the old-binary bake fallback, the audit row and the serial bump — the // browser settings POST (Task 9) funnels through the same function // rather than a second copy of it. set, err := s.writePipeline(ctx, acct, app, in.Channels, addr, "cli") if err != nil { status, msg := classifySetPipelineError(err) if status != http.StatusUnprocessableEntity { // A validation refusal is the caller's own typo — the message // already says so, on the wire, and logging every bad request a // script sends would just be noise. A 409 or 502 here is a // genuine backend condition (a race, an unreachable store) an // operator may need the real detail for. log.Printf("console: api pipeline put %s/%s: set: %v", acct.Sqid, app, err) } jsonError(w, status, msg) return } writeJSON(w, http.StatusOK, map[string]any{"pipeline": set}) } // guardedEdit is the confirmation gate on a policy write. It reports the // channel in cur whose CURRENTLY STORED rules demand the HIGHEST // change_approvals among those this edit touches, and that quorum — or // ("", 0) when nothing guarded was touched. The whole edit then proposes at // that quorum (handleAPIPipelinePut): an edit touching two guarded channels // must meet the stricter of them, or the looser one would be the way past the // stricter in the same request. // // cur is nil for an app that has never had a pipeline (GetPipeline's // (nil, nil) case): nothing is guarded yet, so nothing can be touched. // // "Preserve" means: still present in next, at the SAME index, with an // IDENTICAL PipelineChannel value — name, bake and every rule, compared with // Go's struct ==. Removing the channel, reordering it, or changing anything // about it (including loosening or tightening its own approvals) all count // as touching it, matching spec §2's "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." Only a channel cur did NOT // already guard is free to be added, removed or reordered without // confirmation. // // One boundary this DELIBERATELY leaves open, named here so it reads as // chosen rather than missed: swapping a guarded channel's PREDECESSOR changes // what may be promoted into it, and is not itself an edit to its entry. Spec // §2 scopes the guard to "this channel's entry in the pipeline", and the // predecessor's own entry is the thing being edited — so a predecessor swap // passes unless that predecessor is itself guarded. The promotion into the // guarded channel still meets every rule the guarded channel carries, which // is where its protection actually lives. func guardedEdit(cur *release.Pipeline, next []release.PipelineChannel) (string, int) { if cur == nil { return "", 0 } name, quorum := "", 0 for i, c := range cur.Channels { if c.Rules.ChangeApprovals < 1 || c.Rules.ChangeApprovals <= quorum { continue } if i >= len(next) || next[i] != c { name, quorum = c.Name, c.Rules.ChangeApprovals } } return name, quorum } // ensureProductionForBakes is spec §3's old-binary mitigation: enabling a // pipeline with ANY nonzero bake also sets production: true, so a box that // has not yet been updated to read pipeline.json — and therefore ignores it // entirely — falls back to its OWN fail-closed legacy holds (HoldFor's 72h // for a name it doesn't recognise) instead of adopting a held release // immediately. // // It only ever flips TOWARD true. A pipeline whose every channel bakes for // 0s is not a reason to turn a flag OFF — production may be true for a // reason that has nothing to do with this pipeline (spec's deletion // protection) — and an UNREADABLE flag is left alone rather than // re-declared: GetProduction's error already fails every reader toward // "treat as production" (adopt path, trash's delete gate), so writing over // it here would only risk losing whatever the existing object actually // says. Log-only on a READ failure: the pipeline write itself already // landed, and a box that never sees this flag flip is no worse off than a // pipeline-less app was yesterday. A successful FLIP, though, gets the same // audit row every other SetProduction call site writes (server.go's // enable_bake and production-toggle doors) — this flag gates fleet-wide // baking and delete protection, so a row that goes unaudited here while // every other path to it is audited would be the one silent way to turn it // on. func (s *Server) ensureProductionForBakes(ctx context.Context, acct *Account, app string, p *release.Pipeline, actor string) { bakes := false for _, c := range p.Channels { if d, err := c.BakeWindow(); err == nil && d > 0 { bakes = true break } } if !bakes { return } // A temporary app is never flagged production (spec §4): the flag would // take it out of automatic destruction's reach. Its pipeline bakes on // boxes new enough to read the pipeline; the old-binary fallback is // skipped, and said so in the log. if life, err := s.store.AppLife(acct.ID, app); err != nil || (life != nil && life.Temporary()) { log.Printf("console: pipeline %s/%s: not setting production for the bake fallback (temporary app, or unreadable row: %v)", acct.Sqid, app, err) return } flag, err := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) if err != nil { log.Printf("console: pipeline %s/%s: read production flag for bake fallback: %v", acct.Sqid, app, err) return } if flag != nil && flag.Production { return // already true — never re-declared, never downgraded } if err := s.claimAppMutation(ctx, acct, app, "protection", func(ctx context.Context) error { return release.SetProduction(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, true, actor) }); err != nil { log.Printf("console: pipeline %s/%s: set production for bake fallback: %v", acct.Sqid, app, err) return } s.audit(acct, app, actor, "production", "enabled the safety delay (production: on, at pipeline set — old-binary bake fallback)") } // classifySetPipelineError maps a release.SetPipeline error to the status // and caller-facing message this door answers with. SetPipeline's errors // come in three shapes, and only one of them is safe to echo verbatim: // // - release.ErrCASConflict (checked by errors.Is, since SetPipeline // returns it — via Store.Put — unwrapped): a concurrent write won the // race. 409, with a fixed retry sentence — not the caller's fault and // not a defect in what they sent. // - A store or trash-marker READ failure, wrapped with the exact prefixes // SetPipeline's own source uses ("cannot check the trash marker" / // "set pipeline: read "): the bucket, not the request, is the problem. // 502, with a fixed sentence — the real error (bucket names, request // ids) stays in the log, the same discipline every other store-backed // door here follows (e.g. handleAPITrashApp, apiledger.go). // - Everything else prefixed "set pipeline: " is validatePipeline's own // refusal (or one of the pre-checks that cannot fire from this door, // since apiAccountApp already validated the account/app names) — // genuinely the caller's malformed input, safe and useful to echo. 422. // - An error with NO "set pipeline: " prefix at all and not ErrCASConflict // is the one other shape SetPipeline returns as-is: a raw Store.Put // failure. Not the caller's fault either — 502, generic. func classifySetPipelineError(err error) (int, string) { if errors.Is(err, release.ErrCASConflict) { return http.StatusConflict, "concurrent change — re-read and retry" } msg := err.Error() if strings.Contains(msg, "cannot check the trash marker") || strings.Contains(msg, "set pipeline: read ") { return http.StatusBadGateway, "could not verify the current pipeline — try again" } if strings.HasPrefix(msg, "set pipeline: ") { return http.StatusUnprocessableEntity, msg } return http.StatusBadGateway, "could not write the pipeline — try again" } // pipelineAuditDetail names what actually changed, in the house style the // neighbouring audit rows use (server.go's promote/production rows): a // bare "set the pipeline" would leave a reader of the audit trail unable to // tell a one-line typo fix from a whole new ladder without opening the // bucket object themselves — and this row documents a POLICY rewrite, not a // routine write like a config var. // via names the door the write came through — "cli" for a direct PUT, or // what a confirmed proposal was proposed by and how many members signed off. func pipelineAuditDetail(p *release.Pipeline, via string) string { names := make([]string, len(p.Channels)) for i, c := range p.Channels { names[i] = c.Name } suffix := "s" if len(p.Channels) == 1 { suffix = "" } return fmt.Sprintf("set the pipeline (%s; %d channel%s, %s)", strings.Join(names, " → "), len(p.Channels), suffix, via) } // holdWindow is the pipeline-aware form of release.HoldFor (bake.go), for // later tasks (adoption, promote) that need one channel's bake window // without first checking whether the app has opted into a pipeline at all. // A pipelined app's channel carries its OWN window — possibly zero, and for // a name the legacy ladder has never heard of. An app with no pipeline, or a // channel that sits outside a declared one (canary/*, a -hotfix target), // falls back to the legacy default table exactly as it does today. func holdWindow(p *release.Pipeline, channel string) time.Duration { if p != nil { if c, ok := p.ChannelNamed(channel); ok { if d, err := c.BakeWindow(); err == nil { return d } } } return release.HoldFor(release.DefaultHolds, channel) }