# 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.
```

It is README prose, so invoke the `copy-review` skill on it before
committing, and apply Paul's edits verbatim.

- [ ] **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"
```
