// The doors onto the pending-change primitive (pending.go): proposing a // gated action, confirming one until its quorum is met, rejecting one, and — // the only place any of it actually writes to the bucket — executing it. // Release pipelines v2 §4/§5 // (docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md). // // Three shapes live here, and the difference between them is the whole // security story: // // - proposeOverAPI is the bearer-token shape. It starts at ZERO // confirmations however many members the quorum names, because a token // has proved nobody is present (spec §4). Its answer is 202 and a console // URL: "finish where the passkeys are". // - proposeThenExecute is the browser shape, and is only ever called from // BEHIND requireFreshAuth. That step-up is what earns the proposer their // own first confirmation — and when the quorum is 1, the change is // already met on creation and is carried out in the same request, exactly // as a passkey rule would have been. // - executePending is where a quorum-met change becomes a signed write. It // re-checks the world against the payload the proposal PINNED, so a // pipeline or a channel head that moved underneath a week-old proposal // invalidates it instead of landing something nobody approved. A failed // execution leaves the change unexecuted: it is retryable, never consumed. package console import ( "context" "crypto/subtle" "encoding/json" "errors" "fmt" "log" "net/http" "net/url" "os" "strings" "time" "github.com/carlosframework/platform/internal/release" ) // The three kinds of gated action, spelled once. They are stored in the // database, so they are a wire shape in the sense that matters here: a row // written before a rename must still be executable after it. const ( kindPromote = "promote" kindPolicy = "policy" kindFastTrack = "fasttrack" // kindRouteChannel moves one ROUTE between channels — not a release // anywhere, which is why it is its own kind rather than a promote with a // different payload: nothing is published, and what changes is which // pointer one host follows. kindRouteChannel = "route-channel" ) // pendingTimeout bounds the store round trips an execution makes — the same // budget the promote and pipeline doors give the bucket. const pendingTimeout = 15 * time.Second // promotePayload pins a promotion: this VERSION onto this CHANNEL, never // "whatever the head is when somebody gets around to confirming" (spec §4). // Hotfix rides along because it changes what execution is allowed to // re-check — order, and only order. type promotePayload struct { Version string `json:"version"` Channel string `json:"channel"` Hotfix bool `json:"hotfix,omitempty"` // Rollback marks a proposal that came from the rollback door rather than // promote (server.go's browser handleRollback, apicli.go's API rollback // door) — additive, so a row written before this field existed still // parses and simply renders as a promotion, exactly as it did before // (Task 8 review: pendingSummary said "promote vX to Y" for a rollback, // which is illegible to a confirmer who never asked for vX). Display // only for what lands: both doors already pin the OLDER version as a // promotion with Hotfix set; execution reads it solely to voice the // audit row and the flash as the rollback the confirmer approved. Rollback bool `json:"rollback,omitempty"` // ObservedHead is the target channel's version at PROPOSE time — spec // §4's other pin: "a head that moved underneath a proposal invalidates // it". Execution refuses when the channel no longer serves it. Additive: // "" (an old row, or a channel with no pointer yet at propose time) pins // nothing and keeps the pre-field behaviour. ObservedHead string `json:"observed_head,omitempty"` // Retry is `carlos promote --retry` (D1 of the console release // defences): renew the channel's current promotion of Version with a // fresh PromotedAt, never change the version. Execution passes it to // PromoteOnLadder, whose retry rules (renew only, version guard at the // write) then apply at the moment the write happens. Additive: an old // row has none and is an ordinary promotion. Retry bool `json:"retry,omitempty"` } // policyPayload pins a pipeline edit: the full proposed channel list, plus // the SetAt of the pipeline it was composed against. Execution refuses on a // mismatch — a stale channel list applied blind would silently revert // whatever landed in between. type policyPayload struct { Channels []release.PipelineChannel `json:"channels"` ObservedSetAt time.Time `json:"observed_set_at"` } // fastTrackPayload pins a waiver to the version it was proposed for. // release.FastTrack acts on the channel's CURRENT head by contract, so // execution compares that head with this pin first; without the comparison // the pin would be decoration and a confirmation would waive a build nobody // looked at. type fastTrackPayload struct { Channel string `json:"channel"` Version string `json:"version"` } // appConsoleURL is where a refusal or a proposal points a CLI: the app page, // which is where the passkeys are. One spelling, because these strings are // compared byte-for-byte by tests that exist to prove a member and an owner // get the same refusal. func (s *Server) appConsoleURL(acct *Account, app string) string { return s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) } // appPath is appConsoleURL's origin-relative form, for the redirects a // browser door ends on. func appPath(acct *Account, app string) string { return "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) } // channelRules is the policy a target channel carries. An app with no // pipeline, and a channel outside a declared one (canary/*, a -hotfix // target), carry none — which is spec §2's default-permissive rule, not a // gap: the legacy ladder's own gates still apply to a pipeline-less app, and // they are enforced where they always were. func channelRules(p *release.Pipeline, channel string) release.ChannelRules { if p == nil { return release.ChannelRules{} } if c, ok := p.ChannelNamed(channel); ok { return c.Rules } return release.ChannelRules{} } // pendingSummary is one line naming what a change would do — for the CLI's // list, the console's card, and the audit trail. It reads the PINNED payload // and nothing else, so it describes what would land rather than what the // world currently looks like. func pendingSummary(pc PendingChange) string { switch pc.Kind { case kindPromote: var p promotePayload if err := json.Unmarshal(pc.Payload, &p); err != nil { return "promote " + pc.App } if p.Rollback { // "roll back Y to vX", not "promote vX to Y" — the same pin, read // the way a confirmer who never asked for vX can actually judge // it (Task 8 review): a rollback proposal pins the OLDER version // as a promotion (Hotfix, order bypassed). What lands is // unchanged by the flag; execution reads it only to voice the // audit row and the flash in this same rollback wording // (executePendingPromote). return "roll back " + p.Channel + " to " + p.Version } out := "promote " + p.Version + " to " + p.Channel if p.Hotfix { out += " (hotfix — out of order)" } return out case kindPolicy: var p policyPayload if err := json.Unmarshal(pc.Payload, &p); err != nil { return "change the pipeline of " + pc.App } names := make([]string, len(p.Channels)) for i, c := range p.Channels { names[i] = c.Name } return "change the pipeline to " + strings.Join(names, " → ") case kindFastTrack: var p fastTrackPayload if err := json.Unmarshal(pc.Payload, &p); err != nil { return "skip the safety delay on " + pc.App } return "skip the safety delay for " + p.Version + " on " + p.Channel case kindRouteChannel: var p channelMovePayload if err := json.Unmarshal(pc.Payload, &p); err != nil { return "move a route of " + pc.App } // Both ends, because a confirmer judging this needs to know what the // URL serves NOW as much as what it would serve after. return "move " + p.Host + " from " + p.From + " to " + p.To } return pc.Kind + " on " + pc.App } // ---- proposing --------------------------------------------------------------- // proposeOverAPI records a bearer-token proposal and answers 202. Zero // confirmations, always: spec §4's load-bearing rule is that a token never // counts toward a passkey quorum, including for a quorum of one and // including when the same person could have confirmed it in a browser. func (s *Server) proposeOverAPI(w http.ResponseWriter, acct *Account, app, kind string, payload any, quorum int, proposer string) { raw, err := json.Marshal(payload) if err != nil { log.Printf("console: propose %s %s/%s: marshal: %v", kind, acct.Sqid, app, err) jsonError(w, http.StatusInternalServerError, "could not record the proposal — try again") return } pc, err := s.store.ProposeChange(acct.ID, app, kind, raw, quorum, proposer, false, time.Now()) if err != nil { log.Printf("console: propose %s %s/%s: %v", kind, acct.Sqid, app, err) jsonError(w, http.StatusInternalServerError, "could not record the proposal — try again") return } s.audit(acct, app, proposer, "proposed", pendingSummary(*pc)+ fmt.Sprintf(" (needs %d confirmation(s) in the console)", quorum)) writeJSON(w, http.StatusAccepted, map[string]any{ "result": "proposed", "pending_id": pc.ID, "needed": pc.Quorum, "confirmed": len(pc.Confirms), "console_url": s.appConsoleURL(acct, app), }) } // proposeThenExecute is the browser half. CALL IT ONLY FROM BEHIND // requireFreshAuth: proposerConfirmed=true is a claim that this proposal is // passkey-backed, and it is the difference between a quorum of humans and a // quorum of one person's session cookie. // // A quorum of 1 is met the moment the row exists, and the change is carried // out in the same request — with the proposer's presence just proved, there // is nobody left to ask, and parking it for a second confirmation nobody owes // would leave a "pending" card that only its own author can clear. // // Returns the flash kind and message the caller redirects with. func (s *Server) proposeThenExecute(ctx context.Context, acct *Account, app, kind string, payload any, quorum int, proposer string) (string, string) { raw, err := json.Marshal(payload) if err != nil { log.Printf("console: propose %s %s/%s: marshal: %v", kind, acct.Sqid, app, err) return "err", "Could not record that proposal. Please try again." } pc, err := s.store.ProposeChangeContext(ctx, acct.ID, app, kind, raw, quorum, proposer, true, time.Now()) if err != nil { log.Printf("console: propose %s %s/%s: %v", kind, acct.Sqid, app, err) return "err", "Could not record that proposal. Please try again." } if len(pc.Confirms) < pc.Quorum { s.audit(acct, app, proposer, "proposed", fmt.Sprintf("%s (%d of %d confirmations)", pendingSummary(*pc), len(pc.Confirms), pc.Quorum)) return "ok", fmt.Sprintf("Proposed: %s. %d of %d confirmations — it happens when the rest confirm it here with their passkeys.", pendingSummary(*pc), len(pc.Confirms), pc.Quorum) } msg, err := s.executePending(ctx, acct, app, proposer, pc) if err != nil { return "err", "Could not carry that out: " + err.Error() } return "ok", msg } // channelHead is the target channel's current version — read at a propose // site to pin promotePayload.ObservedHead, and again at execution to check // the pin. "" with a nil error means the channel has no pointer yet; any // real read/verify failure is the error, never folded into "". func (s *Server) channelHead(ctx context.Context, acct *Account, app, channel string) (string, error) { cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel) if errors.Is(err, os.ErrNotExist) { return "", nil } if err != nil { return "", err } return cur.Version, nil } // policyProposal builds a policy payload against the pipeline it was composed // from. cur is nil for an app that has never had one, whose zero SetAt is // itself a legitimate pin: "there was nothing here when I proposed this". func policyProposal(cur *release.Pipeline, next []release.PipelineChannel) policyPayload { p := policyPayload{Channels: next} if cur != nil { p.ObservedSetAt = cur.SetAt } return p } // ---- executing --------------------------------------------------------------- // executePending carries out a change whose quorum is met — the ONE place a // gated write happens. Per kind: re-check the pin, write, announce, audit, // and only then mark the change executed. // // An error return means nothing landed and the change is still pending: a // caller must not mark it executed, and the member who confirmed it is told // why rather than left believing it went through. // // The first checks are this function's OWN preconditions, restated rather // than assumed. Every caller today establishes them — quorum via // ConfirmChange's `met`, the app via pendingScoped's 404 — but this is billed // as the one place a gated write happens, and a future caller (the console's // own pipeline page, Task 9) that got either wrong would otherwise turn a // mistake into an unapproved signed write. They are errors, never panics: a // bug here must refuse, not take the console down. // // The quorum precondition is not a restatement, though: ConfirmChange counts // ROWS against the number STORED on the proposal, and both of those can be // stale by the time anyone confirms. This is where the CURRENT rule and the // CURRENT membership are what the change has to meet — see the comment on the // check itself. func (s *Server) executePending(ctx context.Context, acct *Account, app, actor string, pc *PendingChange) (string, error) { if pc.AccountID != acct.ID || pc.App != app { log.Printf("console: execute %s %s: change belongs to %d/%q, not %d/%q", pc.Kind, pc.ID, pc.AccountID, pc.App, acct.ID, app) return "", errors.New("that change belongs to a different app") } // The quorum this change must meet NOW, not merely the one it was // proposed under. A proposal can wait a week (PendingChangeTTL), and both // halves of "N distinct members" can move underneath it: // // - the RULE can be tightened. Executing at the stored number would make // a stale proposal the way past a raised rule — propose at 2, wait for // somebody to raise it to 3, land it with 2. Required is therefore // max(stored, current). Loosening never lowers the stored number: the // confirmers agreed to something that needed N of them, and one person // lowering the rule afterwards is not the others' confirmation. // - the MEMBERSHIP can change. A confirmation is standing authority, and // an address that has left the account has none, so its row stops // counting (MemberConfirms) while staying on the record. // // Both are refusals, never consumptions: the change stays unexecuted, // listed and retryable, and the remaining members can still meet it. required, err := s.currentQuorum(ctx, acct, app, pc) if err != nil { return "", err } counted, err := s.store.MemberConfirms(pc.AccountID, pc.ID) if err != nil { log.Printf("console: execute %s %s: member confirms: %v", pc.Kind, pc.ID, err) return "", errors.New("could not check who has confirmed this change") } if len(counted) < required { log.Printf("console: execute %s %s: %d of %d confirmations counted (%d rows, quorum stored %d)", pc.Kind, pc.ID, len(counted), required, len(pc.Confirms), pc.Quorum) return "", fmt.Errorf("that change has %d of %d confirmations", len(counted), required) } // From the gate onwards, this change's confirmations ARE the counted // ones: every audit row below reports "%d confirmations" from here, and a // trail that credited a departed member's row would record a quorum that // was never met (PR #258 review, I3). The rows themselves are untouched // in the database — this narrows only what the EXECUTION claims it had. pc.Confirms = counted if s.cfg.Key == nil { return "", errors.New("no signing key on this host — the console is running read-only") } var msg string switch pc.Kind { case kindPromote: msg, err = s.executePendingPromote(ctx, acct, app, actor, pc) case kindPolicy: msg, err = s.executePendingPolicy(ctx, acct, app, actor, pc) case kindFastTrack: msg, err = s.executePendingFastTrack(ctx, acct, app, actor, pc) case kindRouteChannel: msg, err = s.executePendingRouteChannel(ctx, acct, app, actor, pc) default: return "", fmt.Errorf("this console does not know how to carry out a %q change", pc.Kind) } if err != nil { return "", err } if err := s.store.MarkExecutedContext(ctx, pc.AccountID, pc.App, pc.ID, time.Now()); err != nil { // The write above already landed. Failing the caller here would say // "that didn't work" about something that did; the cost of the miss // is a change that stays listed, and every kind's own pin refuses a // second execution of the same payload. log.Printf("console: mark pending change %s executed: %v", pc.ID, err) } return msg, nil } // currentQuorum is how many confirmations this change must have RIGHT NOW: // the number it was proposed under, raised to the target channel's current // rule if that rule has since been tightened. Never lowered — see // executePending's comment for why a loosening is not a confirmation. // // It costs one pipeline read that the promote path then makes again a moment // later. That duplication is deliberate: this is the gate, and a gate that // depends on a value threaded down through three kind-specific functions is a // gate that a fourth kind can be added without. A gated execution is a rare, // human-paced action; the read is not a cost worth trading the shape for. // // Every kind pays the read, INCLUDING a policy edit — the pipeline is fetched // before the kind is looked at, so an unreadable one fails the execution // closed whatever it is. What a policy edit does not do is raise its number // from a channel rule: its quorum comes from the channels its own edit // TOUCHES (guardedEdit), and executePendingPolicy refuses outright unless the // stored pipeline is byte-for-byte the one the proposal was composed against // (the SetAt pin). A rule that moved at all invalidates the proposal before // any quorum question is asked, so for that kind requiredQuorum returns the // stored number and max(stored, current) could only ever equal it. func (s *Server) currentQuorum(ctx context.Context, acct *Account, app string, pc *PendingChange) (int, error) { pipe, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) if err != nil { // Fail closed, like every other unreadable-pipeline path: a proposal // whose governing rules cannot be read is not executable. log.Printf("console: execute %s %s/%s: read pipeline: %v", pc.Kind, acct.Sqid, app, err) return 0, errors.New("could not read the pipeline") } return requiredQuorum(pc, pipe) } // requiredQuorum is currentQuorum's pure half: max(the number stored on the // proposal, the target channel's rule in pipe). Shared with the DISPLAY // surfaces (pendingChangeViews, handleAPIPending) so a card can never promise // a quorum execution will not accept — one rule, one place. // // A nil pipe (legacy app, or a display caller whose best-effort read failed) // carries no rules, so the answer is the stored quorum: the pre-existing // behaviour, and the safe direction for a display. func requiredQuorum(pc *PendingChange, pipe *release.Pipeline) (int, error) { var channel string var rule func(release.ChannelRules) int switch pc.Kind { case kindPromote: var p promotePayload if err := json.Unmarshal(pc.Payload, &p); err != nil { return 0, errors.New("this proposal's payload cannot be read — reject it and propose again") } channel, rule = p.Channel, func(r release.ChannelRules) int { return r.PromoteApprovals } case kindFastTrack: var p fastTrackPayload if err := json.Unmarshal(pc.Payload, &p); err != nil { return 0, errors.New("this proposal's payload cannot be read — reject it and propose again") } // A fast-track is gated by change_approvals, not promote_approvals // (spec §2: one field covers editing a channel's entry AND overriding // its bake), so it must be re-checked against that same field. channel, rule = p.Channel, func(r release.ChannelRules) int { return r.ChangeApprovals } default: return pc.Quorum, nil } if n := rule(channelRules(pipe, channel)); n > pc.Quorum { return n, nil } return pc.Quorum, nil } // pendingTally is the two numbers every SURFACE shows for one pending change // — the card, the CLI list, the confirm flash and the audit row — computed // the way execution counts them: confirmations from current members, against // max(stored quorum, current rule). Best-effort by construction: a failed // membership read or an unreadable payload degrades to the raw row rather // than costing the page, and execution re-checks both for real anyway. // // The gap between this and len(pc.Confirms) is deliberately not hidden: a // change stuck because someone left the account shows fewer confirmations // than it has rows, which is the honest explanation for why it is stuck. func (s *Server) pendingTally(pc PendingChange, pipe *release.Pipeline) (confirmed, needed int) { confirmed, needed = len(pc.Confirms), pc.Quorum if counted, err := s.store.MemberConfirms(pc.AccountID, pc.ID); err != nil { log.Printf("console: pending tally %s: member confirms: %v", pc.ID, err) } else { confirmed = len(counted) } if n, err := requiredQuorum(&pc, pipe); err == nil && n > needed { needed = n } return confirmed, needed } // pendingPipeline is the display surfaces' best-effort pipeline read: one per // listing, never fatal. A WRITE door reads its own copy and refuses on error // (currentQuorum); a card that cannot read the pipeline still renders. func (s *Server) pendingPipeline(ctx context.Context, acct *Account, app string) *release.Pipeline { pipe, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) if err != nil { log.Printf("console: pending tally %s/%s: read pipeline: %v", acct.Sqid, app, err) return nil } return pipe } // staleProposal is the one refusal every kind shares: the world the proposal // pinned is not the world any more. func staleProposal(detail string) error { if detail == "" { return errors.New("the pipeline moved underneath this proposal — re-propose") } return errors.New("the pipeline moved underneath this proposal — re-propose (" + detail + ")") } func (s *Server) executePendingPromote(ctx context.Context, acct *Account, app, actor string, pc *PendingChange) (string, error) { var p promotePayload if err := json.Unmarshal(pc.Payload, &p); err != nil { return "", errors.New("this proposal's payload cannot be read — reject it and propose again") } pipe, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) if err != nil { log.Printf("console: execute promote %s/%s: read pipeline: %v", acct.Sqid, app, err) return "", errors.New("could not read the pipeline") } // The nil guard is not defensive tidiness: VersionStage dereferences the // pipeline it is handed, and an app whose pipeline was deleted while a // proposal waited has no order left for that proposal to be judged // against. Refuse, rather than fall back to a ladder nobody approved. if pipe == nil { return "", staleProposal("this app no longer has a pipeline") } // The head pin (spec §4: "a head that moved underneath a proposal // invalidates it"), checked for EVERY promote-kind proposal — hotfixes // and rollbacks bypass the order re-check below, never this one. On a // mismatch the proposal can never succeed, but it is refused rather than // consumed — surfaced exactly as the policy pin's SetAt mismatch is: an // error return, nothing written, the change still listed until someone // rejects or re-proposes. An empty pin (an old row, or a channel that // had no pointer at propose time) keeps the pre-field behaviour. // // A retry is pinned harder than this, by PromoteOnLadder itself: its // version guard requires the head to be Version on the very pointer the // write is conditional on (and the propose door only lets a retry out // with head == Version). It is left to that guard so a moved channel // fails with the retry's own refusal, "renews a promotion". if p.ObservedHead != "" && !p.Retry { head, herr := s.channelHead(ctx, acct, app, p.Channel) if herr != nil { log.Printf("console: execute promote %s/%s: read channel %s: %v", acct.Sqid, app, p.Channel, herr) return "", errors.New("could not read channel " + p.Channel) } if head != p.ObservedHead { return "", errors.New("the channel moved underneath this proposal — re-propose (" + p.Channel + " no longer serves " + p.ObservedHead + ")") } } // No order re-check for a retry: it moves nothing along the pipeline, // the same reason PromoteOnLadder's repeat path asks no order question. if !p.Hotfix && !p.Retry { stage, serr := release.VersionStage(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, p.Version, pipe) if serr != nil { log.Printf("console: execute promote %s/%s: stage: %v", acct.Sqid, app, serr) return "", errors.New("could not work out where this build sits in the pipeline") } if aerr := release.PipelinePromotionAllowed(pipe, stage, p.Channel); aerr != nil { return "", staleProposal(aerr.Error()) } } // A daemon release waiting in a proposal meets the plan as it stands at // execution, not at propose time (Astra plans-3b finding 2). Rollbacks // too: the target need be on no channel now (round 2 finding 2). A retry // only renews the version the channel already serves. // With plans off this reads nothing (checkDaemonVersion). if !p.Retry { if cerr := s.checkDaemonVersion(ctx, acct, app, p.Version); cerr != nil { var planCap *planCapError if errors.As(cerr, &planCap) { return "", errors.New(planCap.Browser()) } log.Printf("console: execute promote %s/%s: plan: %v", acct.Sqid, app, cerr) return "", errors.New(planCheckFailedCopy) } } res, err := release.PromoteOnLadder(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, p.Channel, p.Version, release.PromoteOptions{Actor: actor, Hotfix: p.Hotfix, Retry: p.Retry}) if err != nil { log.Printf("console: execute promote %s %s -> %s: %v", app, p.Version, p.Channel, err) return "", err } if res.NoOp { // Nothing moved, so nothing is announced and nothing is audited — // the same silence the promote doors keep on a repeat. return p.Channel + " already serves " + p.Version + tagSuffix(res) + " — nothing to do.", nil } s.bumpSerial(ctx) if p.Rollback { // The card said "roll back" (pendingSummary); the trail and the // flash must say what the confirmer approved, in the house voice the // direct rollback door already uses — never "promoted vX" for a // build nobody asked to promote. s.auditActivity(acct, app, actor, Activity{Action: "rollback", TargetType: "channel", Target: p.Channel, Detail: fmt.Sprintf("rolled back %s to %s (proposed by %s, %d confirmations)", p.Channel, p.Version, pc.Proposer, len(pc.Confirms))}) return "Rolled back " + p.Channel + " to " + p.Version + tagSuffix(res) + ".", nil } s.auditActivity(acct, app, actor, Activity{Action: "promote", TargetType: "channel", Target: p.Channel, Detail: fmt.Sprintf("promoted %s to %s (proposed by %s, %d confirmations)", p.Version, p.Channel, pc.Proposer, len(pc.Confirms))}) return "Promoted " + p.Version + " to " + p.Channel + tagSuffix(res) + ".", nil } func (s *Server) executePendingPolicy(ctx context.Context, acct *Account, app, actor string, pc *PendingChange) (string, error) { var p policyPayload if err := json.Unmarshal(pc.Payload, &p); err != nil { return "", errors.New("this proposal's payload cannot be read — reject it and propose again") } cur, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) if err != nil { log.Printf("console: execute policy %s/%s: read pipeline: %v", acct.Sqid, app, err) return "", errors.New("could not read the current pipeline") } var setAt time.Time if cur != nil { setAt = cur.SetAt } // The pin (spec §4). A policy edit is a whole-list replacement, so a // pipeline that moved since the proposal was composed would be silently // reverted by landing this list — including, potentially, a tightening // somebody else made in between. if !setAt.Equal(p.ObservedSetAt) { return "", staleProposal("it was proposed against an older pipeline") } set, err := release.SetPipeline(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, p.Channels, actor) if err != nil { status, msg := classifySetPipelineError(err) if status != http.StatusUnprocessableEntity { log.Printf("console: execute policy %s/%s: set: %v", acct.Sqid, app, err) } return "", errors.New(msg) } s.ensureProductionForBakes(ctx, acct, app, set, actor) s.bumpSerial(ctx) s.audit(acct, app, actor, "pipeline", pipelineAuditDetail(set, fmt.Sprintf("proposed by %s, %d confirmations", pc.Proposer, len(pc.Confirms)))) return "Pipeline updated.", nil } func (s *Server) executePendingFastTrack(ctx context.Context, acct *Account, app, actor string, pc *PendingChange) (string, error) { var p fastTrackPayload if err := json.Unmarshal(pc.Payload, &p); err != nil { return "", errors.New("this proposal's payload cannot be read — reject it and propose again") } cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, p.Channel) if err != nil { log.Printf("console: execute fast-track %s/%s: read channel: %v", acct.Sqid, app, err) return "", errors.New("could not read channel " + p.Channel) } // FastTrack waives the CURRENT head. What was approved is a specific // build, so a head that moved since invalidates the waiver rather than // transferring it to whatever arrived. if cur.Version != p.Version { return "", errors.New("channel " + p.Channel + " now serves " + cur.Version + ", not the " + p.Version + " this was proposed for — re-propose") } ver, err := release.FastTrack(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, p.Channel, actor) if err != nil { log.Printf("console: execute fast-track %s %s: %v", app, p.Channel, err) return "", err } s.bumpSerial(ctx) s.audit(acct, app, actor, "fast-track", fmt.Sprintf( "skipped the safety delay for %s on %s (proposed by %s, %d confirmations)", ver, p.Channel, pc.Proposer, len(pc.Confirms))) return "Skipped the delay for " + ver + " on " + p.Channel + " — it rolls out within a couple of minutes.", nil } // ---- the browser doors ------------------------------------------------------- // pendingScoped resolves the change this request names, and refuses with the // byte-identical 404 for anything that is not a live change of THIS account's // THIS app. That single refusal covers an id that never existed, one that // belongs to another account, one that expired and one that already ran — // Store.PendingChange treats the last two as absence, and the account/app // comparison here is the whole of the authorization scoping, because // ConfirmChange takes no account id and would happily confirm a stranger's // change for anyone who learned its id. func (s *Server) pendingScoped(w http.ResponseWriter, r *http.Request, acct *Account, app string) *PendingChange { id := r.PathValue("id") pc, err := s.store.PendingChange(id, time.Now()) if err != nil { log.Printf("console: pending change %s: %v", id, err) s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read that change. Please try again.") return nil } if pc == nil || pc.AccountID != acct.ID || pc.App != app { http.NotFound(w, r) return nil } return pc } // handlePendingConfirm records one member's passkey-backed confirmation and, // when that meets the quorum, carries the change out. // // Gate order, the mandated one: session + membership (accountApp, 404 for a // non-member) → CSRF → signing key → the change's own 404 → requireFreshAuth // LAST. Membership is the gate here rather than ownership: the quorum names // N MEMBERS, and demanding ownership on top would make an owner's absence a // way to freeze every gated action in the account. // // ConfirmChange's three refusals ("no such", "expired", "already executed") // are only reachable as a race with another request between the 404 above and // the write, so mapping them can leak nothing: by then the caller has been // proved a member of the account that owns the change. They map to 404 / 410 // / 409 respectively — an unknown id stays indistinguishable from another // account's, because both were already answered by the 404 above. func (s *Server) handlePendingConfirm(w http.ResponseWriter, r *http.Request) { addr, raw, acct, _, app, ok := s.accountApp(w, r) if !ok { return } if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return } if s.cfg.Key == nil { s.renderMessage(w, r, http.StatusServiceUnavailable, "Confirmations disabled", "No signing key on this host — the console is running read-only.") return } pc := s.pendingScoped(w, r, acct, app) if pc == nil { return } if !s.requireFreshAuth(w, r, addr, raw) { return } back := func(kind, msg string) { s.setFlash(w, kind, msg) http.Redirect(w, r, appPath(acct, app), http.StatusSeeOther) } pc, _, err := s.store.ConfirmChangeContext(r.Context(), acct.ID, app, pc.ID, addr, time.Now()) if err != nil { s.refuseConfirm(w, r, err) return } ctx, cancel := context.WithTimeout(r.Context(), pendingTimeout) defer cancel() // ConfirmChange's own `met` counts ROWS against the STORED quorum, which // is not what execution requires (see executePending): a change whose // rule was raised, or whose confirmations include somebody who has since // left the account, is not met however many rows it carries. The tally // shown and audited here is the one that decides, so the flash a member // gets and the row the trail keeps say the same thing the gate does. // Deciding on it also keeps the not-met path a plain progress message // rather than an execution that refuses a beat later. confirmed, needed := s.pendingTally(*pc, s.pendingPipeline(ctx, acct, app)) if confirmed < needed { s.audit(acct, app, addr, "confirmed", fmt.Sprintf("%s (%d of %d confirmations)", pendingSummary(*pc), confirmed, needed)) back("ok", fmt.Sprintf("Confirmed: %s. %d of %d confirmations.", pendingSummary(*pc), confirmed, needed)) return } msg, err := s.executePending(ctx, acct, app, addr, pc) if err != nil { back("err", "Could not carry that out: "+err.Error()) return } back("ok", msg) } // refuseConfirm maps ConfirmChange's own refusals onto the wire. See // handlePendingConfirm's doc comment for why this cannot be an oracle. // // errors.Is, never the message text: these are sentinel values (pending.go) // precisely so that rewording a refusal cannot silently turn a 410 or a 409 // into the 500 fallback — "please try again" is the wrong thing to tell // somebody about a change that expired, and a wrong status is the kind of // regression no test catches unless the mapping is pinned to values. func (s *Server) refuseConfirm(w http.ResponseWriter, r *http.Request, err error) { switch { case errors.Is(err, ErrNoPendingChange): http.NotFound(w, r) case errors.Is(err, ErrPendingChangeExpired): s.renderMessage(w, r, http.StatusGone, "Expired", "That change expired before it collected enough confirmations. Nothing has been changed — propose it again.") case errors.Is(err, ErrPendingChangeExecuted): s.renderMessage(w, r, http.StatusConflict, "Already done", "That change was carried out while you were confirming it. Nothing else has been changed.") default: log.Printf("console: confirm pending change: %v", err) s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not record your confirmation. Please try again.") } } // handlePendingReject deletes a proposal outright. The proposer or an account // owner may do it, and NEITHER needs a passkey: refusing authority is not // exercising it, and a rejection that failed closed on an unreachable Keymail // would leave a change nobody could withdraw sitting one confirmation away // from landing. func (s *Server) handlePendingReject(w http.ResponseWriter, r *http.Request) { addr, raw, acct, role, app, ok := s.accountApp(w, r) if !ok { return } if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return } pc := s.pendingScoped(w, r, acct, app) if pc == nil { return } // A member who is neither the proposer nor an owner meets the same 404 // the change's own scope check gives — they learn nothing about a change // they have no say over. if pc.Proposer != addr && role != RoleOwner { http.NotFound(w, r) return } if err := s.store.DeleteChangeContext(r.Context(), acct.ID, app, pc.ID); err != nil { log.Printf("console: reject pending change %s: %v", pc.ID, err) s.setFlash(w, "err", "Could not withdraw that change. Please try again.") http.Redirect(w, r, appPath(acct, app), http.StatusSeeOther) return } s.audit(acct, app, addr, "rejected", pendingSummary(*pc)+" (proposed by "+pc.Proposer+")") s.setFlash(w, "ok", "Withdrawn: "+pendingSummary(*pc)+". Nothing was changed.") http.Redirect(w, r, appPath(acct, app), http.StatusSeeOther) } // ---- the CLI door ------------------------------------------------------------ // handleAPIPending answers `carlos pipeline`'s pending list: what this app is // waiting on, and how far along each one is. Read-only and Publish-reachable, // like channels and releases — a proposal names no secret, and the CLI needs // it precisely because the CLI cannot confirm anything itself (spec §4). func (s *Server) handleAPIPending(w http.ResponseWriter, r *http.Request) { _, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } list, err := s.store.PendingChangesForContext(r.Context(), acct.ID, app, time.Now()) if err != nil { log.Printf("console: api pending %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusInternalServerError, "could not read pending changes") return } // The same tally the card shows, from one pipeline read: "confirmed" and // "needed" are what EXECUTION will count, so `carlos pipeline` cannot // print a change as ready when confirming it would refuse (PR #258 // review, I3). var pipe *release.Pipeline if len(list) > 0 { pipe = s.pendingPipeline(r.Context(), acct, app) } out := make([]map[string]any, 0, len(list)) for _, pc := range list { confirmed, needed := s.pendingTally(pc, pipe) out = append(out, map[string]any{ "id": pc.ID, "kind": pc.Kind, "summary": pendingSummary(pc), "confirmed": confirmed, "needed": needed, }) } writeJSON(w, http.StatusOK, map[string]any{"pending": out}) }