// The CLI's read-only console API: GET routes under /api/cli/ that let a // terminal list its own accounts/apps and read a channel ladder by talking // to the console over HTTP, rather than opening the deployment bucket // directly. Auth and account/app resolution are ONE helper (apiAccountApp) // for every route below that names an account and an app, because // resolution and membership proof are deliberately the same lookup: an // unknown account and an account you don't belong to must be the identical // 404, or a token holder learns something by probing names. package console import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log" "net/http" "net/url" "regexp" "strings" "sync" "time" "github.com/carlosframework/platform/internal/caps" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/svcrole" "github.com/carlosframework/platform/internal/version" ) // apiAccountApp resolves and authorizes one CLI API request, for either kind // of caller. For a PERSON the account segment accepts a sqid or a name, // resolved against the caller's OWN memberships — resolution and membership // proof are deliberately the same lookup, so an unknown account and someone // else's account are the same 404. For a SERVICE it is the credential's own // binding, matched as a sqid only. apiPrincipalAccount holds both rules and // the reasoning behind them; apiAccountAppBy adds the app half. func (s *Server) apiAccountApp(w http.ResponseWriter, r *http.Request) (string, *Account, string, bool) { return s.apiAccountAppBy(w, r, s.store.HasAppIn) } // apiTrashedApp is apiAccountApp for the ONE door whose subject is an app // that is not live: restore. Everything about it — the symmetric resolution, // the ambiguity 409's wording, the byte-identical 404 — is apiAccountApp's, // by construction rather than by copy, because the moment the two diverge // the restore door becomes an oracle: a caller could tell "no such account", // "not your account" and "that app is not in the trash" apart by their // answers. The ONLY difference is the final lookup. func (s *Server) apiTrashedApp(w http.ResponseWriter, r *http.Request) (string, *Account, string, bool) { return s.apiAccountAppBy(w, r, s.store.HasTrashedAppIn) } // apiAccountAppBy is the shared body of the two resolvers above: the account // half resolves first, then `has` — the last gate and the only variable — // says whether THIS door's subject exists. func (s *Server) apiAccountAppBy(w http.ResponseWriter, r *http.Request, has func(int64, string) (bool, error)) (string, *Account, string, bool) { return s.apiAccountAppSaying(w, r, has, mainGateCopy()) } // apiAccountAppSaying is apiAccountAppBy with the sentences of c. func (s *Server) apiAccountAppSaying(w http.ResponseWriter, r *http.Request, has func(int64, string) (bool, error), c gateCopy) (string, *Account, string, bool) { p, acct, ok := s.apiPrincipalAccountSaying(w, r, c) if !ok { return "", nil, "", false } app := r.PathValue("app") // An app-scoped service reaches exactly one app. Checked BEFORE the // existence lookup, so that "not your app" and "no such app" stay the // same 404 rather than differing by which branch produced it — and an // empty binding is never a wildcard, which apiPrincipalAccount has // already refused outright for the one credential shape that can carry // one (see serviceBinding). if p.Kind == PrincipalService && p.App != "" && p.App != app { http.NotFound(w, r) return "", nil, "", false } found, err := has(acct.ID, app) if err != nil { log.Printf("console: api has-app %s/%s: %v", acct.Sqid, app, err) } if err != nil || !found { http.NotFound(w, r) return "", nil, "", false } return auditActor(p, acct), acct, app, true } // auditActor renders a principal for the audit trail, for the log lines that // name a caller, and for the rate-limit key an address used to serve as. A // service is deliberately NOT a bare address: a row written by a machine // must never read as a row written by the human who minted its token. func auditActor(p Principal, acct *Account) string { if p.Kind == PrincipalUser { return p.Address } if p.Name == "" { // A legacy kind='app' row has no name; keep the string the audit // trail has always written for it, so old rows and new rows about // the same credential still read as the same actor. return "app:" + acct.Sqid + "/" + p.App } return "service:" + acct.Sqid + "/" + p.Name } // serviceBinding resolves the account a SERVICE principal is bound to. ok is // false for a binding this console cannot honour, and every one of those is // the family's 404 at the doors and an empty answer at the one door that // enumerates (handleAPIApps) — never a widening. // // Two of the three refusals guard the same failure mode, which is worth // naming because it is the one a collapse of two resolvers into one could // introduce: a credential bound to NOTHING must never read as a credential // bound to EVERYTHING. An unset account id is one shape of it. The other is // a legacy kind='app' row whose app column is empty — for that row the app // IS the whole scope, so an empty one is not an account-scoped credential, // it is a broken one. apiInstancePrincipal refused exactly this row and so // does this. A NAMED service principal, by contrast, may legitimately carry // an empty app: account-scoped is a scope its minter chose. // // The third is an account deleted out from under a live binding, which is a // 404 rather than an error for the same reason every other missing account // is. func (s *Server) serviceBinding(p Principal) (*Account, bool) { if p.AccountID == 0 || (p.Name == "" && p.App == "") { return nil, false } acct, err := s.store.AccountByID(p.AccountID) if err != nil { log.Printf("console: api principal account %d: %v", p.AccountID, err) return nil, false } if acct == nil { return nil, false } return acct, true } // apiPrincipalAccount is the account half of apiAccountAppBy: the principal // cliRoute already resolved, and the account the {account} segment names for // THAT principal. // // It is the SHARED BODY, not a convenience for some future constituency. // Three callers today: apiAccountAppBy and handleAPICreateApp, which both // need the typed principal to check an app binding, and apiAccount, which // renders the audit actor for everyone else. Task 5's rate limiters // (allowCreate/allowWrite/allowShip, svcprincipal.go's principalKey) are the // budget-key constituency this shape was built for — they read the // principal back via principalFrom on the request context instead of // calling this a fourth time, the same principal cliRoute already resolved, // cheaper than resolving the account segment all over again. // // A SERVICE's scope is a BINDING, not a membership set, so the segment is // matched as a sqid ONLY. It holds no memberships, so there is no set within // which to resolve a name; resolving globally and then comparing would be a // name-collision oracle for a credential that never needed names. The // binding is exact or it is a 404. This is apiInstancePrincipal's rule, // moved here rather than deleted, because this is now the only place it is // written down. // // Nothing in the service branch consults AccountsFor. That is the whole // point of issue #220: the reach is a property of the credential, fixed at // mint, and the memberships of the human who minted it are irrelevant — // including the ones that human gains next month. // // A PERSON's path below is unchanged. Resolution is SYMMETRIC // (matchAccounts, resolve.go): the segment matches as a sqid or as a name — // case-insensitively — with no precedence between the two spaces, exactly as // the browser's /a/{seg} resolves it. A sqid match no longer wins outright // over a name match, because precedence is how a reference silently changes // meaning the day a colliding account appears: the caller who typed a name // would start acting on someone else's tenant without the wording of their // command changing at all. Two candidates are refused (409) rather than // guessed at. // // `?exact=sqid` is the transport for the CLI's -account-id flag: it matches // the segment as a sqid ONLY, so the reference a person reaches for to // settle an ambiguity can never itself be ambiguous. It never widens access // — a non-member's probe meets the same 404 either way. func (s *Server) apiPrincipalAccount(w http.ResponseWriter, r *http.Request) (Principal, *Account, bool) { return s.apiPrincipalAccountSaying(w, r, mainGateCopy()) } // gateCopy is what the shared CLI gates say when they refuse in a sentence // of their own. Every door speaks mainGateCopy(), main's bytes; a door with // its own reviewed copy passes its own table (the Phase 1 move door, // phase1MoveCopy()) and the gates' logic is unchanged. type gateCopy struct { ambiguous func(seg, alts string) string // the account segment matches several of the caller's accounts tooMany func(noun string) string // the instance verbs' rate limit noBucket string // the console runs without a deployment bucket } // mainGateCopy returns main's sentences, byte for byte (the dashes are escaped // only in the source). func mainGateCopy() gateCopy { return gateCopy{ ambiguous: func(seg, alts string) string { return fmt.Sprintf("account %q is ambiguous \u2014 pass --account-id with one of: %s", seg, alts) }, tooMany: func(noun string) string { return "too many " + noun + " requests \u2014 wait a minute and try again" }, noBucket: "no deployment bucket on this host \u2014 the console is running without one", } } // apiPrincipalAccountSaying is apiPrincipalAccount with the sentences of c. func (s *Server) apiPrincipalAccountSaying(w http.ResponseWriter, r *http.Request, c gateCopy) (Principal, *Account, bool) { p, ok := principalFrom(r.Context()) if !ok { // Only reachable from a door that skipped cliRoute, which // TestEveryCLIDoorNamesItsRoles says cannot happen under // /api/cli/. Refuse rather than fall back to a second resolver: a // silent fallback here would be the one path on which the role gate // does not run. http.NotFound(w, r) return Principal{}, nil, false } seg := r.PathValue("account") // Dispatched as a closed switch rather than an if/else, so a kind added // later fails shut. An `else` here would send any third kind down the // person path, where it would read as a member with an empty address — // the one shape of bug this package's whole thesis is against. var addr string switch p.Kind { case PrincipalService: acct, ok := s.serviceBinding(p) if !ok || seg != acct.Sqid { http.NotFound(w, r) return Principal{}, nil, false } return p, acct, true case PrincipalUser: addr = p.Address default: http.NotFound(w, r) return Principal{}, nil, false } accts, err := s.store.AccountsFor(addr) if err != nil { jsonError(w, http.StatusInternalServerError, "could not load accounts") return Principal{}, nil, false } var matches []*Account if r.URL.Query().Get("exact") == "sqid" { // -account-id: the reference that never guesses. Match the segment // as a sqid only — a name, however unique, does not answer. for i := range accts { if accts[i].Sqid == seg { matches = append(matches, &accts[i]) break } } } else { matches = matchAccounts(accts, seg) } var acct *Account switch len(matches) { case 1: acct = matches[0] case 0: // acct stays nil — the same 404 an unknown or someone-else's // account gets, below. default: alts := make([]string, len(matches)) for i, m := range matches { alts[i] = fmt.Sprintf("%s (%q)", m.Sqid, m.Name) } jsonError(w, http.StatusConflict, c.ambiguous(seg, strings.Join(alts, ", "))) return Principal{}, nil, false } if acct == nil { http.NotFound(w, r) return Principal{}, nil, false } return p, acct, true } // apiAccount is apiPrincipalAccount for the callers that need only the audit // actor and the account — every existing one, which is why the three-value // signature stays exactly as it was. The first return is WHO TO AUDIT AS // (auditActor), which for a service is deliberately not a bare address. func (s *Server) apiAccount(w http.ResponseWriter, r *http.Request) (string, *Account, bool) { p, acct, ok := s.apiPrincipalAccount(w, r) if !ok { return "", nil, false } return auditActor(p, acct), acct, true } // apiPerson refuses any caller that is not a PERSON, with the byte-identical // 401 cliUser has always given — before any account segment is resolved, so // the answer stays a constant function of the credential rather than telling // a service whether the path it named is its own. // // It is the INNER of two walls at the doors whose subject is a human act: // minting a credential (a credential may never mint a credential) and // enabling instances (the manifest is standing policy about what may be // brought into existence). Both are registered with the zero Set, so no role // reaches either — and THAT registration is the load-bearing wall. The role // table is the single source of truth for what a service credential may // reach, and a caller refused there never arrives here at all. // // This second wall is not redundancy. Before service principals, those // handlers refused a non-person IMPLICITLY: they resolved through // apiAccountApp, which resolved through cliUser, which answers for nobody but // a person. Collapsing that resolution into the principal-aware path removed // the refusal, and an invariant a handler relies on should not be left // depending solely on a line in another file staying right. So the // person-only semantics are stated where the handler is, not only where it // is registered. // // TWO DOORS AND NOT TWELVE, deliberately. The six fleets/* doors and the // four services/* doors are just as person-only and just as closed, and they // do NOT call this: adding a second wall to a door no request can reach is // ceremony, and ceremony is what gets pattern-matched into the next handler // that should not have it. They rely on the registration plus the end-to-end // pin (TestEveryClosedDoorRefusesAServiceCredential). These two get the extra // line because their handler semantics are person-only in a way a reader // editing them must not miss — one mints a credential, the other rewrites // the bounds an app runs inside. Recorded as a choice, not left as an // asymmetry to be read as an oversight. // // It reads the principal cliRoute already resolved, so it costs no second // lookup. Pinned directly by TestApiPersonIsTheInnerWall, because the closed // registrations mean no request can ever deliver a service principal to it // through the mux. func (s *Server) apiPerson(w http.ResponseWriter, r *http.Request) bool { p, ok := principalFrom(r.Context()) if !ok || p.Kind != PrincipalUser { s.cliNotSignedIn(w) return false } return true } // handleAPICreateApp answers `carlos apps create`: the CLI door onto the // same claim the browser's New app form posts to. claimApp holds the checks // for both doors — including the ops account's legacy claim-proofing — so // lifting the parent spec's §7 exclusion changes where a claim can come // from, never what a claim will refuse // (docs/superpowers/specs/2026-08-04-cli-app-create-design.md). // // The rate limit is create's OWN budget, not the shared `write` one, and it // is spent per ATTEMPT, before the name is even read — a refused probe that // cost nothing would leave the limiter bounding nothing a stolen token's // holder cares about. For a non-ops account every refusal below reveals // only the caller's own account's state, so the 409 is not the cross-tenant // oracle the parent spec's §3 guards against. // // The optional "place" field (2026-08-06 ruling: `carlos apps create -place // fleet/`) is validated with the EXACT SAME two checks // placeAppTarget applies to its target — grammar (registry.FleetPlacement) // and that the fleet belongs to the CALLING account — before claimApp is // ever called, and with the same refusal: an unknown-or-foreign fleet is // the byte-identical 404 apiFleet gives everywhere else, never a 400 that // would leak "that fleet exists, just not for you". Creating with an // invalid place must not be a weaker gate than `carlos apps place` run // straight after create — this is what keeps the two equal. func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) { p, acct, ok := s.apiPrincipalAccount(w, r) if !ok { return } // An app-scoped credential's subject is its app. Claiming a NEW one is // ACCOUNT authority it was deliberately not given — name-squatting // inside the account, quota and billing surface, and a row the operator // never authorised. This door has no {app} segment, so apiAccountAppBy's // binding check never runs for it and the guard has to be here. // // The byte-identical 404, not a 403: this is a scope refusal like every // other binding mismatch, not a role refusal. The door is Allow(publish) // and stays that way — it is designed for an ACCOUNT-scoped hub (amadan // Pages), which is exactly the credential it still serves. if p.Kind == PrincipalService && p.App != "" { http.NotFound(w, r) return } addr := auditActor(p, acct) // allowCreate picks the person or the service create budget from the // principal (svcprincipal.go) — a person keyed on the ADDRESS as before // (a token-keyed counter is one the holder of several tokens resets at // will), a service on its NAME, own budget from the operator's. if !s.allowCreate(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "app creation limit reached for today — use the console's New app form") return } var req struct { Name string `json:"name"` Place string `json:"place"` ControlPlane bool `json:"control_plane"` BuildProbe string `json:"build_probe"` Region string `json:"region"` Timezone string `json:"timezone"` } if !readJSON(w, r, &req) { return } name := normalizeAppName(req.Name) place := strings.TrimSpace(req.Place) // Validated BEFORE claimApp, like place: a refused flag refuses the WHOLE // create rather than leaving a claimed name with the wrong shape, and a // shape the server is certain to refuse costs no claim attempt. if req.ControlPlane && acct.Role != RoleOwner { jsonError(w, http.StatusForbidden, "only an owner can mark an app as the control plane") return } probe := strings.TrimSpace(req.BuildProbe) if probe != "" && !validBuildProbe(probe) { jsonError(w, http.StatusBadRequest, `build_probe must be an absolute URL path with no query, e.g. "/healthz"`) return } if place != "" { fleetName, validShape := registry.FleetPlacement(place) if !validShape { jsonError(w, http.StatusBadRequest, `invalid place — want "" or "fleet/"`) return } reg, err := openRegistry(s.cfg.Registry) if err != nil { log.Printf("console: create app %s: open registry: %v", acct.Sqid, err) jsonError(w, http.StatusInternalServerError, "could not reach the fleet registry") return } f, err := reg.GetFleet(fleetName) if err != nil { log.Printf("console: create app %s: get fleet %s: %v", acct.Sqid, fleetName, err) jsonError(w, http.StatusInternalServerError, "could not check the fleet") return } if f == nil || f.Account != acct.Sqid { // Same byte-identical 404 apiFleet and placeAppTarget's target // check both give an unknown-or-foreign fleet — see this // handler's doc comment. http.NotFound(w, r) return } } rq, ref := s.newAppRegionReq(r, req.Region, req.Timezone) if ref != nil { jsonError(w, ref.Status, ref.Msg) return } // ownerClaim: a PERSON who owns this account may adopt objects already // under its own prefix (#79). A service principal never may — it holds no // account role, and handing a CI credential the power to adopt release // history is exactly the widening svcrole exists to prevent. ownerClaim := p.Kind == PrincipalUser && acct.Role == RoleOwner defer s.planLock(acct)() if err := s.checkCreateApp(r.Context(), acct, name); err != nil { if isPlanCap(err) { writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) return } log.Printf("console: api create %s/%s: plan cap: %v", acct.Sqid, name, err) jsonError(w, http.StatusInternalServerError, "could not create the app — try again") return } outcome, placed, held := s.claimApp(r.Context(), acct, name, addr, place, ownerClaim, rq) switch outcome { case claimInvalid: jsonError(w, http.StatusBadRequest, "invalid app name — lowercase letters, digits, and dashes, starting with a letter") case claimReserved: jsonError(w, http.StatusConflict, "that name is reserved") case claimTemporaryReserved: jsonError(w, http.StatusConflict, "names starting with tmp- are kept for temporary sites. Use carlos deploy --temporary, or choose another name") case claimTaken: jsonError(w, http.StatusConflict, "that name is taken") case claimApexHeld: jsonError(w, http.StatusConflict, "that name is not free: "+apexHeldSentence(s.apexHost(acct.Sqid, name), held)) case claimCheckFailed, claimCreateFailed: jsonError(w, http.StatusInternalServerError, "could not create the app — try again") case claimStoreFailed: // A bucket error, like handleCLINewAccount's: the detail stays on // the box (key names and endpoints don't belong in API bodies). jsonError(w, http.StatusBadGateway, "could not check the release store — try again") case claimOK: s.audit(acct, name, addr, "create", "created app "+name+" (cli)") // Stamped in the SAME call that claims the name, after the claim has // actually succeeded: claiming first and flagging second would leave // a window in which the freshly-claimed row is an ordinary app any // ops member — or a Publish service credential — could roll before // the flag lands. Both setters are plain UPDATEs that no-op on a // missing row (store.go), so they are safe here only because claimOK // guarantees CreateAppIn just succeeded. if req.ControlPlane { if err := s.store.SetAppControlPlaneContext(r.Context(), acct.ID, name, true); err != nil { log.Printf("console: claim %s/%s: set control_plane: %v", acct.Sqid, name, err) } } if probe != "" { if err := s.store.SetAppBuildProbeContext(r.Context(), acct.ID, name, probe); err != nil { log.Printf("console: claim %s/%s: set build_probe: %v", acct.Sqid, name, err) } } // "placement", "control_plane" and "build_probe" all echo what // actually stuck, NOT the request's own values — so a caller (CLI's // runAppsCreate, apps.go) can tell "claimed and flagged" apart from // "claimed, flag silently didn't" (a swallowed store-write error) by // comparing these fields to what it asked for, rather than trusting // a 200 to mean everything it asked for happened. gotCP, gotProbe, err := s.store.AppFlags(acct.ID, name) if err != nil { log.Printf("console: claim %s/%s: read back flags: %v", acct.Sqid, name, err) } out := map[string]any{ "account": acct.Sqid, "app": name, "url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(name), "placement": placed, "control_plane": gotCP, "build_probe": gotProbe, } s.echoCreatedRegion(r.Context(), acct, name, out) writeJSON(w, http.StatusOK, out) default: // Any future claimOutcome lands here rather than silently 200ing // with an empty body — see the reserved-name finding this guards. jsonError(w, http.StatusInternalServerError, "could not create the app — try again") } } // echoCreatedRegion adds the region a create door just stored to its // response: region, region_name, region_source and regions_available. Both // create doors (/new and /new-temporary) echo it, so the CLI can say what // region the app landed in with the same lines either way. func (s *Server) echoCreatedRegion(ctx context.Context, acct *Account, name string, out map[string]any) { gotRegion, gotSource, err := s.store.AppRegion(acct.ID, name) if err != nil { log.Printf("console: claim %s/%s: read back region: %v", acct.Sqid, name, err) } files, ferr := s.liveEdges(ctx) cands := homeOnlyCandidates() if ferr == nil { cands = s.regionCands(files) } // regions_available is what THIS create could use, not what the // fleet has. With the switch off (or no named home) every create // lands on the home and --region is ignored (C13b), so there is // one; a CLI that prints C11 at two or more then never invites a // flag this console ignores. Fixed here rather than in the CLI so // older CLIs benefit (final review, C11 on a switch-off console). available := eligibleCount(cands) if !s.appRegionsOn() { available = 1 } out["region"] = s.homeRegionOut(gotRegion) out["region_name"] = s.placeOf(gotRegion, cands) out["region_source"] = gotSource out["regions_available"] = available } // newAppRegionReq builds a create door's regionReq from its optional region // and timezone fields, validating an explicit region exactly as /new always // has. A non-nil refusal is the response; nothing was claimed. func (s *Server) newAppRegionReq(r *http.Request, region, timezone string) (regionReq, *doorRefusal) { // The region is resolved inside claimApp, once it knows the claim is not // an adoption. The timezone and the address only ever feed that guess. rq := regionReq{tz: timezone, ip: s.personIP(r)} // With the switch off an explicit region is neither validated nor // refused (spec §9.6): resolveNewAppRegion stores the home as "default", // and a refusal there would offer choices that would not be honoured. if strings.TrimSpace(region) != "" && s.appRegionsOn() { code, ref := s.validateExplicitRegion(r.Context(), region) if ref != nil { return regionReq{}, ref } rq.explicit, rq.hasExplicit = code, true } return rq, nil } // handleAPIApps answers `carlos apps`: every account the caller can see, and // the app names each one has claimed. Scoped to the caller's OWN // memberships — there is no "list every account" verb here, on the same // principle apiAccountApp enforces one call at a time. // // This is the ONE door with no {account} segment, and therefore the one door // whose ANSWER is scoped rather than whose ACCESS is gated (spec §3a-i). A // service principal has no membership set to enumerate: it answers with // EXACTLY its own scope — the one account it is bound to, or the single app // if it is app-scoped — and never enumerates anything else. Readable by // every role, because a credential seeing the thing it is already bound to // discloses nothing it does not already hold. // // The `role` field is the MEMBERSHIP role (owner/member) that AccountsFor's // join populates. A service holds no membership, so its account row carries // the empty string, which is the right answer and not a gap to fill: v1 adds // no field here, because the CLI in the field parses this response and the // wire shape stays byte-compatible. func (s *Server) handleAPIApps(w http.ResponseWriter, r *http.Request) { p, ok := principalFrom(r.Context()) if !ok { http.NotFound(w, r) return } // A closed switch, for apiPrincipalAccount's reason: a kind added later // must not fall through to AccountsFor and enumerate a person's whole // membership set on behalf of something nobody has classified yet. var accts []Account switch p.Kind { case PrincipalService: // A binding this console cannot honour enumerates NOTHING. It is not // an error — the credential's scope is simply empty — and above all // it is not a fall-through to every account, which is the failure // mode serviceBinding exists to refuse. if acct, ok := s.serviceBinding(p); ok { accts = []Account{*acct} } case PrincipalUser: var err error accts, err = s.store.AccountsFor(p.Address) if err != nil { log.Printf("console: api apps for %s: %v", p.Address, err) jsonError(w, http.StatusInternalServerError, "could not load accounts") return } default: http.NotFound(w, r) return } out := make([]map[string]any, 0, len(accts)) for _, a := range accts { apps, err := s.store.AppsIn(a.ID) if err != nil { log.Printf("console: api apps in %s: %v", a.Sqid, err) jsonError(w, http.StatusInternalServerError, "could not load apps") return } if p.Kind == PrincipalService && p.App != "" { // App-scoped: exactly its own app, and only if that app is still // claimed. Filtering the real list rather than answering with // the binding keeps a deleted app out of the answer. var mine []string for _, app := range apps { if app == p.App { mine = append(mine, app) } } apps = mine } if apps == nil { apps = []string{} } out = append(out, map[string]any{ "sqid": a.Sqid, "name": a.Name, "role": a.Role, "apps": apps, }) } writeJSON(w, http.StatusOK, map[string]any{"accounts": out}) } // handleAPIChannels answers `carlos channels`: every channel pointer under // one app, verified the same way the CLI verifies them itself. A pointer // that fails verification becomes one erroring ROW rather than a failed // response — mirroring runChannels' per-row ERROR behaviour (cli.go), since // one bad pointer must not hide every other channel's answer from the // caller. // // The response also carries two ADDITIVE "pipeline_entry"/"pipeline_channels" // keys (release pipelines v2, Task 11) — see the comment at their assignment // below. func (s *Server) handleAPIChannels(w http.ResponseWriter, r *http.Request) { _, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() prefix := release.ChannelsPrefix(acct.Sqid, app) keys, err := s.cfg.Releases.List(ctx, prefix) if err != nil { log.Printf("console: api channels list %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not list channels") return } // The account's pointer digest marker (#253, spec §4.6), read once: a // row's straggler flag is measured against it. An unreadable marker is // logged and read as off; the rows still answer. marker, merr := release.ReadDigestMarker(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid) if merr != nil { log.Printf("console: api channels %s/%s: digest marker: %v", acct.Sqid, app, merr) } out := make([]map[string]any, 0, len(keys)) for _, k := range keys { name := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json") cp, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, name) if err != nil { out = append(out, map[string]any{"channel": name, "error": err.Error()}) continue } // rung is always present, "" when the channel name isn't one of the // ladder's rungs — a typed client (later tasks) can rely on the key // existing rather than testing for its absence. rung, _ := release.Rung(name) st := release.DigestStateOf(cp, marker) out = append(out, map[string]any{ "channel": name, "version": cp.Version, "promoted_at": cp.PromotedAt.UTC().Format(time.RFC3339), "rung": rung, "digests": st.HeadDigest, "history_missing": st.HistoryMissing, "straggler": st.Straggler, }) } resp := map[string]any{"channels": out} if marker != nil { resp["pointer_digests"] = map[string]any{"on": true, "since": marker.At.UTC().Format(time.RFC3339)} } // pipeline_entry/pipeline_channels: two ADDITIVE top-level keys (release // pipelines v2, Task 11 — review fix round 1) — old CLIs decode // "channels" and ignore the rest. They are a REDUCED view of the // pipeline — the entry channel and the full order, nothing about // bake/rules — not the full signed release.Pipeline object `carlos // pipeline` GETs (Task 6). Deliberately NOT nested under a "pipeline" // key: the `/pipeline` door already uses that JSON path name for the // full object, and reusing it here for a differently-shaped value (an // object there, these two flat fields here) would be the same key // meaning two incompatible things depending on which door answered it — // a trap for any future consumer, and one that freezes the moment a // released CLI starts decoding it. Two new key NAMES sidestep the // collision entirely. The LOAD is shared with handleAPIPipelineGet // verbatim (release.GetPipeline) so the two doors can never disagree // about what "this app's pipeline" is. // // Both keys are OMITTED — together, never one without the other — for // BOTH a legacy app (no pipeline.json) AND a pipeline read failure. // This channels door is a convenience summary for deploy resolution, // not the authority on why a pipeline is absent: a caller that needs to // tell "definitely no pipeline" apart from "the console could not read // it" has the dedicated `/pipeline` door for that (its null-vs-502 // distinction, Task 6). Collapsing the two here costs nothing a caller // of THIS door needs: deployTargets' entry-channel fallback treats an // absent PipelineEntry as "no fallback" either way, which is the // correct conservative behaviour for a read failure as much as for a // genuinely legacy app. if p, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app); err != nil { log.Printf("console: api channels %s/%s: pipeline read: %v", acct.Sqid, app, err) } else if p != nil { names := make([]string, len(p.Channels)) for i, c := range p.Channels { names[i] = c.Name } resp["pipeline_entry"] = p.Entry() resp["pipeline_channels"] = names } writeJSON(w, http.StatusOK, resp) } // handleAPIPromote answers `carlos promote`: the CLI's write door onto the // SAME ladder the console's own promote button enforces (handlePromote, // server.go), doors in the same order minus two the browser needs and this // caller cannot present: CSRF (a bearer request carries no session cookie to // forge in the first place — readJSON's JSON-content-type insistence is // already this door's cross-site defence) and the passkey step-up (there is // no browser here to redirect into Keymail). // // Stable's gate does not soften into a step-up here — it simply refuses, // pointing at the console, which DOES have a passkey to ask for. Refusing // before ever consulting role is what keeps a member's 403 and an owner's 403 // byte-identical: a token holder must not learn their own role, let alone // anyone else's, from a door that isn't the one stable actually goes through. // // Which gate applies is the PIPELINE's answer (release pipelines v2 §5). An // app that has none meets the legacy stable refusal above, byte for byte, as // it always has. A pipelined app meets the target channel's own declared // rules instead: a passkey rule refuses here (there is no passkey at a bearer // token), an approvals rule turns the request into a PROPOSAL, and a channel // with neither promotes exactly as before. // // `hotfix` is read strictly AFTER that decision, never before it: -hotfix // bypasses ORDER only, never rules (spec §5), and reading the flag first is // precisely how a bypass of one quietly becomes a bypass of the other. func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } // ok discarded here, and at every other write/create/ship/domains call // site in this package that reads a principal back off the context: // apiAccountApp/apiAccount/apiFleet above already ran, and cliRoute — // the ONLY writer of the context key — stashed the principal on the // context before the handler ever started, so ok is already true by // the time this line runs (Task 5 review, Risk A). apiPrincipalAccount // is a reader, not the writer: it 404s if the key is ever absent, // which cannot happen for a door that went through cliRoute. // If that ordering were ever broken, the zero Principal reads as // PrincipalUser with Address "", which keys the PERSON limiter on the // empty string for every caller alike — turning, say, the create // door's 10-a-day-per-address budget into ONE shared bucket of 10 // creates a day for the entire deployment, not a per-caller refusal. p, _ := principalFrom(r.Context()) // allowWrite picks the person or the service write budget from the // principal (svcprincipal.go). A person is keyed on the ADDRESS, not the // token — the same key handleCLINewAccount already uses on this // limiter: keying by token instead would give someone with several live // tokens an independent budget per token, which is the limit doing less // than it should, not more. A service is keyed on its NAME, its own // budget separate from the operator who minted it (issue #220). if !s.allowWrite(p, 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 req struct { Version string `json:"version"` Channel string `json:"channel"` Message string `json:"message"` // Hotfix is spec §5's order bypass, and it is read nowhere above the // rules decision below — see this handler's doc comment. Hotfix bool `json:"hotfix"` // Retry renews the channel's current promotion of this version with a // fresh PromotedAt. It goes through the same rules, scope and // Authorize checks as any promote. Retry bool `json:"retry"` } if !readJSON(w, r, &req) { return } message, ok := checkedActivityMessage(w, req.Message) if !ok { return } channel := strings.TrimSpace(req.Channel) if !release.ValidChannel(channel) { jsonError(w, http.StatusBadRequest, "invalid channel name") return } version := strings.TrimSpace(req.Version) if !release.ValidVersion(version) { jsonError(w, http.StatusBadRequest, "invalid version") return } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() man, err := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, version) if err != nil { jsonError(w, http.StatusNotFound, "Version "+version+" is not a shipped release of "+app+".") return } if err := s.checkDaemonRelease(acct, app, man); err != nil { if isPlanCap(err) { writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) return } log.Printf("console: api promote %s/%s: plan: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, planCheckFailedCopy) return } // The pipeline is read ONCE, for the gate below and for the held answer // further down. An unreadable one refuses outright: GetPipeline's error // means "not safely nil", and treating it as "no pipeline" would drop // this door back to the legacy ladder under exactly the app that opted // out of it — the same line PromoteOnLadder itself takes. pipe, perr := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) if perr != nil { log.Printf("console: api promote %s/%s: read pipeline: %v", acct.Sqid, app, perr) jsonError(w, http.StatusBadGateway, "could not read the pipeline for "+app) return } if err := deployScopeAllows(p, pipe != nil, channel, req.Hotfix); err != nil { s.writeDeployRefusal(w, err) return } // A person with ops membership may roll the control plane; a service // credential may not. This is the flag's promote-side job — the whole of // it. Gating a PERSON here would break `carlos deploy -app console`, the // command this exists to make ordinary (controlplane.go). if s.refuseServiceControlPlane(w, p, acct.ID, app, "promote it") { return } // The channel's own gate, LAST — after auth, after the account/app 404, // after the key check, and after every refusal that costs nothing to // answer. Same load-bearing order handlePromote's comment explains: a // non-member meets the byte-identical 404 in apiAccountApp before any of // this is mentioned. Below it, membership (already proved) is the gate; // PromoteOnLadder enforces the order itself. // // hotfix is honoured only where the pipeline can judge what it bypasses: // a target INSIDE the app's declared order. Cleared for anything outside // one, and the reason is a hole this door had for exactly one commit: a // pipelined app's `stable` is not in its pipeline, so it carried no // rules, and a hotfixed promote onto it would have skipped the order // check and written the stable pointer FROM A BEARER TOKEN — a target // that has been an unconditional 403 here since this door existed. // Bypassing an order the pipeline never declared is not a bypass, it is // an escape from the pipeline; the sanctioned escape hatch is a // direct-bucket promote with the root key in hand (spec §6). hotfix := req.Hotfix rung, _ := release.Rung(channel) switch { case pipe == nil: if rung == release.RungStable { writeJSON(w, http.StatusForbidden, map[string]string{ "error": "stable needs a passkey — finish in the console", "console_url": s.appConsoleURL(acct, app), }) return } case pipe.Index(channel) >= 0: switch rules := channelRules(pipe, channel); { case rules.PromoteApprovals >= 1: // Order is checked HERE, before a proposal that could never // execute goes out for signatures — the same refusal, at the // same 409, PromoteOnLadder would give below. This is the first // line in the handler that acts on the hotfix flag, and it is // strictly after the rules decision this switch is making. // pipe is non-nil in this arm by construction, which matters // because VersionStage dereferences the pipeline it is handed. // // A retry asks no order question (it moves nothing along the // pipeline: PromoteOnLadder's repeat path skips order for the // same reason); its precondition is checked against the head // below instead. if !hotfix && !req.Retry { stage, serr := release.VersionStage(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, version, pipe) if serr != nil { log.Printf("console: api promote %s/%s: stage: %v", acct.Sqid, app, serr) jsonError(w, http.StatusBadGateway, "could not work out where this build sits in the pipeline") return } if aerr := release.PipelinePromotionAllowed(pipe, stage, channel); aerr != nil { jsonError(w, http.StatusConflict, aerr.Error()) return } } // Pin the target channel's head at propose time (spec §4: a // head that moved underneath a proposal invalidates it); "" // when the channel has no pointer yet. An unreadable head // refuses — a pin that cannot be read cannot be recorded. head, herr := s.channelHead(ctx, acct, app, channel) if herr != nil { log.Printf("console: api promote %s/%s: read channel %s: %v", acct.Sqid, app, channel, herr) jsonError(w, http.StatusBadGateway, "could not read channel "+channel) return } // A retry renews the head's promotion, so one whose channel does // not serve this version could never execute: refused now, in // PromoteOnLadder's own words, before it goes out for signatures. if req.Retry && head != version { names := head if names == "" { names = "nothing" } jsonError(w, http.StatusConflict, fmt.Sprintf("promote --retry renews a promotion: %s names %s, not %s", channel, names, version)) return } s.proposeOverAPI(w, acct, app, kindPromote, promotePayload{Version: version, Channel: channel, Hotfix: hotfix, ObservedHead: head, Retry: req.Retry}, rules.PromoteApprovals, addr) return case rules.Passkey: // A passkey rule with no approvals alongside it: there is nothing // to propose — one person's presence IS the whole gate — so this // door refuses and names the one that can ask for it. (An // approvals rule above subsumes this: every confirmation counted // toward a quorum is passkey-backed by construction.) writeJSON(w, http.StatusForbidden, map[string]string{ "error": channel + " needs a passkey — finish in the console", "console_url": s.appConsoleURL(acct, app), }) return } default: // A pipelined app, aimed at a channel its pipeline does not name. // The stable RUNG keeps the byte-identical legacy refusal it has // always had here — an app-defined pipeline is a reason to govern // its own channels, never a way to reach one it left out. Everything // else falls through with hotfix cleared, so // PipelinePromotionAllowed's own refusal (409, naming the app's // channels) answers it. canary/* lands here too and is deliberately // NOT refused: spec §2 keeps it outside every pipeline and always // allowed, and PipelinePromotionAllowed waves it through below. if rung == release.RungStable { writeJSON(w, http.StatusForbidden, map[string]string{ "error": "stable needs a passkey — finish in the console", "console_url": s.appConsoleURL(acct, app), }) return } hotfix = false } signer := release.RootSigner(s.cfg.Key) var authorize func(*release.Pipeline) error if p.Kind == PrincipalService && p.Role == svcrole.Deploy { sg, serr := s.deploySigner(p) if serr != nil { log.Printf("console: deploy signer for %s/%s: %v", acct.Sqid, app, serr) jsonError(w, http.StatusServiceUnavailable, "could not prepare a signing key for this deploy credential. Nothing was promoted.") return } signer = sg authorize = func(pp *release.Pipeline) error { return deployScopeAllows(p, pp != nil, channel, req.Hotfix) } } res, err := signer.PromoteOnLadder(ctx, s.cfg.Releases, acct.Sqid, app, channel, version, release.PromoteOptions{Actor: addr, Hotfix: hotfix, Retry: req.Retry, Authorize: authorize}) if err != nil { var ae *release.AuthorizationError if errors.As(err, &ae) { s.writeDeployRefusal(w, ae.Err) return } log.Printf("console: api promote %s %s -> %s: %v", app, version, channel, err) // The ladder's own refusal text names the rungs — as useful over the // API as it is on the app page. jsonError(w, http.StatusConflict, err.Error()) return } if res.NoOp { // held on the no-op too, and this is not decoration: re-running the // same deploy (a CI retry, a repeated `carlos deploy`) against a // production app whose beta head is mid-bake is a NO-OP THAT IS STILL // HELD. A no-op answer without the field decodes as held:false and // sends the client off to watch for an adopt that is a day away — // exactly the false failure this field exists to prevent. Nothing is // announced here (no bump): the pointer did not move. heldNow := s.channelHeld(ctx, pipe, acct.Sqid, app, channel) out := map[string]any{"result": "noop", "held": heldNow} s.addHeldUntil(ctx, out, heldNow, pipe, acct.Sqid, app, channel) writeJSON(w, http.StatusOK, out) return } s.auditActivity(acct, app, addr, Activity{ Action: "promote", TargetType: "channel", Target: channel, Message: message, Detail: "promoted " + version + " to " + channel + " (cli)", }) s.bumpSerial(ctx) heldNow := s.channelHeld(ctx, pipe, acct.Sqid, app, channel) out := map[string]any{"result": "promoted", "from": res.From, "to": res.To, "noop": false, "held": heldNow} s.addHeldUntil(ctx, out, heldNow, pipe, acct.Sqid, app, channel) writeJSON(w, http.StatusOK, out) } // addHeldUntil says when a held promotion lifts (spec §9.1: "held: a bake // hold, with the time it lifts"): the pointer's promotion time plus the // channel's window. A box starts its window when it first sees the build, a // few seconds later, so the CLI says "about". Omitted when nothing is held or // the pointer will not read; the CLI then prints its old line. func (s *Server) addHeldUntil(ctx context.Context, out map[string]any, held bool, p *release.Pipeline, acct, app, channel string) { if !held { return } cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct, app, channel) if err != nil { return } out["held_until"] = cur.PromotedAt.Add(holdWindow(p, channel)).UTC().Format(time.RFC3339) } // channelHeld answers the CLI's next question without a second round trip: is // what this channel now carries actually going to be adopted, or is it waiting // out a bake window? Two conditions, exactly as the browser door's flash // computes them (handlePromote, server.go): the CHANNEL has a window at all, // and the APP has declared production — a non-production app's boxes adopt a // held channel immediately, so a hold claim would be a lie. // // The window comes from holdWindow, not HoldFor: a pipelined channel carries // its own, possibly for a name the legacy table has never heard of. And a // PIPELINED app never consults the production flag at all — its bake policy // is the pipeline's alone (spec §3), which is exactly what the boxes do // (adopt.go's bakeIgnore returns false for a verified pipeline), so consulting // the flag here would answer a question the fleet no longer asks. // // On the legacy path only beta can be both: canary/* and edge have // zero-length windows, and stable never reaches it (the rung gate 403s it). // An UNREADABLE flag reports held, matching what the boxes will actually do — // the adopt path fails TOWARD baking (spec §4), so reporting "not held" there // would be the one wrong answer. func (s *Server) channelHeld(ctx context.Context, p *release.Pipeline, acct, app, channel string) bool { if holdWindow(p, channel) == 0 { return false } if p != nil { return true } flag, err := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct, app) if err != nil { log.Printf("console: api promote production flag %s: %v", app, err) return true } return flag != nil && flag.Production } // handleAPIRollback answers `carlos rollback`: the CLI's write door onto the // same swap-to-previous-version primitive as the console's own rollback // button (handleRollback, server.go). Below stable there is no rung gate, // matching the browser door — rolling back never climbs the ladder, so // nothing about maturity has a say over it. // // Stable is the one exception, and it is Paul's ruling (2026-08-03): a // rollback that lands on stable puts a build in front of every user of the // app exactly as a promotion onto it would (§8's no-stable blast radius // governs regardless of which verb got there), so it is refused with the // SAME byte-identical 403 handleAPIPromote gives stable — before // release.Rollback is ever called, and before the channel's current pointer // is even read, so the refusal cannot vary with what that pointer contains. // That refusal stays UNCONDITIONAL, pipeline or no pipeline: this door only // ever adds gates below, never removes the one it already had. // // A PIPELINED channel's own rules gate it too (release pipelines v2 §2), for // the reason above stated the other way round: a rollback is a pointer write, // and what it puts in front of every user of the app is a build the channel's // rules exist to govern. A channel demanding a passkey refuses here; one // demanding approvals proposes, pinned to the version this rollback would // land on and flagged hotfix — a rollback is out-of-order by nature, so the // order re-check at execution is the one check it must not meet. func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } p, _ := principalFrom(r.Context()) // allowWrite picks the person or the service write budget from the // principal (svcprincipal.go). A person is keyed on the ADDRESS, not the // token — the same key handleCLINewAccount already uses on this // limiter: keying by token instead would give someone with several live // tokens an independent budget per token, which is the limit doing less // than it should, not more. A service is keyed on its NAME, its own // budget separate from the operator who minted it (issue #220). if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, textTooManyRequests) return } if s.cfg.Key == nil { jsonError(w, http.StatusServiceUnavailable, "no signing key on this host — the console is running read-only") return } var req struct { Channel string `json:"channel"` Message string `json:"message"` } if !readJSON(w, r, &req) { return } message, ok := checkedActivityMessage(w, req.Message) if !ok { return } channel := strings.TrimSpace(req.Channel) if !release.ValidChannel(channel) { jsonError(w, http.StatusBadRequest, "invalid channel name") return } // A deploy credential never gets the legacy stable text below: it is not // a passkey it lacks, it is a rung it does not hold (spec §7.3, D22). // This early call only ever produces a refusal; for stable // deployScopeAllows refuses whatever the pipeline says, and the full check // after the pipeline read stays as it is. Every other principal gets nil. if s.refuseStableOverAPI(w, p, acct, app, channel) { return } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() pipe, perr := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) if perr != nil { log.Printf("console: api rollback %s/%s: read pipeline: %v", acct.Sqid, app, perr) jsonError(w, http.StatusBadGateway, "could not read the pipeline for "+app) return } if err := deployScopeAllows(p, pipe != nil, channel, false); err != nil { s.writeDeployRefusal(w, err) return } cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel) if err != nil { jsonError(w, http.StatusNotFound, "could not read channel "+channel) return } if len(cur.History) == 0 { jsonError(w, http.StatusConflict, "channel "+channel+" has no previous version to roll back to") return } target := cur.History[0] if err := s.checkDaemonVersion(ctx, acct, app, target); err != nil { if isPlanCap(err) { writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) return } log.Printf("console: api rollback %s/%s: plan: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, planCheckFailedCopy) return } // Rollback moves the same pointer promote moves, so it wears the same // service refusal — a credential barred from promoting the control plane // must not reach the identical effect through the other verb. // // It sits LATER in the handler than handleAPIPromote's does, and that // asymmetry is deliberate rather than drift. Promote's runs as soon as // the pipeline is in hand; this one runs after the pointer read and the // empty-history 409 as well, because rollback has no request-supplied // version — `target` only exists once the pointer has been read, and the // rules switch below has to pin it. // // Safe, on three counts. Nothing above this line WRITES: GetPipeline and // Channel are reads, so a refusal here still leaves the request with no // effect, which is the only property the gate owes. Nothing above it // leaks across a boundary either: the byte-identical membership 404 lives // far above in apiAccountApp, so a non-member never reaches any of this. // And the two facts the extra reads can disclose to a barred service — // that the channel resolves, and that it has history — are ones the same // credential can already read straight out of handleAPIChannels, which // carries no control-plane refusal at all because listing pointers is not // release authority. if s.refuseServiceControlPlane(w, p, acct.ID, app, "roll it back") { return } // The channel's declared rules, applied to the version this would land // on. Read after the pointer because a proposal must PIN that version; // nothing above it varies with what the pointer contains, so the // stable refusal is still answered before the bucket is touched. switch rules := channelRules(pipe, channel); { case rules.PromoteApprovals >= 1: s.proposeOverAPI(w, acct, app, kindPromote, promotePayload{Version: target, Channel: channel, Hotfix: true, Rollback: true, ObservedHead: cur.Version}, rules.PromoteApprovals, addr) return case rules.Passkey: s.writePasskeyRefusal(w, acct, app, channel) return } signer := release.RootSigner(s.cfg.Key) var authorize func(*release.Pipeline) error if p.Kind == PrincipalService && p.Role == svcrole.Deploy { sg, serr := s.deploySigner(p) if serr != nil { log.Printf("console: deploy signer for %s/%s: %v", acct.Sqid, app, serr) jsonError(w, http.StatusServiceUnavailable, "could not prepare a signing key for this deploy credential. Nothing was rolled back.") return } signer = sg authorize = deployScopeAuthorize(p, channel) } guard, gerr := s.rollbackDaemonGuard(ctx, acct, app, channel) if gerr != nil { log.Printf("console: api rollback %s/%s: plan: %v", acct.Sqid, app, gerr) jsonError(w, http.StatusBadGateway, planCheckFailedCopy) return } if err := signer.RollbackAuthorized(ctx, s.cfg.Releases, acct.Sqid, app, channel, bothAuthorize(authorize, guard)); err != nil { if isPlanCap(err) { writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) return } var ge *planGuardError if errors.As(err, &ge) { log.Printf("console: api rollback %s/%s: plan: %v", acct.Sqid, app, ge.err) jsonError(w, http.StatusBadGateway, planCheckFailedCopy) return } var ae *release.AuthorizationError if errors.As(err, &ae) { s.writeDeployRefusal(w, ae.Err) return } log.Printf("console: api rollback %s %s: %v", app, channel, err) jsonError(w, http.StatusConflict, err.Error()) return } s.auditActivity(acct, app, addr, Activity{ Action: "rollback", TargetType: "channel", Target: channel, Message: message, Detail: "rolled back " + channel + " to " + target + " (cli)", }) s.bumpSerial(ctx) writeJSON(w, http.StatusOK, map[string]string{"result": "rolled back"}) } // textTooManyRequests is the write budget's refusal over the API, shared by // the rollback door and the pointer digest doors so the text is defined once // (Astra r1 #8). The wording is the shipped one, unchanged. const textTooManyRequests = "too many requests — wait a minute and try again" // refuseStableOverAPI is the API's refusal of a pointer write onto stable // that is not a promotion (rollback, resign; Paul's ruling 2026-08-03): a // deploy credential gets its scope refusal, as it never holds the stable rung, // and everyone else the passkey 403. It reads nothing, so the refusal cannot // vary with the bucket. It returns true when it has written the response. func (s *Server) refuseStableOverAPI(w http.ResponseWriter, p Principal, acct *Account, app, channel string) bool { if rung, _ := release.Rung(channel); rung != release.RungStable { return false } if err := deployScopeAllows(p, false, channel, false); err != nil { s.writeDeployRefusal(w, err) return true } s.writePasskeyRefusal(w, acct, app, release.RungStable) return true } // writePasskeyRefusal is the 403 for a pointer write the channel reserves to // a passkey in the browser, with the console page to finish it on. func (s *Server) writePasskeyRefusal(w http.ResponseWriter, acct *Account, app, channel string) { writeJSON(w, http.StatusForbidden, map[string]string{ "error": channel + " needs a passkey — finish in the console", "console_url": s.appConsoleURL(acct, app), }) } // deployScopeAuthorize is the deploy-scope check a pointer write asks again // inside its admitted mutation, of the pipeline read there. func deployScopeAuthorize(p Principal, channel string) func(*release.Pipeline) error { return func(pp *release.Pipeline) error { return deployScopeAllows(p, pp != nil, channel, false) } } // handleAPIRestart answers `carlos restart`: touch the app's restart // generation, then bump the serial so every box serving it cycles that app's // processes within seconds (issue #113). No version moves, no config is // written, no bake window applies — this is the member-facing way to cycle a // wedged instance, which until now meant a box login the platform's own design // rule forbids ("every carlos command works for a user with zero infra // access"). // // The BUMP is the delivery mechanism, not decoration. Boxes do not poll // restart generations: the agent's existing 2s serial poll is what makes it // look, exactly as with a promote or a config write (internal/host/restart.go). // A touch that landed without a bump would still converge — on the hourly root // pass, up to an hour later — which is the floor, not the promise this door // makes. // // The doors, in order, and why this order — handleAPITrashApp's exactly: // // token — cliUser's one 401, before any name is resolved. // account+app 404 — apiAccountApp: an unknown account, someone else's // account and an app that isn't there are one answer, so // probing this door teaches a stranger nothing. It also // means a TRASHED app cannot be restarted: the live-apps // lookup simply doesn't answer for it. // rate limit — the shared `write` budget, keyed on the ADDRESS like // promote's: a budget belongs to the person, not the // terminal. This door needs it more than most — its whole // effect is to make processes stop and start, so an // unbudgeted one would be a restart loop anyone with a // token could aim at their own app. // Releases nil — 503. The request IS a bucket object; a console with no // bucket has nowhere to put it and must not answer // "requested". // touch + bump — the object, then the announcement, in that order (a bump // for a request that failed to land would wake the fleet // for nothing). // // Audited, unlike a read: this stops and starts customer processes, which // anyone reading audit rows must be able to see. // // No body is read. There is nothing to say beyond the app already named in the // path — there is no restart "options" — and a bearer request carries no // cookie for a cross-site form to forge. // // No per-host scoping HERE, and that is now a division of labour rather than // a limit. This door's generation is per APP: every box serving it cycles its // own routes for that app, which is what makes it the one form every box in // the fleet understands, including the ones that predate narrowing. Narrowed // requests (`--host`, `--environment`) live on their own path and their own // object — apirestartscope.go, discussion #16 — precisely so that a box or a // console that cannot understand a narrowing does NOTHING rather than falling // back to this. Boxes skip their hibernating routes on their own // (internal/host/restart.go) — nothing here wakes anything. func (s *Server) handleAPIRestart(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } p, _ := principalFrom(r.Context()) if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return } if s.cfg.Releases == nil { jsonError(w, http.StatusServiceUnavailable, "no deployment bucket on this host — this console cannot request a restart") return } var req struct { Message string `json:"message"` } // The original restart door had no request body. Keep accepting those // clients; a body is present only when a newer caller supplies context. if r.ContentLength != 0 && !readJSON(w, r, &req) { return } message, ok := checkedActivityMessage(w, req.Message) if !ok { return } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() if err := s.requestAppRestart(ctx, acct.Sqid, app); err != nil { // Logged, not returned: a store error carries bucket names, endpoints // and request ids. The caller gets the fixed sentence every other // bucket failure here gets, and a retry is safe — the touch is // idempotent in the only sense that matters (one more request, one // more cycle). log.Printf("console: api restart %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not request the restart — nothing was cycled; try again") return } s.auditActivity(acct, app, addr, Activity{ Action: "restart", TargetType: "app", Target: app, Message: message, Detail: "requested a restart (cli)", }) // The request is in the bucket; this is what makes boxes LOOK at it — they // read restart generations only when the serial moves. The difference // between "seconds" and "up to an hour" for the one verb people type when // something is stuck. s.bumpSerial(ctx) writeJSON(w, http.StatusOK, map[string]any{ "requested": true, "account": acct.Sqid, "app": app, }) } // ---- trash and restore ------------------------------------------------------ // handleAPITrashApp answers `carlos apps delete`: it moves an app to the // Trash by writing the bucket's trash marker, and marks the console row to // match. Nothing is destroyed here — the 30-day cooloff (release.TrashCooloff) // is what makes this a cheap, reversible verb any MEMBER may reach for with // nothing but a bearer token. The irreversible half (purge) is console-only // and ceremonial, and deliberately has no API at all. // // The doors, in order, and why this order: // // token — cliUser's one 401, before any name is resolved. // account+app 404 — apiAccountApp: an unknown account, someone else's // account and an app that isn't there are one answer, so // probing this door teaches a stranger nothing. // rate limit — keyed on the ADDRESS, like handleAPIPromote: a write // budget belongs to the person, not the terminal. // control plane — 403, outright. An app flagged as the control plane IS // the platform; its release history is what the fleet is // running. Clearing the flag is the separate, deliberate // act that unlocks this door (controlplane.go). // Releases nil — 503. A console with no bucket cannot trash at all: the // marker IS the trash state, and a row-only trash would // un-hide itself at the next materialize pass (which // reconciles rows FROM markers), silently un-deleting the // app the caller was told was gone. // production — ON, or UNREADABLE, refuses (409). Spec §5: the // production flag is the deletion protection — no // checkbox, no break-glass — and when the bucket cannot // say "not production", the platform acts as if it had // said "production", the same direction bake.go fails. // TrashApp — the BUCKET first, then the row. Same order as account // deletion (ownership.go) and for the same reason: this // console's rows are materialized from the bucket, so a // row written first and a marker that never lands is an // app that deletes itself from the console and comes back // on the next pass. Marker-then-row fails the other way — // visibly, recoverably, and self-healing. // TrashAppRow — the row follows the marker, carrying the MARKER's // timestamp, so both halves agree on when the clock // started. // audit — who trashed what, kept past the purge. func (s *Server) handleAPITrashApp(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } p, _ := principalFrom(r.Context()) if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return } // The control plane is not trashable at all (controlplane.go): after the // byte-identical 404 above, so a stranger never learns the app exists, // and before anything is written. if s.refuseControlPlane(w, acct, app, "trashed") { return } if s.cfg.Releases == nil { jsonError(w, http.StatusServiceUnavailable, "no deployment bucket on this host — this console cannot delete an app") return } ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() // Every transition takes the app's lock and settles first (spec §2, §5). // The checks above ran before the lock: ask again. An app already in the // Trash with its marker falls through to the idempotent path below. defer s.lockApp(acct.Sqid, app)() if _, ref := s.recheckUnderLock(ctx, acct, app, "trashed", false, time.Now()); ref != nil { switch ref.Kind { case lockedGone: http.NotFound(w, r) // what apiAccountApp's gate answers for this app now case lockedControlPlane: jsonError(w, ref.Status, ref.Msg) default: // Existing copy (below): the same answer an unreadable marker // already gets from this door, and the retry is safe. jsonError(w, http.StatusBadGateway, "could not confirm the trash window — try again") } return } // (nil, nil) is the flag genuinely ABSENT, which is the default and not // production — most apps never had one. Every other outcome is an error // and refuses. flag, err := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) appURL := s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) if err != nil { // Logged, not returned: a store error carries bucket names, endpoints // and request ids, and this caller gets the same fixed sentence every // other bucket failure in this console gets. log.Printf("console: api trash %s/%s: production flag: %v", acct.Sqid, app, err) writeJSON(w, http.StatusConflict, map[string]string{ "error": "could not read the production setting for " + app + " — when in doubt it's treated as production, so deleting is refused." + " Set it again with the production switch on the app page, then try again.", "console_url": appURL, }) return } if flag != nil && flag.Production { writeJSON(w, http.StatusConflict, map[string]string{ "error": app + " is flagged production, so deleting is refused." + " Turn production off first — the off-switch on the app page (account owner," + " passkey, and a hold on every box) is the only way through — then try again.", "console_url": appURL, }) return } now := time.Now() if err := release.TrashApp(ctx, s.cfg.Releases, acct.Sqid, app, addr, now); err != nil { log.Printf("console: api trash %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not write the trash marker — nothing was deleted; try again") return } // Re-read, because TrashApp is create-only: an app already in the trash — // trashed headless, or by a racing request — keeps its ORIGINAL clock, // and that marker is the ONLY clock in this feature. The auto-purge pass // destroys at the marker's DeletedAt + TrashCooloff, so a window invented // here would over-promise the deadline, and a row stamped with this // request's time would never self-heal: materialize's trash case // short-circuits on an already-trashed row, and TrashAppRow is guarded by // deleted_at = 0. // // So a marker that cannot be re-read is a 502 with NOTHING written to the // row. The marker is on disk either way — the app IS trashed — and the // retry takes the idempotent path and reads the true window out of it. // Answering "trashed, until " is the one // outcome a deletion feature must not produce. tomb, err := release.ReadAppTombstone(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil || tomb == nil { if err != nil { log.Printf("console: api trash %s/%s: re-read marker: %v", acct.Sqid, app, err) } else { // Written and already gone: a restore raced this trash. Same // answer — this request cannot say what the window is. log.Printf("console: api trash %s/%s: marker vanished between write and read", acct.Sqid, app) } jsonError(w, http.StatusBadGateway, "could not confirm the trash window — try again") return } deletedAt := tomb.DeletedAt if err := s.store.TrashAppRowContext(r.Context(), acct.ID, app, deletedAt); err != nil { log.Printf("console: api trash row %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusInternalServerError, "the trash marker was written, but this console's own record did not update —"+ " it will catch up on the next materialize pass") return } s.audit(acct, app, addr, "trash", "moved "+app+" to the trash (cli)") // Box-consumed (carlos-deploy §3), symmetric with the restore door below: // the trash marker is what stops instance minting and drops the app's // routes (provision/reconcile.go, domains/reconcile.go). Without the bump // a trash through this door sits invisible to the fleet until the hourly // floor comes round, while restore already announces in seconds. s.bumpSerial(ctx) // One deadline, everywhere (spec §5). If the row cannot be read back, // promise the SHORTER window: under-promising a restore deadline is // harmless, over-promising is the one outcome a deletion must not produce. until := deletedAt.Add(release.TemporaryCooloff) if life, lerr := s.store.AppLife(acct.ID, app); lerr == nil && life != nil { until = effectivePurgeAt(life.DeletedAt, life.PurgeAfter) } else if lerr != nil { log.Printf("console: api trash %s/%s: read back the deadline: %v", acct.Sqid, app, lerr) } writeJSON(w, http.StatusOK, map[string]any{ "result": "trashed", "restorable_until": until.UTC().Format(time.RFC3339), }) } // handleAPIRestoreApp answers `carlos apps restore`: the undo button. Same // doors and same order as trash, minus the production gate — an app in the // trash cannot be flagged production (§3 refuses ship, promote and // SetProduction to a trashed app), and refusing to UNDO a deletion is // protecting nothing. // // It resolves through apiTrashedApp, so an app that is not in the trash is // the byte-identical 404 an unknown one gets: this door must not become the // way to ask whether somebody's app has been deleted. // // Restoring resurrects NO routes — not the platform apex, not custom domains // (spec §3, the anti-ghost rule: reconcile's minting is route-driven, and // re-minting from pointers would resurrect every deliberately-retired app // forever). The response says so, because nothing else in the terminal will. func (s *Server) handleAPIRestoreApp(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiTrashedApp(w, r) if !ok { return } p, _ := principalFrom(r.Context()) if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return } if s.cfg.Releases == nil { jsonError(w, http.StatusServiceUnavailable, "no deployment bucket on this host — this console cannot restore an app") return } ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() // The bucket first, for trash's reason mirrored: the row is materialized // from the marker, so a row restored while the marker survives is an app // that comes back and then vanishes again on the next pass. // restoreApp (restore.go) is the one restore every door runs: it locks, // settles, checks the temporary cap and orders the row and marker writes. var planCap *planCapError var capErr *temporaryCapError var rf *restoreFailure switch err := s.restoreApp(ctx, acct, app, time.Now()); { case err == nil: case errors.Is(err, errNotInTrash): http.NotFound(w, r) return case errors.As(err, &planCap): writeJSON(w, http.StatusConflict, map[string]any{"error": planCap.Error(), "code": "cap"}) return case errors.As(err, &capErr): writeJSON(w, http.StatusConflict, map[string]any{"error": capErr.Error(), "code": "cap"}) return case errors.As(err, &rf) && rf.Marker: log.Printf("console: api restore %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not remove the trash marker — nothing was restored; try again") return default: log.Printf("console: api restore row %s/%s: %v", acct.Sqid, app, err) // The marker is gone, so the bucket already says restored: tell the // fleet now rather than at the hourly floor. The row catches up on // the next settle. s.bumpSerial(ctx) jsonError(w, http.StatusInternalServerError, "the trash marker was removed, but this console's own record did not update —"+ " it will catch up on the next materialize pass") return } s.audit(acct, app, addr, "restore", "restored "+app+" from the trash (cli)") // Box-consumed (carlos-deploy §3), same as the web door (handleRestoreApp, // ownership.go): removing the trash marker re-enables instance minting for // this app, and minting from records is leg 1 of the fast pass's reconcile. // Without the bump a restore through this door is invisible to the fleet // until the hourly floor comes round. s.bumpSerial(ctx) note := app + " is restored with its releases and channels intact, and with NO routes:" + " run `carlos add ` to serve it again." if release.IsTemporaryName(app) { note = restoredTemporaryNote(app) } writeJSON(w, http.StatusOK, map[string]any{"result": "restored", "note": note}) } // ---- ship (multipart upload) ------------------------------------------------ // apiShipMaxBytes bounds one ship request across every part TOGETHER — meta // plus all artifacts — not any single part on its own. A var, not a const, // so a test can lower it without rebuilding the fixture. 256MiB comfortably // covers today's binaries and a static site tarball in one request. var apiShipMaxBytes int64 = 256 << 20 // apiShipMaxPartsStatic and apiShipMaxPartsBinary bound the NUMBER of file // parts a ship request may carry: one for a static release (the site.tar.gz // name alone), eight for a binary release (comfortably above today's // platform count). r.MultipartReader() has no stdlib cap on part count at all — that // cap exists only on mime/multipart's ReadForm (1000 parts), which // streaming deliberately opts out of. Without a check of our own, a body // full of tiny, validly-named parts fits millions of them inside // apiShipMaxBytes: each one a map entry on the console's heap, and — had it // ever gotten that far — one sequential putImmutable call per part inside // release.Ship until the ship context timed out, orphaning however many // artifact objects had already landed (Ship writes the manifest LAST by // design, so a killed run leaves bytes with no manifest naming them). This // check IS the part-count cap; nothing upstream provides one. const ( apiShipMaxPartsStatic = 1 apiShipMaxPartsBinary = 8 ) // apiShipMetaMaxBytes bounds the "meta" part's own JSON — separately from // apiShipMaxBytes, which bounds the whole request (meta plus every // artifact). release.MaxNotesLen is the biggest single field meta carries; // the extra 8KiB is headroom for everything else in the same object // (version, kind, label, and a capability block). A rootfs release's // processes object adds up to release.MaxProcessesBytes. Still tiny next to // apiShipMaxBytes, and still read inside the same semaphore-bounded // handler, so this is a decode-shape bound, not a new resource exposure. const apiShipMetaMaxBytes = release.MaxNotesLen + release.MaxProcessesBytes + 8<<10 // The ship door's answers that its rootfs half (apiship_rootfs.go) gives // too. shipKindUnchecked is a kind check that could not be made // (RFS-1-C79): a store error, or an app whose releases cannot be read. const ( shipFlagsUnreadable = "could not check this app's control-plane status — nothing was shipped" shipStoreFailed = "could not store the release — try again" shipKindUnchecked = "could not check this app's releases. nothing was shipped. try again" ) // shipContentConflict is the 409 for a version already shipped with other // artifacts. func shipContentConflict(ver string) string { return "version " + ver + " was already shipped with different content — versions are immutable; ship under a new version" } // targetRe validates a binary artifact's part NAME before it is used to // build a bucket key: release.ArtifactKey joins it straight into the object // path, so this check is path-traversal defense, not tidiness — a part // named "../../etc/passwd" must never reach release.Ship as a file name. var targetRe = regexp.MustCompile(`^[a-z0-9]+-[a-z0-9]+$`) // shipMaxBytesRefused answers 413 if err is the http.MaxBytesReader wrapped // around the request body cutting a read short, and reports whether it did // — the caller returns immediately when this is true, and falls through to // its own (more specific) 400 otherwise. func shipMaxBytesRefused(w http.ResponseWriter, err error) bool { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { jsonError(w, http.StatusRequestEntityTooLarge, "upload too large") return true } return false } // shipImmutableConflict reports whether err is release.Ship's refusal to // overwrite an existing version's object with DIFFERENT bytes — a // permanent conflict a retry can never fix, unlike every other error Ship // returns (a store/IO failure, which a retry might actually resolve). // // release.Ship/putImmutable (internal/release/release.go) expose no // sentinel or typed error for this specific case — the only signal // available is the literal wording putImmutable formats ("... already // exists with different content — refusing to overwrite an immutable // release object"), so this is a STRING match against release.go's current // wording, not a durable error-type check (errors.Is/As have nothing to // grab onto here). If that wording ever changes in release.go, this // silently stops firing and the conflict falls back to the 502 below — // worth revisiting if release.go ever grows a proper sentinel for it. // // validateManifest's errors also return from Ship along this same // unwrapped path, but none of its messages ("invalid app...", "invalid // kind...", "manifest has no artifacts", etc) contain this wording, so they // can never match here. They are unreachable in this branch for an // unrelated reason: every field validateManifest checks (app, version, // kind, artifact file names, sha256, size) was already validated by // handleAPIShip itself, using the same rules, before Ship was ever called. func shipImmutableConflict(err error) bool { return strings.Contains(err.Error(), "already exists with different content") } // shipArtifactsMatch reports whether two artifact maps describe the exact // same shipped CONTENT — same file set, same bytes (by sha256+size) for // each. BuiltAt/BuiltBy are deliberately excluded: they are metadata, not // content, and both handleAPIShip and the direct `carlos ship` CLI path // stamp a fresh BuiltAt into every manifest they build, so comparing // manifests wholesale (as release.Ship's own putImmutable does, // byte-for-byte) would call a genuinely identical re-ship a conflict for a // reason no caller could ever avoid. func shipArtifactsMatch(a, b map[string]release.Artifact) bool { if len(a) != len(b) { return false } for k, av := range a { bv, ok := b[k] if !ok || av.File != bv.File || av.SHA256 != bv.SHA256 || av.Size != bv.Size { return false } } return true } // capsRefusal turns a caps error into the caller-facing 400 body. // // caps writes its own refusals for a human, and every one of them opens with // "capabilities: " — those pass through verbatim, because naming the offending // capability and the fix is the whole point of them. What must NOT pass // through is encoding/json's own message for a value that is not even an // object ("json: cannot unmarshal number into Go value of type caps.Set"): // it leaks an internal type name, carries no prefix, and tells the caller // nothing they can act on. Those become one fixed line, in the same register // as this handler's other 400s ("malformed meta part"). func capsRefusal(err error) string { if msg := err.Error(); strings.HasPrefix(msg, "capabilities: ") { return msg } return "malformed capabilities block — want the manifest's capabilities object" } // shipCapabilitiesMatch reports whether two capability blocks are the SAME // DECLARATION, comparing their CANONICAL JSON ENCODINGS rather than the // structs. // // Same declaration, not "same meaning": list ORDER is significant, so // ports [{25},{587}] and [{587},{25}] do not match and a re-ship between them // is a 409. That is deliberate and it is the conservative direction — under // immutability the question being asked is "are these the same bytes of // declaration", and sorting here would be this function quietly deciding that // two manifests an operator can see differ are the same release. // // Comparing ENCODINGS rather than structs is not a stylistic choice either. // caps.Set carries an unexported notes slice (the // unknown-field-inside-a-known-capability lines its decoder collected), so a // decoded Set is neither ==-comparable nor reflect.DeepEqual-safe against one // built any other way once those notes are non-empty — two blocks that reach // the manifest as identical bytes would compare unequal because one of them // was written with a field this binary ignored. The JSON encoding // carries exactly the fields that reach the manifest and nothing else, and // Set has no custom marshaller, so the encoding is deterministic (struct // field order) and total. A nil block encodes as "null" on both sides, which // is why nil == nil falls out with no special case. func shipCapabilitiesMatch(a, b *caps.Set) bool { ja, aerr := json.Marshal(a) jb, berr := json.Marshal(b) if aerr != nil || berr != nil { // Unreachable for a Set (no marshaller to fail), and if it ever // happens the honest answer is "these are not known to match" — which // refuses the re-ship rather than waving it through. return false } return bytes.Equal(ja, jb) } // shipMeta is a ship request's "meta" part. type shipMeta struct { Version string `json:"version"` Kind string `json:"kind"` // Label and Notes are the human half of a manifest (release // versioning spec): a table-cell summary and a changelog fragment, // both optional and both metadata rather than content — a re-ship of // the same bytes tolerates a different value for either, the same // way it already tolerates a different BuiltAt. Label string `json:"label,omitempty"` Notes string `json:"notes,omitempty"` Message string `json:"message,omitempty"` // Capabilities is the app's declaration (internal/caps), ADDITIVE: // absent from every request a CLI shipped before this field existed, // and absent from every request that does not pass -capabilities. It // is held raw so the refusal below carries caps's own message rather // than "malformed meta part" — an unknown capability name is a // specific, actionable thing to be told. Capabilities json.RawMessage `json:"capabilities,omitempty"` // ProgramChange is `carlos ship --program-change`: the operator's // statement that this release's Go main package really differs from // the app's previous release. Absent from every request a CLI shipped // before the flag existed, which then meets the refusal below. ProgramChange bool `json:"program_change,omitempty"` // Swap is the release's own demand (zero-downtime spec §4.2): "hold" // or absent. ADDITIVE: absent from every request without --swap. Swap string `json:"swap,omitempty"` // Processes, Hibernate, JIT and RootfsSize belong to a rootfs release // (rootfs apps Plan 1) and to no other kind. Processes is held raw so // a refusal is release.DecodeProcesses' own sentence; RootfsSize is the // image's length, declared so the door can refuse an image too large // before reading it, and bound the body to it. Processes json.RawMessage `json:"processes,omitempty"` Hibernate bool `json:"hibernate,omitempty"` JIT bool `json:"jit,omitempty"` RootfsSize int64 `json:"rootfs_size,omitempty"` } // handleAPIShip answers `carlos ship`: a multipart upload of one release's // artifacts. Hashes and sizes are computed HERE, from the bytes that // actually arrived on the wire, and handed to release.Ship as // already-filled Artifact{SHA256,Size} — Ship itself recomputes and // cross-checks them (release.go), so there is no way for a caller to lie // about either even in principle: the wire shape below has no field to lie // in (the meta part carries only version, kind, and the app's capability // declaration — and that last one is re-validated here against caps's own // closed vocabulary before an artifact byte is read). // // Streamed, never buffered to disk: r.MultipartReader() reads one part at a // time. ParseMultipartForm was ruled out on purpose — it spills whatever // doesn't fit its own in-memory threshold to a temp file under an // INDEPENDENT size limit, one that would sit alongside apiShipMaxBytes // rather than being governed by it. Each part is still read fully into // memory here, because release.Ship's own shape (map[string][]byte) leaves // no smaller option — the same buffering the CLI's own direct-ship path // already does with os.ReadFile — but the MaxBytesReader wrapped around the // whole request body below is what actually caps the total. // // A rootfs release is the exception (rootfs apps Plan 1): its image, up to // 2 GiB, is spooled to the seed scratch disk instead of memory, and its // meta part declares the image's size so the body's limit can drop to fit // it (apiship_rootfs.go). func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } p, _ := principalFrom(r.Context()) // allowShip picks the budget: a person stays keyed on the TOKEN, not the // address — a ship is heavy (bytes on the wire, an object written per // artifact), so a token is the natural principal for an upload, unlike // a write budget. (The other half of that reasoning, "ships are rare", // no longer holds and is no longer load-bearing here: see // cliShipWindow.) Keying by address instead would make a laptop and a // CI runner sharing one human's identity share one shipping budget // between them, which is the limit doing less than it should, not // more. A service gets its own budget, keyed on its NAME // (svcprincipal.go), separate from the operator who minted it. // // The refusal names the wait and stamps Retry-After. "Wait a while", // which is what this said while the window was an hour, is the answer // that wedges the caller who meets this budget most: an agent shipping // in a loop cannot tell a two-second pause from an hour-long lockout, // so it reports that shipping is broken. cliShipWindow's move to a // minute is what makes the number worth printing at all. if ok, wait := s.allowShip(p, r, time.Now()); !ok { retryAfter(w, wait) jsonError(w, http.StatusTooManyRequests, fmt.Sprintf( "too many uploads — try again in %ds", max(int(wait/time.Second), 1))) return } // The rate limiter bounds requests over TIME; it says nothing about how // many run AT ONCE. A ship holds a goroutine and a substantial buffer // open for as long as the client trickles bytes in, so a handful of // slow uploads in parallel is a resource-exhaustion path the counter // above does nothing to close. Non-blocking: a full semaphore means // "busy right now", answered immediately, rather than a request that // queues indefinitely behind someone else's slow upload. select { case s.shipSem <- struct{}{}: default: // Seconds, not the budget window: this refusal clears as soon as an // upload in flight finishes, which is a different and much shorter // wait than the rate limiter's. Stamped for the same reason — // "shortly" is not a number a script can sleep on. retryAfter(w, 2*time.Second) jsonError(w, http.StatusTooManyRequests, "another ship is in progress — try again shortly") return } // The slot is given back once, whichever comes first: the handler's // return, or a rootfs ship that holds its scratch reservation, which // bounds rootfs uploads by itself (one per app, two in all) and may // then run for over an hour (shipRootfsParts). releaseShip := sync.OnceFunc(func() { <-s.shipSem }) defer releaseShip() if s.shipStarted != nil { s.shipStarted <- struct{}{} } // Bounds the read side in TIME, not just in bytes: with no deadline, a // client sending one byte a second could hold this goroutine (and // whatever it has already buffered) open for hours — the semaphore // above only bounds how many can do that at once, not for how long. // http.ErrNotSupported is tolerated because some transports (test // servers among them, in principle) don't implement the underlying // deadline setter; the semaphore is still there as a backstop on those. rc := http.NewResponseController(w) if err := rc.SetReadDeadline(time.Now().Add(10 * time.Minute)); err != nil && !errors.Is(err, http.ErrNotSupported) { log.Printf("console: api ship set read deadline: %v", err) } defer func() { if err := rc.SetReadDeadline(time.Time{}); err != nil && !errors.Is(err, http.ErrNotSupported) { log.Printf("console: api ship clear read deadline: %v", err) } }() // The body's ceiling is the largest request any kind can make: a rootfs // image with its meta and framing. Once the meta part says the kind, // the limit drops to what that kind may carry (below), so a binary or // static ship is still bounded by apiShipMaxBytes. ceiling := apiShipMaxBytesRootfs + apiShipMetaMaxBytes + apiShipFramingBytes raw := r.Body if s.shipBodyWrap != nil { raw = s.shipBodyWrap(raw) } upload := newShipBody(http.MaxBytesReader(w, raw, ceiling), ceiling) r.Body = upload mr, err := r.MultipartReader() if err != nil { jsonError(w, http.StatusBadRequest, "expected a multipart/form-data body") return } first, err := mr.NextPart() if err != nil { if shipMaxBytesRefused(w, err) { return } jsonError(w, http.StatusBadRequest, "empty upload") return } if first.FormName() != "meta" { jsonError(w, http.StatusBadRequest, `the first part must be named "meta"`) return } var meta shipMeta // The budget is release.MaxNotesLen — the biggest single field the meta // part now carries — plus 8KiB of headroom for everything else in it: // version, kind, label, and a capability block (a few hundred bytes, the // CLI's canonical re-marshal rather than a file verbatim). Before Notes // existed this reader was a flat 8KiB; capping it at that today would // truncate notes anywhere above ~8KiB mid-JSON, so a caller within // release.MaxNotesLen would meet "malformed meta part" instead of the // notes-specific refusal below. if err := json.NewDecoder(io.LimitReader(first, apiShipMetaMaxBytes)).Decode(&meta); err != nil { jsonError(w, http.StatusBadRequest, "malformed meta part") return } message, ok := checkedActivityMessage(w, meta.Message) if !ok { return } switch meta.Kind { case "binary", "static", release.KindRootfs: default: jsonError(w, http.StatusBadRequest, `invalid kind (want "binary", "static" or "rootfs")`) return } rootfs := meta.Kind == release.KindRootfs switch { case rootfs: if s.refuseTemporary(w, acct, app, "ship a rootfs release to it") { return } case meta.Kind != "static": if s.refuseTemporary(w, acct, app, "ship a binary release to it") { return } } // Checked before the version is minted or validated: NextIteration below // lists the app's whole releases prefix, and a label/notes refusal should // not pay for that bucket round trip first. if err := release.ValidateLabelNotes(meta.Label, meta.Notes); err != nil { jsonError(w, http.StatusBadRequest, err.Error()) return } ver := strings.TrimSpace(meta.Version) // Whether THIS console chose the version, rather than the caller. It // changes what a control-plane refusal can honestly advise below: a // version minted here cannot possibly be the one stamped into a binary // the operator built before the request. minted := ver == "" if ver == "" { // Mint from the app's version target (release versioning spec): no // target, no mint — and the refusal names the fix. This runs inside // the ship semaphore acquired above, which is what makes allocation // race-free on one console. vt, _, err := release.GetTarget(r.Context(), s.cfg.Releases, acct.Sqid, app) if err != nil { jsonError(w, http.StatusBadGateway, "could not read the version target") return } if vt == nil { jsonError(w, http.StatusBadRequest, "no version given and no version target set — pass --version, or set a target with `carlos version target`") return } t, ok := release.ParseSemVer(vt.Target) if !ok { jsonError(w, http.StatusConflict, "the stored version target "+vt.Target+" is not a semver — set it again") return } ver, err = release.NextIteration(r.Context(), s.cfg.Releases, acct.Sqid, app, t) if err != nil { jsonError(w, http.StatusBadGateway, "could not allocate the next iteration") return } } else if !release.ValidVersion(ver) || ver == "dev" { jsonError(w, http.StatusBadRequest, "invalid version") return } // Decoded and validated HERE — before a single artifact part is read, let // alone buffered — so a bad declaration costs the caller nothing but the // meta part. release.Ship would reach the same verdict through // validateManifest, but only after every byte had crossed the wire, and // not at all on the already-shipped short-circuit below. var declared *caps.Set if len(meta.Capabilities) > 0 { if err := json.Unmarshal(meta.Capabilities, &declared); err != nil { jsonError(w, http.StatusBadRequest, capsRefusal(err)) return } if declared == nil { jsonError(w, http.StatusBadRequest, "capabilities: null — omit the key to ship no capabilities") return } if err := caps.ValidCapabilities(app, declared); err != nil { jsonError(w, http.StatusBadRequest, capsRefusal(err)) return } // Plans 3b: a daemon never sleeps, and every plan's apps do. Before // the identical-reship short-circuit, so a re-ship cannot slip past. if declared.Daemon { if err := s.checkDaemon(acct, app); err != nil { if isPlanCap(err) { writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) return } log.Printf("console: api ship %s/%s: plan: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, planCheckFailedCopy) return } } } if refuseBadSwap(w, meta.Swap) { return } // What the rest of the body may hold, now that the meta says the kind. var procs map[string]release.Process if rootfs { var ref *doorRefusal if procs, ref = rootfsMetaRefusal(meta); ref != nil { refuseUnread(w, ref.Status, ref.Msg) return } upload.lower(apiShipMetaMaxBytes + meta.RootfsSize + apiShipFramingBytes) } else { if len(meta.Processes) > 0 || meta.Hibernate || meta.JIT || meta.RootfsSize != 0 { jsonError(w, http.StatusBadRequest, release.ErrRootfsFieldsOnly.Error()) return } upload.lower(apiShipMaxBytes) } // An app's kind and hibernation are fixed by its first ship, so a // release of another kind, or a rootfs release with the other // hibernation, is refused here, before any part is read. The check is // advisory: release.Ship and release.ShipSpooled repeat it at // publication, which is what decides a race between two first ships. kctx, kcancel := context.WithTimeout(r.Context(), 20*time.Second) stub := release.Manifest{App: app, Version: ver, Kind: meta.Kind, Hibernate: meta.Hibernate} kerr := release.CheckShipKind(kctx, s.cfg.Releases, acct.Sqid, &stub) kcancel() if kerr != nil { if msg, ok := shipKindConflict(kerr); ok { refuseUnread(w, http.StatusConflict, msg) return } log.Printf("console: api ship %s/%s %s: kind check: %v", acct.Sqid, app, ver, kerr) refuseUnread(w, http.StatusServiceUnavailable, shipKindUnchecked) return } if rootfs { s.shipRootfsParts(w, r, mr, shipRootfsReq{addr: addr, acct: acct, app: app, ver: ver, message: message, meta: meta, procs: procs, releaseShip: releaseShip}) return } maxParts := apiShipMaxPartsBinary if meta.Kind == "static" { maxParts = apiShipMaxPartsStatic } files := make(map[string][]byte) artifacts := make(map[string]release.Artifact) for { part, err := mr.NextPart() if err == io.EOF { break } if err != nil { if shipMaxBytesRefused(w, err) { return } jsonError(w, http.StatusBadRequest, "malformed multipart body") return } // Checked before anything else touches this part — including its // name, and certainly before io.ReadAll — because the cost this // guards against is per-PART, not per-byte: a body of nothing but // tiny, validly-named parts must be refused on the (maxParts+1)th // one, not after buffering it. if len(files) >= maxParts { jsonError(w, http.StatusBadRequest, fmt.Sprintf("too many file parts (max %d for a %s release)", maxParts, meta.Kind)) return } name := part.FormName() if meta.Kind == "static" { if name != "site.tar.gz" { jsonError(w, http.StatusBadRequest, `a static release ships exactly one file part, named "site.tar.gz"`) return } } else if !targetRe.MatchString(name) { jsonError(w, http.StatusBadRequest, "invalid artifact name "+name) return } // A second part claiming a name already seen is refused rather than // silently overwriting the first: both would otherwise be charged // against apiShipMaxBytes and apiShipMaxPartsBinary/Static, but only // the last one's bytes would ever reach the manifest — a caller // deserves an error, not a release that quietly ships less than it // paid to upload. if _, dup := files[name]; dup { jsonError(w, http.StatusBadRequest, "duplicate artifact part "+name) return } b, err := io.ReadAll(part) if err != nil { if shipMaxBytesRefused(w, err) { return } jsonError(w, http.StatusBadRequest, "could not read part "+name) return } sum := sha256.Sum256(b) files[name] = b artifacts[name] = release.Artifact{ File: name, SHA256: hex.EncodeToString(sum[:]), Size: int64(len(b)), } } if len(files) == 0 { jsonError(w, http.StatusBadRequest, "no artifact parts in the upload") return } // The app's row is read ONCE for the rest of this handler: the // control-plane verdict immediately below and the build-probe echo on // both success paths are two columns of the same row, and a ship has no // reason to ask twice. // // It sits deliberately BEFORE release.Ship, and it FAILS THE SHIP CLOSED: // an unreadable row is answered 503 with nothing written. // // THIS SUPERSEDES AN EARLIER DESIGN, and the history holds both shapes, // so which reasoning won is written here. The build-probe echo used to // read this row for itself, AFTER release.Ship had stored the bytes, and // the ruling there was that a failed read must not fail the ship — // correct in that position, because the ship had already succeeded and // refusing bought nothing. Reading once moves the read ahead of the // write, and the premise goes with it: there is no stored release to be // honest about. // // The two policies cannot be reconciled from ONE read, so one has to // dominate, and it is the GUARD's. If the console cannot read this row it // does not know whether this app is the control plane, and shipping // anyway is exactly "a database blip is the way past the guard" — the // failure every other reader of this flag holds closed (controlplane.go). // The echo is a convenience; the guard is what stands between a // wrong-architecture binary and every box that runs the console. A // convenience does not get to set the failure policy of a guard. // // The exposure this buys is small: apiAccountApp has already read the // same table (HasAppIn) before this line, and migrate() runs at Open(), // so a row unreadable HERE but readable THERE is a column-level fault on // a console that started successfully — reachable in a test by dropping // one column, not by an ordinary outage. // // One asymmetry to know about, and accepted rather than overlooked: a // MISSING row falls open where an UNREADABLE row fails closed. AppFlags // maps sql.ErrNoRows to (false, "", nil) — "not the control plane, no // probe", which is the right reading of an app that no longer exists — // so an app purged between the gate and this line would ship unchecked. // It is acceptable for the same reason that mapping exists: apiAccountApp // has already answered its byte-identical 404 for an app that is gone, so // reaching here with no row means losing a race with a purge of the very // app being shipped to, and the release lands in a prefix nothing points // at. Fixing it belongs in AppFlags with its own callers considered, not // here. cp, probe, err := s.store.AppFlags(acct.ID, app) if err != nil { log.Printf("console: api ship %s/%s: read app flags: %v", acct.Sqid, app, err) jsonError(w, http.StatusServiceUnavailable, shipFlagsUnreadable) return } // Read the bytes that actually arrived, before a single one is written. // // The static exemption is an ASSUMPTION, stated here because it is the // shape a bypass would take: both checks read an ELF (machine header, // embedded build info) and a static release ships one gzipped tarball, // which neither can parse — so `-kind static` skips them entirely. That // is sound only while no control-plane app ships static. Nothing refuses // one that does, deliberately: inventing constraints on what a // control-plane app may BE is not this guard's business. But anyone // making the console (or any control-plane app) a static release is // removing these two checks by doing so, and should replace them rather // than discover the gap later. if cp && meta.Kind != "static" { if err := checkControlPlaneArtifacts(files, ver); err != nil { msg := err.Error() // A MINTED version is a dead end for these checks, and the // refusal has to say so or it sends the operator in a circle: // the version was chosen here, after their binary was linked, so // no stamp could have matched it — and rebuilding with the stamp // the message names only loses to the NEXT mint. `carlos deploy` // never lands here (it defaults -version to the git short head), // but a flagged app with a version target shipped through // `carlos ship` does. if minted { msg += " — and this version was minted at the door, so no build could have carried it: pass --version explicitly for a control-plane app" } jsonError(w, http.StatusBadRequest, msg) return } } m := release.Manifest{ Schema: 1, App: app, Version: ver, Kind: meta.Kind, BuiltBy: "console-api " + version.Build, BuiltAt: time.Now().UTC().Format(time.RFC3339), Artifacts: artifacts, Capabilities: declared, // nil when the meta part carried no key Label: meta.Label, Notes: meta.Notes, Swap: meta.Swap, } // Longer than the other CLI doors' 15-20s: this one moves real bytes — // up to apiShipMaxBytes of them — into the bucket, not a handful of // short strings. ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) defer cancel() // A version's objects are immutable once shipped, but BOTH callers that // build a manifest like this one (this handler and the direct `carlos // ship` CLI path) stamp BuiltAt with a fresh time.Now() on every // invocation. Comparing the freshly-marshaled manifest byte-for-byte // against whatever release.Ship's own putImmutable finds already // stored — which is exactly what it does — would make a genuinely // identical re-ship register as a "different content" conflict for a // reason no caller could ever avoid: its OWN timestamp. Comparing // ARTIFACTS here first — content, not metadata — is what makes // re-shipping the exact same bytes twice actually succeed instead of // permanently wedging on its own BuiltAt. // Label and Notes are checked NOWHERE below this point — deliberately. // They are metadata about a version, not content, the same tolerance // BuiltAt already gets: a re-ship of an existing version with a // DIFFERENT label or notes still succeeds through the short-circuit // below. The STORED manifest keeps its ORIGINAL label/notes — nothing // is written on this path, so a later ship can never rewrite them. if existing, gerr := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, ver); gerr == nil { if !shipArtifactsMatch(existing.Artifacts, artifacts) { jsonError(w, http.StatusConflict, shipContentConflict(ver)) return } // Artifacts alone are not the whole of what a version means any more. // The capability block decides the SHAPE OF THE UNIT the bytes run // in — which user, which privileged ports, which runtime dir — so a // re-ship of the same bytes with a different block is a different // release in every way that matters on a box. Without this check the // short-circuit below would answer 200 and silently keep the FIRST // block, which is the immutability promise broken for exactly the // field that changes the most about how the app runs. if !shipCapabilitiesMatch(existing.Capabilities, declared) { jsonError(w, http.StatusConflict, "version "+ver+" was already shipped with different capabilities — versions are immutable; ship under a new version") return } if refuseSwapConflict(w, existing.Swap, meta.Swap, ver) { return } // Identical content already shipped: answer success without // writing anything — calling release.Ship again would only // rediscover this the slow way, and would in fact trip its OWN // manifest-level putImmutable conflict (see above), since BuiltAt // drifts every call even when the artifacts don't. 200, not 201: // nothing was created by this request. out := make(map[string]any, len(existing.Artifacts)) for name, a := range existing.Artifacts { out[name] = map[string]any{"sha256": a.SHA256, "size": a.Size} } body := map[string]any{"result": "shipped", "version": ver, "artifacts": out} // The stored block, not `declared` — they are equal by the check just // above, and echoing what is ON RECORD is the honest answer to "what // did this version end up declaring". addCapabilitiesEcho(body, existing.Capabilities) addSwapEcho(body, existing.Swap) addBuildProbe(body, probe) writeJSON(w, http.StatusOK, body) return } // The wrong-program rule, the same one the direct-bucket `carlos ship` // calls: a release whose Go main package differs from the app's previous // release is refused unless the caller said --program-change. It reads // the artifact bytes that arrived, so it runs after m exists and before // anything is written, but AFTER the identical-reship short-circuit // above: re-shipping a version that is already on record must stay // idempotent (a CI retry of an older version after a program change is // still a 200), so only a NEW version meets the rule. A mismatch is a 409 (the request is well // formed, the app's history disagrees); anything else is a 400, the // artifact or the app's releases being unreadable. if err := release.CheckShipProgram(ctx, s.cfg.Releases, acct.Sqid, &m, files, meta.ProgramChange); err != nil { var pm *release.ProgramMismatchError if errors.As(err, &pm) { jsonError(w, http.StatusConflict, err.Error()) return } jsonError(w, http.StatusBadRequest, err.Error()) return } if err := release.Ship(ctx, s.cfg.Releases, acct.Sqid, m, files); err != nil { log.Printf("console: api ship %s %s: %v", app, ver, err) // Publication repeats the kind check, so a lost race between two // first ships is refused here, as it would have been at the door. if shipKindRefusedAtPublication(w, err) { return } if shipImmutableConflict(err) { // A permanent conflict, not a transient one: reachable when this // raced against another ship of the same version (the // GetManifest check above is not atomic with the Ship call // below), not through the common path above. Retrying this // exact request will never succeed, so it gets its own status // rather than the retry-flavored 502 below. jsonError(w, http.StatusConflict, shipContentConflict(ver)) return } // Anything else is the store failing, not the caller: a 502, // matching the other doors' convention that a bucket-side error is // never described to the caller in bucket-side terms. jsonError(w, http.StatusBadGateway, shipStoreFailed) return } s.auditActivity(acct, app, addr, Activity{ Action: "ship", TargetType: "release", Target: ver, Message: message, Detail: "shipped " + ver + " (cli)", }) out := make(map[string]any, len(artifacts)) for name, a := range artifacts { out[name] = map[string]any{"sha256": a.SHA256, "size": a.Size} } body := map[string]any{"result": "shipped", "version": ver, "artifacts": out} addCapabilitiesEcho(body, m.Capabilities) addSwapEcho(body, m.Swap) addBuildProbe(body, probe) writeJSON(w, http.StatusCreated, body) } // addBuildProbe adds the app's build-probe path to a ship response, so // `carlos deploy` learns it from the call it was already making. The probe is // what the deploy watch polls for a build the app's own PROCESS reports // instead of the edge's route-row header (deploy.go, watchTarget.Probe). // // It rides the ship response rather than a door of its own because deploy // always ships before it watches: the value arrives with no extra round trip, // and the ship response is already scoped to exactly the app whose probe this // is. The value comes from the app ROW (Store.AppFlags) — never from // provision.Record, whose handleAPIInstanceUpdate is a read-modify-write that // would silently drop a field it does not know: the probe is app-level, not // per-instance. // // It takes the probe rather than reading it because handleAPIShip already // read that row, for the control-plane check that must happen before any // bytes are written — so the read moved UP, ahead of release.Ship, and this // function is now only the echo. With the move, this function's original // "a failed read must not fail the ship" ruling is SUPERSEDED: the read now // fails the ship closed, because the guard sharing it cannot fall through. // The reasoning is recorded in full at the read site. // // Additive: a client that does not read the key is unaffected, and an app // with no probe carries no key at all, so every ship the fleet does today // answers byte for byte as before. func addBuildProbe(body map[string]any, probe string) { if probe != "" { body["build_probe"] = probe } } // addCapabilitiesEcho adds the "capabilities" key to a ship response: the // canonical JSON of the block that was actually STORED, or nothing at all when // the version declares none. // // It exists to close the removal signal (2026-08-03 review, I3). `capabilities` // is an unknown JSON key to any console built before it existed, and Go's // decoder ignores unknown keys — so an un-upgraded console meeting a `carlos // ship -capabilities` request drops the block on the floor, writes a manifest // that declares NOTHING, and answers 201. On the box that manifest is not // "nothing new"; it is a manifest that resolves and declares no capabilities, // which is the REMOVAL instruction — converge deletes the drop-ins, and :25 // comes off a running mail server because a binary somewhere was old. The // deploy order in §3b exists to prevent exactly this, and an echo is what // makes violating it loud instead of silent: a client that sent a block and // gets no echo back knows it is talking to a console that did not understand // it, before the release is ever promoted. // // Additive in the direction that costs nothing: a client that does not read // the field is unaffected, and a version with no capabilities carries no key — // so the response bytes for every ship the fleet does today are unchanged. func addCapabilitiesEcho(body map[string]any, set *caps.Set) { if set == nil { return } b, err := json.Marshal(set) if err != nil { // Unreachable — caps.Set has no custom marshaller — and omitting the // key is the fail-closed direction: a client that sent capabilities // refuses on a missing echo, which is the same verdict it would reach // if this console were the old one. log.Printf("console: api ship: marshal capabilities echo: %v", err) return } body["capabilities"] = json.RawMessage(b) }