spec: console bake controls — the production flag and fast-track
Per-app production flag (default off, ratcheted off-switch, deletion- and replay-proof), root-key fast-track recorded on the pointer like -hotfix, console controls behind the existing passkey step-up, and the edge:0 fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VioXzogWf1Xi4Rswz2B6EK
1 file changed,
+257
−0
docs/superpowers/specs/2026-08-04-console-bake-controls-design.md+257 −0
diff --git a/docs/superpowers/specs/2026-08-04-console-bake-controls-design.md b/docs/superpowers/specs/2026-08-04-console-bake-controls-design.md| new file mode 100644 |
| index 000000000..b42b9934a |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-04-console-bake-controls-design.md |
| @@ -0,0 +1,257 @@ |
| +# Console bake controls — the production flag and fast-track |
| + |
| +2026-08-04. Extends the pipelines spec (2026-08-02-pipelines-design.md §3, §5) |
| +and the scoped signing keys spec (2026-08-03). Touches `internal/release` |
| +(bake.go, adopt.go, release.go) and `internal/console` (server.go, ladder.go, |
| +view.go, templates). |
| + |
| +## 1. Problem |
| + |
| +The supply-chain bake window (beta 24h, stable 72h) protects subscribers of an |
| +app that is actually in production. On a rapid-iteration deployment, edge, beta |
| +and stable are effectively the same audience, and the hold is pure friction — |
| +today the only escape is root on each box, and even that is unwired (the |
| +`ignore` parameter of `Hold` has no caller that passes true). |
| + |
| +Two adjacent defects surfaced while designing this: |
| + |
| +- `DefaultHolds` has no `edge` entry, so `HoldFor` fail-closes edge to 72 |
| + hours. The pipelines spec defines edge as "picked up as it lands — no human |
| + in the loop, by definition". This spec fixes it to 0. |
| +- The "per-deployment overrides come from host config" promised in bake.go's |
| + comment was never wired; that stays unwired (non-goal, §10). |
| + |
| +## 2. The production flag |
| + |
| +Each app carries a **production flag**. `production: true` means the |
| +supply-chain bake applies to the app's beta and stable channels; |
| +`production: false` (the default) means its boxes adopt immediately on every |
| +channel. |
| + |
| +**Default is off.** A new app is not in production, and the flag should mean |
| +"someone declared this production" — a real audit fact — rather than "nobody |
| +got around to opting out". Nothing on either current deployment loses |
| +protection: the entire fleet runs on `canary/*` channels, which never bake. |
| +The silent-failure risk of default-off (a production app quietly missing its |
| +window) is answered structurally in §7: promote-to-stable asks the question at |
| +the exact moment it matters, and prod-wiring detection nags persistently. |
| + |
| +The flag gates ONLY the bake window. The ladder, its sign-off gates, the |
| +passkey step-up on stable, and route wiring are all untouched — a |
| +non-production app climbs edge → beta → stable with the same recorded |
| +promotions; its boxes just don't sit out the hold. |
| + |
| +## 3. Wire shapes — both additive |
| + |
| +Live pointers and manifests exist in production buckets; everything written |
| +before this change must still parse (repo rule). Both shapes here are |
| +additive, and both fail SAFE on old binaries: a box that predates this feature |
| +keeps baking. |
| + |
| +**The flag object** is a new signed bucket object per app, in the same Ed25519 |
| +envelope as channel pointers, root-key-signed: |
| + |
| +```json |
| +{"account": "<sqid>", "app": "<app>", "production": true, |
| + "actor": "<address>", "set_at": "<rfc3339>"} |
| +``` |
| + |
| +Stored at `apps/<acct>/<app>/production.json` (the `appPrefix` shape) — |
| +deliberately NOT under `channels/`, which ChannelsPrefix lists and whose |
| +entries the console and CLI parse as pointers. A missing object means the |
| +default: not production — but see §4 for why absence never *downgrades* a box |
| +that has seen `true`. A scoped key must not be able to mint it — enforced at |
| +mint and at verification, like everything else the root key reserves. |
| + |
| +**Fast-track** is a new optional field on the channel pointer itself, |
| +mirroring `Hotfix`: |
| + |
| +``` |
| +FastTrack bool `json:"fast_track,omitempty"` |
| +``` |
| + |
| +No second object to fetch during adopt; inherently scoped to one |
| +(account, app, channel, version) so it cannot be replayed onto a later |
| +release; recorded on the very artifact it waives, next to the `Actor` trail. |
| + |
| +Consumer list for the pointer shape (everything that PARSES it, per the repo |
| +rule): `verifyPointer` and the adopt pass, console `ChannelView`/`view.go`, |
| +CLI status output, `laddercheck`. All are additive parses; old parsers ignore |
| +the field, which means old boxes bake — the safe direction. |
| + |
| +## 4. The ratchet on turning bake off |
| + |
| +Flipping an app to `production: false` is honored by each box only after the |
| +flag object has sat unchanged for the **stable window (72h) on that box's own |
| +first-seen clock** — the flag defends itself with the mechanism it controls. |
| +A stolen root key cannot insta-disable baking; it can only start a loud 72h |
| +timer that the console displays for the whole window (§7). |
| + |
| +Mechanics: the adopt pass runs the flag object through the same `FirstSeen` |
| +clock, under a synthetic key that cannot collide with channel keys (the |
| +`clockSep` argument from bake.go applies). First sighting of a given |
| +`production: false` object starts the clock; `Hold`-style eligibility uses the |
| +stable hold with the box's jitter. |
| + |
| +Flipping to `production: true` is honored **immediately** — the fail-safe |
| +direction always wins, and a re-enable must never itself bake. |
| + |
| +Two tamper cases the ratchet must survive, both handled with small local |
| +state in the box's bake-clock file: |
| + |
| +- **Deletion is not a downgrade.** Once a box has seen a verified |
| + `production: true` for an app, it holds that verdict until a verified |
| + `production: false` passes the ratchet. A missing, unreadable or |
| + unverifiable object never downgrades a box that once saw `true` — else |
| + anyone with bucket write but no key could delete the flag and skip the |
| + ratchet entirely. The box persists "this app was production" alongside its |
| + first-seen clock. |
| +- **A stale flag cannot be replayed.** The box records the newest `set_at` it |
| + has honored per app and refuses an object older than that — a signed |
| + `production: false` from a past era, re-written to the bucket by someone |
| + with bucket access alone, cannot roll back a newer `true`. (It would still |
| + cost the replayer the full 72h ratchet even without this; the monotonicity |
| + check just closes the loop.) |
| + |
| +Carve-out: if the app has no beta or stable pointer at the moment a box |
| +evaluates the flag, `production: false` takes effect immediately — there is |
| +nothing at a baked head to protect. With default-off this is rarely exercised |
| +(it matters only for an app flipped on and then off while young), but it keeps |
| +the ratchet honest about what it protects. |
| + |
| +## 5. Fast-track a release |
| + |
| +For a production app whose beta or stable head is inside its window, the |
| +console offers **fast-track**: re-promote the same version with |
| +`FastTrack: true`, rewriting the pointer in place. Boxes then skip the hold |
| +for exactly that version on that channel. |
| + |
| +- `PromoteOptions` gains `FastTrack bool`. The promote no-op check learns one |
| + nuance: same version but *gaining* fast_track is a write, not a repeat. |
| +- **Root key only, enforced at verification, not just at mint** — a |
| + scoped-key-signed pointer bearing `fast_track` is refused by verifiers, |
| + matching the grant model's mint-AND-verify rule. Otherwise one leaked |
| + scoped stable key would defeat the window it bakes under. |
| +- `fast_track` is set per write and never inherited: a later promotion of a |
| + new version onto the channel starts clean, and a rollback writes whatever |
| + the rollback path writes today (rollback never bakes anyway). |
| + |
| +Rollback needs none of this: a pull-back to a previously-seen version is |
| +already immediately eligible (bake.go keeps past heads for exactly this). |
| + |
| +## 6. Adopt changes (box side) |
| + |
| +In `adoptHost`, before the existing `Hold` check: resolve the app's flag |
| +(verified read, with §4's deletion and replay rules), and pass `ignore=true` |
| +to `Hold` when either |
| + |
| +- the app is non-production and past its ratchet (§4), or |
| +- the channel pointer carries a root-signed `fast_track` for the version |
| + being considered. |
| + |
| +Fail-safe detail: for a box with no memory of `true`, a flag object that |
| +exists but will not read or verify is treated as `production: true` — an |
| +attacker who can corrupt but not sign must not be able to shorten anything. |
| +The bake clock still ticks regardless of the flag (`Observe` runs as today): |
| +observations are cheap, and flipping the flag back on must not restart |
| +anyone's 72h from zero. |
| + |
| +The wired `ignore` finally gives the box-local override a caller; a |
| +`carlos adopt` flag for root-on-box use remains a non-goal (§10). |
| + |
| +## 7. Console surface |
| + |
| +All on the app page's ladder section, all through the existing gates in the |
| +repo's mandated order — session → CSRF → owner (byte-identical 404) → |
| +`requireFreshAuth` LAST. No new auth machinery: these are three more callers |
| +of the existing step-up gate. |
| + |
| +- **Bake status line**: "Supply-chain bake: off (default)" / "on" / |
| + "turning off — hosts honor this from ~<date>" during the ratchet, the date |
| + computed from the flag's `set_at` plus the stable hold (an estimate — each |
| + box's own clock and jitter govern, as the adopt nag already words it). |
| +- **Turn on**: any member, instant, no step-up. The safe direction is the |
| + cheap one, deliberately frictionless. |
| +- **Turn off**: owner-only + passkey step-up, audited, and the ratchet |
| + message shown on confirmation. |
| +- **Fast-track**: shown on a beta/stable channel row currently inside its |
| + window (the row already knows PromotedAt + `HoldFor`); owner-only + |
| + step-up; audited; the row carries a permanent "fast-tracked" badge, like |
| + the hotfix badge. |
| +- **Promote-to-stable prompt** (the structural mitigation for default-off): |
| + when the flag is off, the stable promote flow — already owner + passkey — |
| + asks inline: "This puts a build in front of every user of this app. Is this |
| + app in production now? Enabling the supply-chain bake is recommended." One |
| + checkbox, recorded through the same confirmed action. It asks; it never |
| + flips the flag on its own. |
| +- **Prod-wiring nudge**: the signals `prodWiredCanaryHosts` already computes |
| + (a route bound to the same environment as a stable route, or sitting on the |
| + app's apex) plus "has a stable route at all": an app tripping them while |
| + bake is off gets a persistent line on its ladder section — "this app looks |
| + production-wired but adopts releases immediately" — and the same line in |
| + the fleet view. Detection recommends; it never flips the flag (implicit |
| + gates are the thing this platform keeps refusing). |
| + |
| +## 8. The edge fix |
| + |
| +`DefaultHolds` gains `"edge": 0`, restoring the pipelines spec's definition of |
| +edge. Independent of everything above and safe to land first. |
| + |
| +## 9. Security posture — what changed and what held |
| + |
| +bake.go's ported doctrine was "deliberately NO publisher-side bypass". This |
| +spec knowingly relaxes it, the way ladder.go already did for the ladder: an |
| +explicit, recorded bypass beats an invisible one, and beats the workaround |
| +culture that grows around a gate with no legitimate exit. |
| + |
| +What an attacker holding a **scoped** key can do: nothing new. Fast-track and |
| +the flag are root-reserved at mint and at verification. |
| + |
| +What an attacker holding the **root** key gains: an instant path past the |
| +window — fast-track — that is loud by construction: the pointer names the |
| +actor and wears the badge forever. The standing flag gains them nothing |
| +instant: flipping it off starts a 72h ratchet the console displays the whole |
| +time, which preserves the discovery window the bake existed to buy. The |
| +honest loss is exactly fast-track on production apps: before this spec a |
| +root-key thief had no publisher-side way past the hold; now they have one |
| +that cannot be used quietly. |
| + |
| +Console compromise short of the root key gets nothing: every control sits |
| +behind owner + fresh passkey, server-side, fail-closed when Keymail is |
| +unreachable (stepup.go's existing properties). |
| + |
| +## 10. Non-goals |
| + |
| +- CLI fast-track flag (a root-key holder at a terminal has stronger powers |
| + already; add later if wanted). |
| +- Per-deployment hold overrides via host config — bake.go's comment keeps |
| + promising it; still unwired. |
| +- A `carlos adopt -ignore-bake` root-on-box flag (the `ignore` parameter now |
| + has its console-driven caller; the local flag can follow if ever needed). |
| +- Any canary behavior change. Canary remains bake-free and outside all of |
| + this. |
| + |
| +## 11. Testing |
| + |
| +- `bake_test.go`: ratchet timing (off honored only after stable hold + |
| + jitter), on honored immediately, the no-beta/stable carve-out, corrupt |
| + flag object treated as production, deletion-is-not-a-downgrade, stale |
| + `set_at` replay refused. |
| +- Wire tests: pre-change pointer parses; pointer with `fast_track` parses in |
| + the old struct shape (field ignored). |
| +- Grant/verify tests: scoped-key-signed `fast_track` refused; scoped key |
| + cannot write the flag object. |
| +- Promote tests: same-version-gaining-fast_track is a write, not a no-op. |
| +- Console tests: gate ordering for all three controls (non-member gets the |
| + byte-identical 404 before any step-up redirect), promote-to-stable prompt |
| + shown only when the flag is off, nudge appears on prod-wired signals. |
| +- `laddercheck_test.go`'s pinned production channels: untouched and still |
| + green — the fleet runs on `canary/*` and nothing here may refuse them. |
| + |
| +## 12. Operational follow-up (not part of the build) |
| + |
| +Once shipped: flip the flag ON for flagship's four apps (console, kass, |
| +keymail, platform-site) and tito's production apps — these are the apps the |
| +flag is for. Until their routes follow beta/stable channels the flag changes |
| +nothing day-to-day, but the declaration should exist before the migration off |
| +`canary/*` ever happens, not after. |