carlos / platform Public

plan: console release defences, plan 2 of 4 (D2)

Four tasks: program detection and the ship-side rule, both ship doors
calling it, the box checking staged bytes against the route's program
history, and the gate with an operator note. Depends on plan 1's
refusal records.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 9d64bbc298d2e1ca3878a35ad609809ad9b09d83 parent 2b6287e
1 file changed, +718 −0
  • docs/superpowers/plans/2026-10-06-console-release-defences-plan-2-d2.md +718 −0

CI passed — run details

diff --git a/docs/superpowers/plans/2026-10-06-console-release-defences-plan-2-d2.md b/docs/superpowers/plans/2026-10-06-console-release-defences-plan-2-d2.md
new file mode 100644
index 000000000..dd1a71205
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-06-console-release-defences-plan-2-d2.md
@@ -0,0 +1,718 @@
+# Console release defences, Plan 2 of 4: D2 (refuse the wrong program)
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Shipping or adopting a binary whose Go main package differs from the
+app's previous release is refused unless the change is declared with
+`--program-change`. A box also lets a build back in if its route has run that
+program before.
+
+**Architecture:** One package-level helper set in `internal/release`
+(`program.go`) reads Go build info from artifact bytes or a staged file. The
+two ship doors (direct-bucket `cli.go`, API `internal/console/apicli.go`) call
+one `CheckShipProgram` before `release.Ship`. `adoptHost` checks the staged
+binary against the route's program history (`programhistory.go`, in the bake
+directory) before pointing it live, and writes a Plan 1 refusal when it
+refuses.
+
+**Tech Stack:** Go stdlib `debug/buildinfo`; the existing release, console and
+CLI packages.
+
+**Spec:** `docs/superpowers/specs/2026-10-06-console-release-defences-design.md`
+(rev 5): §3 (D2) and §7's D2 tests. **Depends on Plan 1's Task 1**
+(`release.WriteRefusal`, `Refusal`). Land Plan 1 first, or cherry-pick that
+task.
+
+## Global Constraints
+
+- `program` is the build info's main package path (`buildinfo.BuildInfo.Path`), additive to manifest schema 1, `omitempty` (spec §3.1).
+- `program_change: true` is written only when `--program-change` was passed (spec §3.1).
+- No previous release with a `program`, a non-Go artifact, a `static` kind, or a first release: ship as today (spec §3.1).
+- All artifacts of one release must report the same program, or ship refuses (spec §3.1).
+- "Previous" = the newest of the app's 20 newest releases (`release.VersionCandidates`, as `carlos releases` uses) whose manifest declares a `program`. This plan picks that over "the newest release": a release from an older client has no `program` and must not switch the check off.
+- On the box the check reads the **staged bytes**, never only the manifest (spec §3.2), and passes for any program in the route's program history (spec §3.2, codex 7).
+- Gate with `GOFLAGS=-buildvcs=false` and, in this sandbox, `GOCACHE=$TMPDIR/gocache` per command.
+
+## Review Focus
+
+1. **A release shipped by a v0.24.1 or older client right after this lands** (no `program` in its manifest). Expect: the ship-side check skips it as a predecessor, and the box still checks its bytes. Test in Task 3 (`TestBoxRefusesWrongProgramWithNoManifestField`).
+2. **A legitimate move of the console to a new main package** (a refactor). Expect: `--program-change` ships it, the box accepts it, and a later rollback to the old program is accepted because the route ran it. Tests in Tasks 1 and 3.
+3. **A non-Go binary app** (Rust, a shell wrapper). Expect: no refusal ever, at ship or on the box. Tests in Tasks 1 and 3.
+4. **Multi-arch releases** (`linux-arm64` and `linux-amd64` built from different main packages by mistake). Expect: ship refuses with both names. Test in Task 1.
+5. **A box upgraded to this release with a route already live and no history.** Expect: the history is seeded from the live binary, so the next mismatching build is refused, not trusted. Test in Task 3 (`TestBoxSeedsHistoryFromTheLiveBinary`).
+
+---
+
+### Task 1: Program detection and the ship-side rule
+
+**Files:**
+- Create: `internal/release/program.go`
+- Modify: `internal/release/release.go` (`Manifest`, after `Notes`: two fields)
+- Test: `internal/release/program_test.go`
+
+**Interfaces:**
+- Produces:
+ - `Manifest.Program string \`json:"program,omitempty"\``, `Manifest.ProgramChange bool \`json:"program_change,omitempty"\``
+ - `func ArtifactProgram(b []byte) string` — "" for non-Go
+ - `func FileProgram(path string) string` — "" for non-Go or unreadable
+ - `func ReleaseProgram(files map[string][]byte) (string, error)` — error when artifacts disagree
+ - `func PreviousProgram(ctx context.Context, s Store, acct, app string) (program, version string, err error)`
+ - `type ProgramMismatchError struct { App, Got, Want, WantVersion string }` with `Error()`
+ - `func CheckShipProgram(ctx context.Context, s Store, acct string, m *Manifest, files map[string][]byte, allowChange bool) error`, which sets `m.Program` and `m.ProgramChange`
+
+- [ ] **Step 1: Write the test helper and failing tests**
+
+```go
+package release
+
+import (
+ "context"
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// tinyProgram builds a stripped linux/arm64 Go binary whose main package is
+// modPath, exactly as the console is built (-s -w), at test time. Nothing is
+// checked in.
+func tinyProgram(t *testing.T, modPath string) []byte {
+ t.Helper()
+ if testing.Short() {
+ t.Skip("builds a binary")
+ }
+ dir := t.TempDir()
+ must := func(err error) {
+ t.Helper()
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ must(os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module "+modPath+"\n\ngo 1.22\n"), 0o644))
+ must(os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\nfunc main() {}\n"), 0o644))
+ out := filepath.Join(dir, "bin")
+ cmd := exec.Command("go", "build", "-trimpath", "-ldflags=-s -w", "-o", out, ".")
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=arm64", "CGO_ENABLED=0", "GOFLAGS=-buildvcs=false")
+ if b, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("build %s: %v\n%s", modPath, err, b)
+ }
+ b, err := os.ReadFile(out)
+ must(err)
+ return b
+}
+
+func TestArtifactProgram(t *testing.T) {
+ if got := ArtifactProgram(tinyProgram(t, "example.com/console")); got != "example.com/console" {
+ t.Fatalf("program = %q", got)
+ }
+ if got := ArtifactProgram([]byte("#!/bin/sh\necho hi\n")); got != "" {
+ t.Fatalf("non-Go program = %q, want empty", got)
+ }
+}
+
+func TestReleaseProgramDisagreement(t *testing.T) {
+ _, err := ReleaseProgram(map[string][]byte{
+ "linux-arm64": tinyProgram(t, "example.com/console"),
+ "linux-amd64": tinyProgram(t, "example.com/carlos"),
+ })
+ if err == nil || !strings.Contains(err.Error(), "example.com/console") || !strings.Contains(err.Error(), "example.com/carlos") {
+ t.Fatalf("err = %v, want both programs named", err)
+ }
+}
+
+func shipWithProgram(t *testing.T, s Store, ver string, art []byte, allow bool) error {
+ t.Helper()
+ files := map[string][]byte{adoptPlatform: art}
+ m := Manifest{Schema: 1, App: "console", Version: ver, Kind: "binary",
+ Artifacts: map[string]Artifact{adoptPlatform: {}}} // Ship fills file, sha256, size
+ if err := CheckShipProgram(context.Background(), s, "bab", &m, files, allow); err != nil {
+ return err
+ }
+ return Ship(context.Background(), s, "bab", m, files)
+}
+
+func TestCheckShipProgram(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ console := tinyProgram(t, "example.com/console")
+ cli := tinyProgram(t, "example.com/carlos")
+ if err := shipWithProgram(t, s, "v1", console, false); err != nil {
+ t.Fatalf("first release: %v", err)
+ }
+ err := shipWithProgram(t, s, "v2", cli, false)
+ var pm *ProgramMismatchError
+ if !errors.As(err, &pm) || pm.Got != "example.com/carlos" || pm.Want != "example.com/console" {
+ t.Fatalf("wrong program ship = %v, want ProgramMismatchError", err)
+ }
+ if !strings.Contains(err.Error(), "--program-change") {
+ t.Fatalf("refusal %q does not name the override", err)
+ }
+ if err := shipWithProgram(t, s, "v3", cli, true); err != nil {
+ t.Fatalf("--program-change ship: %v", err)
+ }
+ m, err := GetManifest(context.Background(), s, "bab", "console", "v3")
+ if err != nil || m.Program != "example.com/carlos" || !m.ProgramChange {
+ t.Fatalf("v3 manifest = %+v, %v; want program recorded and program_change true", m, err)
+ }
+ if err := shipWithProgram(t, s, "v4", []byte("#!/bin/sh\n"), false); err != nil {
+ t.Fatalf("non-Go artifact refused: %v", err)
+ }
+}
+
+// A release from an old client has no program and must not switch the check
+// off for the next ship.
+func TestPreviousProgramSkipsReleasesWithoutOne(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ if err := shipWithProgram(t, s, "v1", tinyProgram(t, "example.com/console"), false); err != nil {
+ t.Fatal(err)
+ }
+ shipRelease(t, s, "bab", "console", "v2", adoptPlatform, []byte("old client, no program field"))
+ got, ver, err := PreviousProgram(context.Background(), s, "bab", "console")
+ if err != nil || got != "example.com/console" || ver != "v1" {
+ t.Fatalf("PreviousProgram = %q %q %v, want example.com/console from v1", got, ver, err)
+ }
+}
+```
+
+This mirrors `shipRelease` (internal/release/release_test.go:39), which passes
+empty `Artifact` values and lets `Ship` fill them.
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/release -run 'Program' -count=1`
+Expected: FAIL, `undefined: ArtifactProgram`.
+
+- [ ] **Step 3: Implement**
+
+In `Manifest`, after `Notes`:
+
+```go
+ // Program is the Go main package path of the release's binaries
+ // (debug/buildinfo), and ProgramChange records an intended change of it
+ // (`carlos ship --program-change`). ADDITIVE to schema 1 like Label:
+ // omitted when empty, ignored by old binaries. Console release defences
+ // D2 (program.go).
+ Program string `json:"program,omitempty"`
+ ProgramChange bool `json:"program_change,omitempty"`
+```
+
+`internal/release/program.go`:
+
+```go
+// The wrong-program check — D2 of the console release defences (spec
+// 2026-10-06-console-release-defences-design.md §3).
+//
+// On 2026-10-06 the public carlos CLI binary was shipped as the console. Its
+// architecture and its version stamp were both right, so nothing objected;
+// what was wrong was the program. A Go binary records its main package path
+// in its build info, readable from stripped bytes for any architecture
+// without running them, so a ship can compare it with the app's previous
+// release and a box can compare it with what its route has run.
+package release
+
+import (
+ "bytes"
+ "context"
+ "debug/buildinfo"
+ "fmt"
+ "sort"
+ "strings"
+)
+
+func ArtifactProgram(b []byte) string {
+ bi, err := buildinfo.Read(bytes.NewReader(b))
+ if err != nil {
+ return ""
+ }
+ return bi.Path
+}
+
+func FileProgram(path string) string {
+ bi, err := buildinfo.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+ return bi.Path
+}
+
+// ReleaseProgram is the one program every Go artifact in files reports, ""
+// when none is Go.
+func ReleaseProgram(files map[string][]byte) (string, error) {
+ seen := map[string][]string{}
+ for name, b := range files {
+ if p := ArtifactProgram(b); p != "" {
+ seen[p] = append(seen[p], name)
+ }
+ }
+ if len(seen) > 1 {
+ var parts []string
+ for p, names := range seen {
+ sort.Strings(names)
+ parts = append(parts, fmt.Sprintf("%s is %s", strings.Join(names, ", "), p))
+ }
+ sort.Strings(parts)
+ return "", fmt.Errorf("this release's artifacts are different programs: %s", strings.Join(parts, "; "))
+ }
+ for p := range seen {
+ return p, nil
+ }
+ return "", nil
+}
+
+// PreviousProgram is the program of the newest release (among the 20 newest)
+// whose manifest declares one, and that release's version.
+func PreviousProgram(ctx context.Context, s Store, acct, app string) (string, string, error) {
+ vers, _, err := VersionCandidates(ctx, s, acct, app, 20)
+ if err != nil {
+ return "", "", err
+ }
+ var best *Manifest
+ for _, v := range vers {
+ m, err := GetManifest(ctx, s, acct, app, v)
+ if err != nil || m == nil || m.Program == "" {
+ continue
+ }
+ if best == nil || m.BuiltAt > best.BuiltAt {
+ best = m
+ }
+ }
+ if best == nil {
+ return "", "", nil
+ }
+ return best.Program, best.Version, nil
+}
+
+type ProgramMismatchError struct{ App, Got, Want, WantVersion string }
+
+func (e *ProgramMismatchError) Error() string {
+ return fmt.Sprintf("this artifact is %s; %s's releases are %s (%s). If the app really is changing program, ship again with --program-change",
+ e.Got, e.App, e.Want, e.WantVersion)
+}
+
+// CheckShipProgram is the one ship-side rule both ship doors call before
+// release.Ship. It stamps m.Program (and m.ProgramChange when allowed).
+func CheckShipProgram(ctx context.Context, s Store, acct string, m *Manifest, files map[string][]byte, allowChange bool) error {
+ if m.Kind != "binary" {
+ return nil
+ }
+ prog, err := ReleaseProgram(files)
+ if err != nil || prog == "" {
+ return err
+ }
+ m.Program = prog
+ prev, prevVer, err := PreviousProgram(ctx, s, acct, m.App)
+ if err != nil {
+ return fmt.Errorf("reading %s's previous releases: %w", m.App, err)
+ }
+ if prev == "" || prev == prog {
+ return nil
+ }
+ if !allowChange {
+ return &ProgramMismatchError{App: m.App, Got: prog, Want: prev, WantVersion: prevVer}
+ }
+ m.ProgramChange = true
+ return nil
+}
+```
+
+`BuiltAt` is RFC 3339 UTC in every writer (cli.go and apicli.go both use
+`time.Now().UTC().Format(time.RFC3339)`), so string comparison orders it.
+
+- [ ] **Step 4: Run the tests**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/release -run 'Program' -count=1`
+Expected: PASS (builds four tiny binaries; about 10 s).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/release/program.go internal/release/program_test.go internal/release/release.go
+git commit -m "release: detect a release's Go program and refuse a change of it at ship (console defences D2)"
+```
+
+---
+
+### Task 2: Both ship doors call the rule
+
+**Files:**
+- Modify: `cli.go`: `runShip`'s flag set (add `--program-change`), the direct-mode path just before `release.Ship` (~2192), the API-mode `sess.ship(...)` call
+- Modify: `apimode.go`: `ship` (3953) gains `programChange bool`; meta JSON gains `ProgramChange bool \`json:"program_change,omitempty"\``
+- Modify: `internal/console/apicli.go`: meta struct (~1715) gains `ProgramChange bool \`json:"program_change,omitempty"\``; call `release.CheckShipProgram` after `m` is built (~1969), mapping `*ProgramMismatchError` to 409
+- Test: `internal/console/apicli_test.go` (append, beside the existing ship-door tests)
+
+**Interfaces:**
+- Consumes: Task 1's `CheckShipProgram`, `ProgramMismatchError`.
+
+- [ ] **Step 1: Write the failing API-door test**
+
+Copy the nearest existing successful ship test in `internal/console/apicli_test.go`
+(grep `func TestAPIShip`) as `TestAPIShipRefusesAProgramChange`. Ship a
+`tinyProgram(t, "example.com/console")` artifact as `v1`, then a
+`tinyProgram(t, "example.com/carlos")` artifact as `v2`. Assert:
+
+```go
+ if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), "--program-change") {
+ t.Fatalf("second ship = %d %s, want 409 naming --program-change", rec.Code, rec.Body.String())
+ }
+```
+
+Then ship `v3` with the meta part carrying `"program_change": true`, and assert
+200. `tinyProgram` lives in package `release`'s tests. Copy it into
+`internal/console` as `tinyProgramForConsole` in the test file; test helpers
+cannot be imported across packages.
+
+- [ ] **Step 2: Run it to verify it fails**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/console -run 'TestAPIShipRefusesAProgramChange' -count=1`
+Expected: FAIL. The second ship returns 200.
+
+- [ ] **Step 3: Implement**
+
+`internal/console/apicli.go`, right after the `m := release.Manifest{...}`
+literal and before the context/timeout lines:
+
+```go
+ if err := release.CheckShipProgram(r.Context(), 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
+ }
+```
+
+`cli.go`, in `runShip`'s flags:
+
+```go
+ programChange := fs.Bool("program-change", false, "accept that this release's Go main package differs from the app's previous release (recorded in the manifest)")
+```
+
+Direct mode, just before `release.Ship(context.Background(), st, acct, m, files)`:
+
+```go
+ if err := release.CheckShipProgram(context.Background(), st, acct, &m, files, *programChange); err != nil {
+ log.Fatal(err)
+ }
+```
+
+API mode: pass `*programChange` through `sess.ship(...)` to the new
+`programChange` parameter. In `apimode.go`'s meta struct literal add
+`ProgramChange bool \`json:"program_change,omitempty"\`` and the value.
+`carlos deploy` also calls `sess.ship`; pass `false` there, so a deploy never
+changes program silently.
+
+- [ ] **Step 4: Run the tests and the build**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/console -run 'APIShip' -count=1 && GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go build ./...`
+Expected: PASS, and the build succeeds.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cli.go apimode.go internal/console/apicli.go internal/console/apicli_test.go
+git commit -m "ship: both doors refuse a change of program without --program-change"
+```
+
+---
+
+### Task 3: The box checks staged bytes against the route's program history
+
+**Files:**
+- Create: `internal/release/programhistory.go`
+- Modify: `internal/release/adopt.go`, in `adoptHost` after `ensureStaged` succeeds and before the capability converge; and after `PointLive` succeeds
+- Test: `internal/release/programhistory_test.go`
+
+**Interfaces:**
+- Consumes: Task 1's `FileProgram`; Plan 1's `WriteRefusal`/`Refusal`; `VersionPath`, `RouteAccount`, `passConfig.bakeDir`.
+- Produces:
+ - `func ReadPrograms(bakeDir, host string) []string`
+ - `func RecordProgram(bakeDir, host, program string) error`
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package release
+
+import (
+ "bytes"
+ "context"
+ "strings"
+ "testing"
+)
+
+// The route is live on "old" (a console build). The channel is promoted to
+// "new" (a CLI build) shipped by an OLD client, so its manifest has no
+// program field and only the bytes can tell.
+func TestBoxRefusesWrongProgramWithNoManifestField(t *testing.T) {
+ host := "console.example.com"
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt, bake := t.TempDir(), t.TempDir()
+ reg := testRegistry(t)
+ good, bad := tinyProgram(t, "example.com/console"), tinyProgram(t, "example.com/carlos")
+ shipRelease(t, s, "bab", "console", "old", adoptPlatform, good)
+ shipRelease(t, s, "bab", "console", "new", adoptPlatform, bad) // old client: no program
+ if err := StageArtifact(opt, "bab", "old", "console", good); err != nil {
+ t.Fatal(err)
+ }
+ addRoute(t, reg, host, "console", "canary/x", false)
+ if err := PointLive(opt, host, "bab", "old", "console"); err != nil {
+ t.Fatal(err)
+ }
+ if err := Promote(ctx, s, priv, "bab", "console", "canary/x", "new"); err != nil {
+ t.Fatal(err)
+ }
+ restart := func(h string) error { t.Fatalf("restart consulted for a wrong-program build on %s", h); return nil }
+ var out bytes.Buffer
+ withCapturedStdout(t, &out, func() {
+ if err := Pass(ctx, reg, s, pub, opt, restart, BakeDir(bake)); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ })
+ if v, _ := LiveVersion(opt, host); v != "old" {
+ t.Fatalf("live = %q, want old", v)
+ }
+ if !strings.Contains(out.String(), "example.com/carlos") || !strings.Contains(out.String(), "not pointed live") {
+ t.Fatalf("printed %q, want the program refusal", out.String())
+ }
+ if r, _ := ReadRefusal(bake, host); r == nil || r.Version != "new" {
+ t.Fatalf("refusal = %+v, want one for new", r)
+ }
+}
+
+func TestBoxSeedsHistoryFromTheLiveBinary(t *testing.T) {
+ bake := t.TempDir()
+ if got := ReadPrograms(bake, "console.example.com"); len(got) != 0 {
+ t.Fatalf("fresh history = %v", got)
+ }
+ // The refusal test above starts with no history: passing it proves the
+ // seed. Pin the file shape directly too.
+ if err := RecordProgram(bake, "console.example.com", "example.com/console"); err != nil {
+ t.Fatal(err)
+ }
+ if err := RecordProgram(bake, "console.example.com", "example.com/console"); err != nil {
+ t.Fatal(err)
+ }
+ if got := ReadPrograms(bake, "console.example.com"); len(got) != 1 || got[0] != "example.com/console" {
+ t.Fatalf("history = %v, want one entry (no duplicates)", got)
+ }
+}
+
+func TestBoxAcceptsAProgramTheRouteRanBefore(t *testing.T) {
+ host := "console.example.com"
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt, bake := t.TempDir(), t.TempDir()
+ reg := testRegistry(t)
+ a, b := tinyProgram(t, "example.com/a"), tinyProgram(t, "example.com/b")
+ shipRelease(t, s, "bab", "console", "va", adoptPlatform, a)
+ shipRelease(t, s, "bab", "console", "vb", adoptPlatform, b)
+ if err := StageArtifact(opt, "bab", "vb", "console", b); err != nil {
+ t.Fatal(err)
+ }
+ addRoute(t, reg, host, "console", "canary/x", false)
+ if err := PointLive(opt, host, "bab", "vb", "console"); err != nil {
+ t.Fatal(err)
+ }
+ for _, p := range []string{"example.com/a", "example.com/b"} {
+ if err := RecordProgram(bake, host, p); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := Promote(ctx, s, priv, "bab", "console", "canary/x", "va"); err != nil {
+ t.Fatal(err)
+ }
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, BakeDir(bake)); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ if v, _ := LiveVersion(opt, host); v != "va" {
+ t.Fatalf("live = %q, want va: rolling back across a deliberate change must work (codex 7)", v)
+ }
+}
+
+func TestBoxIgnoresNonGoBinaries(t *testing.T) {
+ host := "x.example.com"
+ ctx, reg, s, pub, opt := startUnitFixture(t, host) // "good build"/"bad build" are not Go
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, BakeDir(t.TempDir())); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ if v, _ := LiveVersion(opt, host); v != "new" {
+ t.Fatalf("live = %q, want new: non-Go bytes are never refused", v)
+ }
+}
+```
+
+`shipRelease(t, s, acct, app, ver, platform, bytes)` (release_test.go:39) writes a
+manifest with no `program`, which is exactly an old client's ship.
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/release -run 'TestBox' -count=1`
+Expected: FAIL, `undefined: ReadPrograms`.
+
+- [ ] **Step 3: Implement `programhistory.go`**
+
+```go
+// Program history — the box half of D2 (spec §3.2). Every Go main package
+// path this box has pointed live for a route, in the bake directory. A build
+// whose program is in its route's history is never "the wrong program", which
+// is what lets a deliberate A -> B change be rolled back to A.
+package release
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+)
+
+func programPath(bakeDir, host string) string {
+ return filepath.Join(bakeDir, "programs", host+".json")
+}
+
+func ReadPrograms(bakeDir, host string) []string {
+ if strings.ContainsAny(host, `/\`) {
+ return nil
+ }
+ b, err := os.ReadFile(programPath(bakeDir, host))
+ if err != nil {
+ return nil
+ }
+ var rec struct {
+ Programs []string `json:"programs"`
+ }
+ if json.Unmarshal(b, &rec) != nil {
+ return nil
+ }
+ return rec.Programs
+}
+
+func RecordProgram(bakeDir, host, program string) error {
+ if program == "" || strings.ContainsAny(host, `/\`) {
+ return nil
+ }
+ have := ReadPrograms(bakeDir, host)
+ if slices.Contains(have, program) {
+ return nil
+ }
+ b, err := json.Marshal(struct {
+ Schema int `json:"schema"`
+ Programs []string `json:"programs"`
+ }{1, append(have, program)})
+ if err != nil {
+ return err
+ }
+ p := programPath(bakeDir, host)
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ return err
+ }
+ tmp := p + ".tmp"
+ if err := os.WriteFile(tmp, b, 0o644); err != nil {
+ return err
+ }
+ return os.Rename(tmp, p)
+}
+```
+
+- [ ] **Step 4: Wire it into `adoptHost`**
+
+Right after `m, err := ensureStaged(...)` and its error branch (before the
+capability converge comment block), insert:
+
+```go
+ // D2 (programhistory.go): the staged bytes, not the manifest, decide.
+ // Skipped for non-Go binaries and when this pass has no bake dir.
+ newProg := FileProgram(VersionPath(optDir, RouteAccount(rt), ver, rt.App))
+ if cfg.bakeDir != "" && newProg != "" {
+ hist := ReadPrograms(cfg.bakeDir, rt.Host)
+ if len(hist) == 0 && cur != "" {
+ if seed := FileProgram(VersionPath(optDir, RouteAccount(rt), cur, rt.App)); seed != "" {
+ _ = RecordProgram(cfg.bakeDir, rt.Host, seed)
+ hist = []string{seed}
+ }
+ }
+ if len(hist) > 0 && !slices.Contains(hist, newProg) && !(m != nil && m.ProgramChange) {
+ fmt.Printf(" %s: %s is %s, but this route has only run %s — not pointed live (ship with --program-change if this is intended)\n",
+ rt.Host, ver, newProg, strings.Join(hist, ", "))
+ sha := ""
+ if m != nil {
+ sha = m.Artifacts[adoptPlatform].SHA256
+ }
+ if err := WriteRefusal(cfg.bakeDir, Refusal{Host: rt.Host, App: rt.App, Version: ver, SHA256: sha,
+ Platform: adoptPlatform, PointerPromotedAt: ptr.PromotedAt, RefusedAt: now.UTC(),
+ Reason: "wrong program: " + newProg}); err != nil {
+ fmt.Printf(" %s: writing refusal record: %v\n", rt.Host, err)
+ }
+ return
+ }
+ }
+```
+
+and right after `PointLive` succeeds (after its error branch, before "The
+accepted window"):
+
+```go
+ if cfg.bakeDir != "" && newProg != "" {
+ if err := RecordProgram(cfg.bakeDir, rt.Host, newProg); err != nil {
+ fmt.Printf(" %s: recording program history: %v\n", rt.Host, err)
+ }
+ }
+```
+
+Add `"slices"` to adopt.go's imports if it is not already there.
+
+- [ ] **Step 5: Run the release suite**
+
+Run: `GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go test ./internal/release -count=1`
+Expected: PASS. Existing fixtures ship non-Go bytes ("good build"), so their
+behaviour is unchanged.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/release/programhistory.go internal/release/programhistory_test.go internal/release/adopt.go
+git commit -m "release: a box refuses a staged binary its route has never run the program of"
+```
+
+---
+
+### Task 4: Gate and operator note
+
+**Files:**
+- Modify: `README.md`, the same operator-notes area Plan 1 Task 9 used
+
+- [ ] **Step 1: Add the note**
+
+```markdown
+**Wrong-program check (console release defences, D2).** `carlos ship` now
+records a binary's Go main package in the manifest and refuses a release whose
+program differs from the app's previous one. Pass `--program-change` when the
+change is intended. Boxes check the staged bytes too, so a release shipped by
+an older client is caught at adoption. A build a route has run before is
+always accepted, so rolling back across an intended change works.
+```
+
+- [ ] **Step 2: Run the full gate**
+
+```bash
+GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false nice -n 10 go vet ./... && \
+GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false nice -n 10 go test ./... -count=1 && \
+GOOS=darwin GOARCH=arm64 GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go build ./... && \
+GOOS=windows GOARCH=amd64 GOCACHE=$TMPDIR/gocache GOFLAGS=-buildvcs=false go build ./...
+```
+
+Expected: all pass.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add README.md
+git commit -m "README: operator note for the wrong-program check"
+```