| 1 | // The per-app production flag — the console bake controls spec |
| 2 | // (docs/superpowers/specs/2026-08-04-console-bake-controls-design.md, §2–§3). |
| 3 | // |
| 4 | // production: true means the supply-chain bake window (bake.go) applies to |
| 5 | // this app's held channels; false — and MISSING, the default — means its |
| 6 | // boxes adopt immediately. The object is root-signed in the pointer's own |
| 7 | // signedWrapper envelope, and root-ONLY: a wrapper carrying a key grant is |
| 8 | // refused before its signature is even checked, because a scoped key that |
| 9 | // could flip the flag could skip the very window it bakes under. |
| 10 | // |
| 11 | // What verification here does NOT decide: whether a missing or unreadable |
| 12 | // flag downgrades a box that once saw production: true. It never does — |
| 13 | // that memory, the ratchet and the replay floor are the box's own |
| 14 | // (bake.go's production memory), because anyone with bucket write but no |
| 15 | // key can delete an object, and deletion must not be a downgrade (spec §4). |
| 16 | package release |
| 17 | |
| 18 | import ( |
| 19 | "context" |
| 20 | "crypto/ed25519" |
| 21 | "encoding/base64" |
| 22 | "encoding/json" |
| 23 | "errors" |
| 24 | "fmt" |
| 25 | "os" |
| 26 | "time" |
| 27 | ) |
| 28 | |
| 29 | // ProductionFlag is the signed payload: one app's declaration that it is (or |
| 30 | // is not) in production, and therefore whether its releases bake. |
| 31 | type ProductionFlag struct { |
| 32 | Schema int `json:"schema"` // 1 |
| 33 | Account string `json:"account"` |
| 34 | App string `json:"app"` |
| 35 | Production bool `json:"production"` |
| 36 | // Actor is who flipped it — a console member's address. Recorded for the |
| 37 | // same reason the pointer's audit trail exists: an invisible bypass is a |
| 38 | // broken gate. |
| 39 | Actor string `json:"actor,omitempty"` |
| 40 | SetAt time.Time `json:"set_at"` |
| 41 | } |
| 42 | |
| 43 | // ProductionKey is where an app's flag lives: beside channels/ and |
| 44 | // releases/, deliberately NEVER under ChannelsPrefix — those listings are |
| 45 | // parsed as channel pointers by the console and the CLI. |
| 46 | // |
| 47 | // WARNING to anything that copies objects under an app prefix (`carlos |
| 48 | // accounts migrate` is the one such thing today): this object must be |
| 49 | // RE-DECLARED at a new account, never copied. Its signed payload names the |
| 50 | // account, so a byte-identical copy fails validateProductionFlag at the |
| 51 | // destination forever — which every box reads as "unreadable, keep baking", |
| 52 | // holding the app's held heads with no expiry and no error anyone sees. |
| 53 | func ProductionKey(acct, app string) string { |
| 54 | return appPrefix(acct, app) + "production.json" |
| 55 | } |
| 56 | |
| 57 | // SetProduction writes the flag, root key only, CAS over whatever is there |
| 58 | // (a concurrent flip surfaces as ErrCASConflict — re-ask, never overwrite |
| 59 | // blind). SetAt is stamped here; boxes use their OWN sighting for the |
| 60 | // ratchet and this stamp only as a replay floor. |
| 61 | func claimRawSetProduction(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app string, production bool, actor string) error { |
| 62 | if len(priv) != ed25519.PrivateKeySize { |
| 63 | return fmt.Errorf("set production: no usable release signing key") |
| 64 | } |
| 65 | if !ValidAccount(acct) { |
| 66 | return fmt.Errorf("set production: invalid account %q", acct) |
| 67 | } |
| 68 | if !ValidApp(app) { |
| 69 | return fmt.Errorf("set production: invalid app %q", app) |
| 70 | } |
| 71 | // A trashed app cannot be declared production, in EITHER direction: §3 |
| 72 | // already refuses ship/promote to one, and a production flag appearing |
| 73 | // on an app in the trash would contradict the flag's own promise |
| 74 | // (production = never deletable). There is nothing legitimate to do to |
| 75 | // a trashed app's flag; restore first. Unreadable refuses too — the |
| 76 | // usual direction: a caller about to declare production must never act |
| 77 | // as though a marker it cannot verify simply isn't there. |
| 78 | tomb, err := ReadAppTombstone(ctx, s, acct, app) |
| 79 | if err != nil { |
| 80 | return fmt.Errorf("set production: cannot check the trash marker for %s/%s: %w", acct, app, err) |
| 81 | } |
| 82 | if tomb != nil { |
| 83 | return fmt.Errorf("set production: %s/%s is in the trash — restore it before flagging it production", acct, app) |
| 84 | } |
| 85 | key := ProductionKey(acct, app) |
| 86 | raw, etag, err := s.Get(ctx, key) |
| 87 | if errors.Is(err, os.ErrNotExist) { |
| 88 | etag = "" // first write: create-only |
| 89 | } else if err != nil { |
| 90 | return fmt.Errorf("set production: read %s: %w", key, err) |
| 91 | } |
| 92 | setAt := time.Now().UTC().Truncate(time.Second) |
| 93 | if err == nil { |
| 94 | // SetAt is truncated to the second; two flips inside one wall-clock |
| 95 | // second would otherwise share a value, and the box-side replay |
| 96 | // floor (bake.go's NewestSetAt) treats an equal set_at as nothing |
| 97 | // new — silently swallowing the second flip on every box. Bump |
| 98 | // strictly past whatever this key's own last signed write recorded. |
| 99 | if existing, verr := verifyProductionFlag(priv.Public().(ed25519.PublicKey), raw, acct, app); verr == nil && !existing.SetAt.Before(setAt) { |
| 100 | setAt = existing.SetAt.Add(time.Second) |
| 101 | } |
| 102 | } |
| 103 | f := &ProductionFlag{ |
| 104 | Schema: 1, Account: acct, App: app, Production: production, |
| 105 | Actor: actor, SetAt: setAt, |
| 106 | } |
| 107 | if err := validateProductionFlag(f, acct, app); err != nil { |
| 108 | return fmt.Errorf("set production: %w", err) |
| 109 | } |
| 110 | payload, err := json.MarshalIndent(f, "", " ") |
| 111 | if err != nil { |
| 112 | return err |
| 113 | } |
| 114 | b64 := base64.StdEncoding.EncodeToString(payload) |
| 115 | wrapper, err := json.MarshalIndent(signedWrapper{ |
| 116 | Payload: b64, |
| 117 | Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(b64))), |
| 118 | }, "", " ") |
| 119 | if err != nil { |
| 120 | return err |
| 121 | } |
| 122 | _, err = s.Put(ctx, key, wrapper, etag) |
| 123 | return err |
| 124 | } |
| 125 | |
| 126 | // GetProduction resolves an app's flag. A MISSING object is (nil, nil): the |
| 127 | // default, not production — but never let a nil collapse the distinction the |
| 128 | // spec draws: any OTHER failure (unreadable, unverifiable, misnamed) is the |
| 129 | // error, and callers on the adopt path treat it as production=true, failing |
| 130 | // toward baking. |
| 131 | func GetProduction(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string) (*ProductionFlag, error) { |
| 132 | raw, _, err := s.Get(ctx, ProductionKey(acct, app)) |
| 133 | if errors.Is(err, os.ErrNotExist) { |
| 134 | return nil, nil |
| 135 | } |
| 136 | if err != nil { |
| 137 | return nil, err |
| 138 | } |
| 139 | return verifyProductionFlag(pub, raw, acct, app) |
| 140 | } |
| 141 | |
| 142 | // verifyProductionFlag is verifyPointer's discipline for the flag: refuse a |
| 143 | // grant outright, check the signature against the ROOT key alone, decode |
| 144 | // after, validate the payload names the account/app whose key it sits at. |
| 145 | func verifyProductionFlag(pub ed25519.PublicKey, wrapper []byte, wantAcct, wantApp string) (*ProductionFlag, error) { |
| 146 | var sw signedWrapper |
| 147 | if err := json.Unmarshal(wrapper, &sw); err != nil { |
| 148 | return nil, fmt.Errorf("production flag wrapper: %v", err) |
| 149 | } |
| 150 | if sw.Grant != nil { |
| 151 | return nil, fmt.Errorf("production flag %s/%s: signed by a scoped key — the flag takes the root key, because a delegate that could flip it could skip the window it bakes under", wantAcct, wantApp) |
| 152 | } |
| 153 | if len(pub) != ed25519.PublicKeySize { |
| 154 | return nil, fmt.Errorf("production flag: no usable release public key") |
| 155 | } |
| 156 | sig, err := base64.StdEncoding.DecodeString(sw.Sig) |
| 157 | if err != nil { |
| 158 | return nil, fmt.Errorf("production flag sig: %v", err) |
| 159 | } |
| 160 | if !ed25519.Verify(pub, []byte(sw.Payload), sig) { |
| 161 | return nil, fmt.Errorf("production flag signature verification failed") |
| 162 | } |
| 163 | payload, err := base64.StdEncoding.DecodeString(sw.Payload) |
| 164 | if err != nil { |
| 165 | return nil, fmt.Errorf("production flag payload: %v", err) |
| 166 | } |
| 167 | var f ProductionFlag |
| 168 | if err := json.Unmarshal(payload, &f); err != nil { |
| 169 | return nil, fmt.Errorf("production flag payload: %v", err) |
| 170 | } |
| 171 | if err := validateProductionFlag(&f, wantAcct, wantApp); err != nil { |
| 172 | return nil, err |
| 173 | } |
| 174 | return &f, nil |
| 175 | } |
| 176 | |
| 177 | func validateProductionFlag(f *ProductionFlag, wantAcct, wantApp string) error { |
| 178 | if f.Schema != 1 { |
| 179 | return fmt.Errorf("unsupported production flag schema %d", f.Schema) |
| 180 | } |
| 181 | if f.Account != wantAcct || f.App != wantApp { |
| 182 | return fmt.Errorf("production flag names %s/%s, expected %s/%s", f.Account, f.App, wantAcct, wantApp) |
| 183 | } |
| 184 | if !ValidAccount(f.Account) || !ValidApp(f.App) { |
| 185 | return fmt.Errorf("production flag has invalid account/app %s/%s", f.Account, f.App) |
| 186 | } |
| 187 | if f.SetAt.IsZero() { |
| 188 | return fmt.Errorf("production flag has no set_at") |
| 189 | } |
| 190 | return nil |
| 191 | } |
| 192 | func SetProduction(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app string, production bool, actor string) (claimResult0 error) { |
| 193 | claimResult0 = WithAppMutation(ctx, s, acct, app, "release.SetProduction", func(claimContext context.Context) error { |
| 194 | claimResult0 = claimRawSetProduction(claimContext, s, priv, acct, app, production, actor) |
| 195 | return claimResult0 |
| 196 | }) |
| 197 | return |
| 198 | } |
| 199 | |