package console // The app page's ladder section — the console half of the pipelines spec // (§3 the ladder, §5 guard rails, §7 console surface). // // One screen that answers "what is on each rung, who put it there, and which // hosts are serving it", plus the only promotions the ladder permits: the // step from one rung to the next. It is assembled from what the page already // reads — the app's channel pointers and its registry routes — so it can // disagree with neither. // // One thing it deliberately is not: a second promote path. The controls post // to the app's one promote endpoint, which enforces the ladder and the // passkey gate for everybody, so a hand-written POST meets the same doors as // a button. // // Every route also shows the environment it is wired to (spec §4a) beside // the channel it is listed under — "default" for a route with no binding — // and prodWiredCanaryHosts below answers "production" from that AND from // which host is the app's apex: the environment says what glue a canary is // wired to, the apex says whether it is in front of everybody. // // canaryExpiry answers a different question (spec §5 "Canaries expire"): // not whether a canary is dangerous, but whether it has been sitting there // so long it stopped being a canary and became infrastructure nobody // remembers deploying. Visibility only — an expired canary keeps serving. import ( "fmt" "sort" "strings" "time" "github.com/carlosframework/platform/internal/release" ) // LadderRung is one rung of the app's ladder, with everything sitting on it. type LadderRung struct { // Rung is the rung's name: canary, edge, beta or stable. Rung string // Gate is what it takes to get here, in one line (spec §3's gate table). Gate string // Channels are the channels belonging to this rung — one for edge, beta // and stable; any number for canary, which is the rung with names. Channels []LadderChannel // Up describes the step to the next rung: the promote control, or the // reason there is not one. nil on stable, which is the top. Up *LadderStep } // LadderChannel is one channel on a rung: what it holds and who is serving it. type LadderChannel struct { Channel, Version string PromotedAt time.Time // Actor is who promoted the version this channel holds, read from the // pointer's signed audit trail. "" is the ordinary case for now, not an // error: the trail is new and every pointer written before it has none. Actor string // Hotfix records that this promotion bypassed the ladder. Hotfix bool // FastTrack and BakedUntil carry the channel's waiver and its ~hold // estimate into the rung row, where the fast-track control lives. FastTrack bool BakedUntil time.Time // Err carries a pointer that would not read or verify, and a channel that // hosts follow but that nothing has ever been published to. Both are one // line of bad news on one rung, never a failed page. Err string // Routes are the hosts subscribed to this channel. Routes []LadderRoute } // LadderRoute is one host under the channel it follows. type LadderRoute struct { Host string // Environment is the named config bundle this route is wired to — "" // (rendered "default" in ladder.html) means the app's default bundle // alone. Independent of the channel it is listed under (spec §4a). Environment string // Canary is whether this host follows a canary channel at all. Canary bool // ProdWired is the conspicuous case: a canary bound to the same // environment as one of the app's stable routes, OR sitting on the app's // apex host. See prodWiredCanaryHosts for why it is both. ProdWired bool // Expired is whether this route's canary/* channel has outlived // release.CanaryTTL, measured from the pointer's own PromotedAt (spec // §5 "Canaries expire"). Always false for a non-canary route. Visibility // only: an expired canary keeps serving, keeps adopting and stays // promotable — this badges it, nothing more. Expired bool // ExpiredFor is how long past the TTL the channel is, formatted for a // human ("9d", "6h") — valid only when Expired is true. ExpiredFor string // Caps is the operator-granted capability tokens for this route, human // legible and already formatted by RouteView (view.go's humanizeCaps); // "" renders nothing (app-capabilities design §9's optional console // render — read-only, no new route, no gate touched). Caps string } // LadderStep is the move from one rung to the one above it. type LadderStep struct { // To is the rung above. To string // Version is what the rung below currently holds — the version the // control would promote. "" when the rung below holds nothing, or holds // it on a pointer that will not read, and so there is nothing to offer. Version string // Note is shown instead of a control: why this step is not a promotion, // or why it cannot be offered right now. Note string // StepUp warns that taking this step will ask for a passkey. Display // only — the gate itself is server-side, in the promote handler. StepUp bool } // gates are spec §3's gate table, in the words a person reading the page // needs rather than the words the table uses. var gates = map[string]string{ release.RungCanary: "No sign-off needed — a test version can be pointed at anything you choose, including production.", release.RungEdge: "Builds land here when they merge to main — nothing is promoted onto edge.", release.RungBeta: "Any account member can promote here; it's recorded.", release.RungStable: "An account owner promotes here with their passkey, after the safety delay.", } // assembleLadder folds an app's channels and routes into rungs — the four // frozen ones for a legacy (pipeline-less) app, or the app's own declared // channel order once it has opted into a pipeline (release pipelines v2 // §2, §5). The two shapes are deliberately exclusive: pipeline == nil takes // the legacy path below, BYTE-IDENTICALLY to before this pipeline-awareness // existed — every existing legacy app renders exactly as it always has — // and a non-nil pipeline hands off to assemblePipelineLadder entirely, // which also owns the one-channel "hide the vocabulary" rule (spec §5): it // returns nil for a single-channel pipeline, and the app's pages read that // absence as "show no ladder card at all", not as "empty rungs". // // A channel outside the ladder's vocabulary (and any host following one) is // not on the ladder and does not appear here — the Channels table below still // lists it. A host following a rung channel that has no pointer DOES appear, // as an error line: a host serving nothing is exactly the kind of thing this // section exists to stop being invisible. func assembleLadder(channels []ChannelView, routes []RouteView, pipeline *release.Pipeline, now time.Time) []LadderRung { if pipeline != nil { return assemblePipelineLadder(pipeline, channels, routes) } prod := prodWiredCanaryHosts(routes) expiredFor := canaryExpiry(channels, now) byChannel := map[string][]LadderRoute{} for _, rt := range routes { rung, ok := release.Rung(rt.Channel) age := expiredFor[rt.Channel] byChannel[rt.Channel] = append(byChannel[rt.Channel], LadderRoute{ Host: rt.Host, Environment: rt.Environment, Canary: ok && rung == release.RungCanary, ProdWired: prod[rt.Host], Expired: age != "", ExpiredFor: age, Caps: rt.Caps, }) } entries := map[string][]LadderChannel{} for _, cv := range channels { rung, ok := release.Rung(cv.Name) if !ok { continue } entries[rung] = append(entries[rung], LadderChannel{ Channel: cv.Name, Version: cv.Version, PromotedAt: cv.PromotedAt, Actor: cv.Actor, Hotfix: cv.Hotfix, Err: cv.Err, FastTrack: cv.FastTrack, BakedUntil: cv.BakedUntil, Routes: byChannel[cv.Name], }) delete(byChannel, cv.Name) } // Whatever is left is a host following a channel with no pointer behind // it. Only rung channels are shown, for the reason in the doc comment. for ch, rts := range byChannel { rung, ok := release.Rung(ch) if !ok { continue } entries[rung] = append(entries[rung], LadderChannel{ Channel: ch, Routes: rts, Err: "nothing has been published here yet — these addresses are waiting for a version", }) } out := make([]LadderRung, 0, 4) ladder := []string{release.RungCanary, release.RungEdge, release.RungBeta, release.RungStable} for i, rung := range ladder { chans := entries[rung] // Map iteration ordered nothing above; canary is the rung that can // hold several, and a list that reshuffles per request is unreadable. sort.Slice(chans, func(a, b int) bool { return chans[a].Channel < chans[b].Channel }) lr := LadderRung{Rung: rung, Gate: gates[rung], Channels: chans} if i+1 < len(ladder) { lr.Up = stepUp(rung, ladder[i+1], chans) } out = append(out, lr) } return out } // stepUp describes the move from one rung to the next: a control when the // ladder permits one and there is something to promote, otherwise the reason. // The rule it encodes is release.PromotionAllowed's, in the direction the page // draws — nothing is promoted onto edge, and the two real steps are edge→beta // and beta→stable. func stepUp(from, to string, chans []LadderChannel) *LadderStep { s := &LadderStep{To: to, StepUp: to == release.RungStable} if to == release.RungEdge { s.Note = "Nothing is promoted onto edge — merging to main is what puts a build there." return s } // edge and beta hold exactly one channel, named for the rung. for _, c := range chans { if c.Err == "" && c.Version != "" { s.Version = c.Version break } } if s.Version == "" { s.Note = "Nothing on " + from + " to promote." } return s } // assemblePipelineLadder is assembleLadder's pipeline-shaped half: one rung // per declared channel, in the pipeline's own order, rather than the four // frozen rungs. canary/* is deliberately absent here — it lives outside the // pipeline (spec §2) and stays visible through the Canaries table the app // page already draws unconditionally, independent of Ladder. // // A single-channel pipeline returns nil: spec §5's "hide the vocabulary // entirely" rule. The app's pages read a nil (or one-channel) pipeline's // AppView.OneChannelPipeline() and skip the ladder card, the promote form // and the channel table outright — this function simply never hands them // anything to draw in that case. func assemblePipelineLadder(p *release.Pipeline, channels []ChannelView, routes []RouteView) []LadderRung { if len(p.Channels) < 2 { return nil } byChannel := map[string]ChannelView{} for _, cv := range channels { byChannel[cv.Name] = cv } prod := prodWiredCanaryHosts(routes) byRoute := map[string][]LadderRoute{} for _, rt := range routes { byRoute[rt.Channel] = append(byRoute[rt.Channel], LadderRoute{ Host: rt.Host, Environment: rt.Environment, ProdWired: prod[rt.Host], Caps: rt.Caps, }) } out := make([]LadderRung, 0, len(p.Channels)) for i, c := range p.Channels { var chans []LadderChannel if cv, ok := byChannel[c.Name]; ok { // BakedUntil is copied verbatim from cv, exactly as the legacy // branch above does — it is NOT recomputed here. The caller // (applyPipelineView, server.go) is the one place a pipelined // app's Channels get their BakedUntil corrected to THIS // channel's own window (holdWindow) in place of the legacy // per-app table, precisely so this card and the Channels table // reading the same Channels slice can never disagree (Task 9 // review) — recomputing it again here would be a second // implementation of the same answer, free to drift from the // first. chans = []LadderChannel{{ Channel: cv.Name, Version: cv.Version, PromotedAt: cv.PromotedAt, Actor: cv.Actor, Hotfix: cv.Hotfix, Err: cv.Err, FastTrack: cv.FastTrack, BakedUntil: cv.BakedUntil, Routes: byRoute[c.Name], }} } else if rts := byRoute[c.Name]; len(rts) > 0 { chans = []LadderChannel{{Channel: c.Name, Routes: rts, Err: "nothing has been published here yet — these addresses are waiting for a version"}} } rung := LadderRung{Rung: c.Name, Gate: gateSentence(c.Rules), Channels: chans} if i+1 < len(p.Channels) { rung.Up = pipelineStepUp(c, p.Channels[i+1], chans) } out = append(out, rung) } return out } // gateSentence is gates' pipelined analogue: what THIS channel's own rules // demand, in the same one-line register the frozen map speaks (spec §5's // UX list, in the order it gives them). The zero value — no rule at all — // is "Anyone on the team promotes here.", spec §2's default-permissive // posture made legible rather than left blank. func gateSentence(rules release.ChannelRules) string { var parts []string if rules.Passkey { parts = append(parts, "Needs a passkey.") } if n := rules.PromoteApprovals; n > 0 { member := "members" if n == 1 { member = "member" } parts = append(parts, fmt.Sprintf("Needs %d %s to confirm.", n, member)) } if len(parts) == 0 { return "Anyone on the team promotes here." } return strings.Join(parts, " ") } // pipelineStepUp is stepUp's pipelined analogue: the move from one declared // channel to the next one in the list, rather than to a frozen rung name. // There is no "nothing is promoted onto edge" special case here — a // pipeline's entry channel simply has no Up step at all (assemblePipelineLadder // only builds one for i+1 < len(Channels)), so every Up this function builds // describes a genuine promotion. func pipelineStepUp(from, to release.PipelineChannel, curChans []LadderChannel) *LadderStep { s := &LadderStep{To: to.Name, StepUp: to.Rules.Passkey} if len(curChans) == 1 && curChans[0].Err == "" && curChans[0].Version != "" { s.Version = curChans[0].Version return s } s.Note = "Nothing on " + from.Name + " to promote." return s } // apexHost picks the app's primary host — fewest labels, then shortest, // then alphabetical — the same "which host is the apex" answer // prodWiredCanaryHosts uses. "" when there are no named routes. func apexHost(routes []RouteView) string { apex := "" better := func(a, b string) bool { la, lb := strings.Count(a, "."), strings.Count(b, ".") if la != lb { return la < lb } if len(a) != len(b) { return len(a) < len(b) } return a < b } for _, rt := range routes { if rt.Host == "" { continue } if apex == "" || better(rt.Host, apex) { apex = rt.Host } } return apex } // prodWiredCanaryHosts is spec §4a's honest definition of "prod-wired", // replacing the apex-host guess this function used before named // environments existed (2026-08-02-pipelines-design.md §4a, last // paragraph): a canary/* route is prod-wired when it binds the SAME // ENVIRONMENT as the app's stable route — an unbound route binds "" (the // default bundle), so two unbound routes count as matching, exactly as the // spec says "or both bind default". // // An app can have more than one stable-channel route (e.g. two hosts both // following `stable`); this treats stable's set of bound environments as // "production", so a canary matching ANY of them is flagged. An app with no // stable route at all has no production environment to be wired in front of, // so that signal flags nothing — the correct answer, not a gap. // // The apex-host heuristic this replaced is KEPT as a second signal, OR'd in // (2026-08-02 review, IMPORTANT 5). The two answer different halves of §5's // worry and neither is a superset of the other: the environment set says // "wired to production GLUE", the apex host says "in front of production // TRAFFIC". A canary on the app's apex bound to `sandbox` serves everybody // and binds nothing production — the environment rule alone would say // nothing about it, which is a warning the console used to give and would // have silently stopped giving. Production is the app's PRIMARY host: fewest // labels, then shortest, then alphabetical. // // It flags, it never restricts: callers only ever ask "is this host a // prod-wired canary", and both signals are allowed to be wrong in the // flagging direction. More flags, never fewer, is the conservative side of // this particular trade. func prodWiredCanaryHosts(routes []RouteView) map[string]bool { stableEnvs := map[string]bool{} for _, rt := range routes { if rung, ok := release.Rung(rt.Channel); ok && rung == release.RungStable { stableEnvs[rt.Environment] = true } } apex := apexHost(routes) out := map[string]bool{} for _, rt := range routes { rung, ok := release.Rung(rt.Channel) if !ok || rung != release.RungCanary { continue } if stableEnvs[rt.Environment] || rt.Host == apex { out[rt.Host] = true } } return out } // canaryExpiry maps each canary/* channel with a readable pointer to how // long past release.CanaryTTL it is, formatted for a human — spec §5 // "Canaries expire". A channel that has not (yet) expired, or whose pointer // carries an Err (assembleChannels degraded it to one bad row already), is // simply absent from the map, which is exactly what LadderRoute.Expired // reads as false. // // Built once per ladder assembly, keyed by channel name rather than host, // because expiry is a property of the CHANNEL's PromotedAt — every route // following the same canary shares one answer, computed once rather than // re-derived per host. // // Formatting goes through release.CanaryAge + release.FormatCanaryAge — // the SAME pair the adopt pass's nag calls (internal/release/adopt.go) — // rather than computing a duration here and rendering it locally. That used // to be two independent implementations that quietly disagreed by exactly // CanaryTTL (2026-08-03 review, IMPORTANT 2): sharing the formatter is what // makes "the nag and the badge cannot drift apart" a structural fact // instead of a coincidence two people have to keep re-verifying. func canaryExpiry(channels []ChannelView, now time.Time) map[string]string { out := map[string]string{} for _, cv := range channels { if cv.Err != "" { continue } // A synthetic pointer carrying only the two fields release.CanaryExpired // reads: the channel's name (so it recognizes canary/*) and the // promotion timestamp ChannelView already carries. No pointer re-read — // expiry is computed from data the console already has in hand. ptr := &release.ChannelPointer{Channel: cv.Name, PromotedAt: cv.PromotedAt} if !release.CanaryExpired(ptr, now) { continue } out[cv.Name] = release.FormatCanaryAge(release.CanaryAge(ptr, now)) } return out }