carlos / platform Public

plan: console as an app — 10 TDD tasks and a rollout runbook

Ten code tasks, then an ordered runbook that is deliberately NOT tasks
(each step is an operational act with a stated proof, not a test cycle).

Task order is forced by two things the spec found. The flag has to exist
before the claim, or the row is a window in which any ops member — or any
Publish service credential — can roll the production console. And the code
has to reach the boxes before the first self-deploy, so R1 is one last
roll-console.sh: without it R5 runs against a console that predates its
own probe and cannot succeed.

Two artifact checks in task 8 were verified against a real arm64 console
build rather than assumed: debug/elf reads EM_AARCH64 and debug/buildinfo
reads the -ldflags setting back, and -s -w strips neither.

Task 1 puts both new columns in the ALTER block AND in migrateAppsKey's
apps_new copy list, with a test that builds a pre-rebuild database by hand
— the rebuild copies an enumerated column list, so one place is not
enough, and store.go:280 already says so about deleted_at and placement.

Task 7 is the tenant fix: a rolled-back restart now starts the service
again. Without it the box is left with a correct symlink and no process,
and the next pass's try-restart — a no-op on a stopped unit — exits 0 and
stamps a build that has never run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 1258e9a6c21e146d771ca0a2c5be6e93303a084b parent 06cc998
1 file changed, +1968 −0
  • docs/superpowers/plans/2026-08-23-console-as-an-app.md +1968 −0
diff --git a/docs/superpowers/plans/2026-08-23-console-as-an-app.md b/docs/superpowers/plans/2026-08-23-console-as-an-app.md
new file mode 100644
index 000000000..5e4e2afb0
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-23-console-as-an-app.md
@@ -0,0 +1,1968 @@
+# Console as an App — Implementation Plan
+
+> **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:** Make the CARLOS console an ordinary app deployed by `carlos deploy`, retiring `roll-console.sh` to break-glass.
+
+**Architecture:** The console already ships as a release, rides a channel and is adopted through a route — what it lacks is an app *row* (blocked by #79), an honest way to verify which build is running (the edge's `X-Carlos-Version` is stamped from the route row and lies), and a rollback that leaves the box running. Tasks 1–4 give the app a row and a control-plane flag that gates destruction; tasks 5–6 replace header-watching with a probe the console process answers itself; tasks 7–10 close three failure modes the pinfra script has been papering over. Then a runbook rolls it out, tito first.
+
+**Tech Stack:** Go 1.24, SQLite (modernc driver), systemd, AWS SSM (runbook only, never from `carlos`).
+
+**Spec:** `docs/superpowers/specs/2026-08-23-console-as-an-app-design.md` — read it first; this plan argues from it.
+
+## Global Constraints
+
+- `go build ./...`, `go vet ./...`, `go test ./... -count=1` clean, **every package**, before any commit lands.
+- `gofmt -l` clean for files you touch. Three pre-existing unformatted files elsewhere are left alone.
+- `GOOS=darwin go build ./...` must pass — `internal/host` links into the CLI, which people run on Macs.
+- Sandbox: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`.
+- **Wire-shape changes are additive.** Anything written before your change must still parse.
+- **Console gate order never moves**: session → CSRF → owner (byte-identical 404) → `requireFreshAuth` LAST.
+- A test that shells out to the `carlos` binary must build its env through `carlosTestEnv` and set `cmd.Dir` to a scratch dir.
+- Every change lands through a PR. Nothing merges to `main` locally.
+- Branch: `console-as-an-app` (already exists, carries the spec).
+
+---
+
+### Task 1: Two columns on `apps`, and the migration trap
+
+**Files:**
+- Modify: `internal/console/store.go:189-231` (the additive ALTER block), `internal/console/store.go:266-288` (`migrateAppsKey`'s `apps_new`)
+- Modify: `internal/console/store.go` (new accessors near `SetAppPlacement`, ~line 1170)
+- Test: `internal/console/migrate_test.go`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces:
+ - `func (s *Store) AppFlags(accountID int64, app string) (controlPlane bool, buildProbe string, err error)`
+ - `func (s *Store) SetAppControlPlane(accountID int64, app string, on bool) error`
+ - `func (s *Store) SetAppBuildProbe(accountID int64, app, path string) error`
+
+**Why both columns land together:** they are one migration, and splitting them means touching `migrateAppsKey`'s copy list twice.
+
+**The trap** (spec §8): the ALTER block at `store.go:189` runs **before** `migrateAppsKey()` at line 231, and that rebuild copies an **enumerated** column list into `apps_new`. A column added only to the ALTER block is silently dropped for any database that has not yet been rebuilt — a self-hoster jumping versions. `deleted_at` and `placement` are already in both places for exactly this reason; the comment at `store.go:280` says so. Follow that pattern: **add to both.**
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `internal/console/migrate_test.go`:
+
+```go
+// TestAppFlagsSurviveTheAppsKeyRebuild: control_plane and build_probe are
+// added by the ALTER block, which runs BEFORE migrateAppsKey's table rebuild
+// (store.go:189 vs :231). The rebuild copies an ENUMERATED column list, so a
+// column missing from apps_new is silently dropped on any database that has
+// not been rebuilt yet — a self-hoster jumping versions. deleted_at and
+// placement are in both places for this reason (store.go:280); these must be
+// too, and this test is what proves it rather than a comment.
+func TestAppFlagsSurviveTheAppsKeyRebuild(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "old.db")
+
+ // A pre-rebuild database: PRIMARY KEY(app), the shape migrateAppsKey exists
+ // to replace. Built by hand because no released binary produces it any more.
+ raw, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := raw.Exec(`CREATE TABLE apps (
+ app TEXT PRIMARY KEY,
+ account_id INTEGER NOT NULL DEFAULT 1,
+ created_by TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ deleted_at INTEGER NOT NULL DEFAULT 0,
+ placement TEXT NOT NULL DEFAULT ''
+);`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := raw.Exec(`INSERT INTO apps (app, account_id, created_by, created_at)
+VALUES ('console', 1, 'paul@keymail.dev', 1)`); err != nil {
+ t.Fatal(err)
+ }
+ if err := raw.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ st, err := Open(path)
+ if err != nil {
+ t.Fatalf("Open (which runs migrate) on a pre-rebuild database: %v", err)
+ }
+ defer st.Close()
+
+ if err := st.SetAppControlPlane(OpsAccountID, "console", true); err != nil {
+ t.Fatalf("SetAppControlPlane: %v — the column did not survive the rebuild", err)
+ }
+ if err := st.SetAppBuildProbe(OpsAccountID, "console", "/healthz"); err != nil {
+ t.Fatalf("SetAppBuildProbe: %v", err)
+ }
+ cp, probe, err := st.AppFlags(OpsAccountID, "console")
+ if err != nil {
+ t.Fatalf("AppFlags: %v", err)
+ }
+ if !cp || probe != "/healthz" {
+ t.Fatalf("AppFlags = %v %q, want true \"/healthz\"", cp, probe)
+ }
+}
+
+// TestAppFlagsDefaultOff: every row that existed before this change means
+// "not the control plane, no probe" — the additive-default idiom the kind and
+// service-principal columns used.
+func TestAppFlagsDefaultOff(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ cp, probe, err := fx.st.AppFlags(OpsAccountID, "titogo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cp || probe != "" {
+ t.Fatalf("a freshly claimed app = %v %q, want false \"\"", cp, probe)
+ }
+}
+```
+
+Check `migrate_test.go`'s existing imports; add `database/sql`, `path/filepath`, `time` if absent, and confirm the sqlite driver import matches what `store.go` uses (`_ "modernc.org/sqlite"` or the package's existing alias).
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+```
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/console/ -run 'TestAppFlags' -count=1
+```
+
+Expected: FAIL — `st.SetAppControlPlane undefined`.
+
+- [ ] **Step 3: Add the columns to the ALTER block**
+
+In `internal/console/store.go`, append to the additive slice that ends with the `accounts … tier` statement (~line 226):
+
+```go
+ // Control-plane apps (console-as-an-app spec §6): the flag REFUSES
+ // destruction — trash, purge, rename, transfer — and refuses a
+ // service principal's promote. It deliberately does NOT gate a
+ // person's promote: `stable`'s fresh-auth 403 has no API-side
+ // step-up and `carlos deploy` log.Fatalfs on a failed promote AFTER
+ // the ship has landed (deploy.go:206-208), so gating promotion would
+ // break `carlos deploy -app console` — the command the flag exists to
+ // make safe. Deploying the console should be easy; destroying it
+ // should be hard.
+ `ALTER TABLE apps ADD COLUMN control_plane INTEGER NOT NULL DEFAULT 0;`,
+ // The path `carlos deploy` polls for the build the app's own PROCESS
+ // reports, instead of X-Carlos-Version — which the edge stamps from
+ // the route row (edge.go:1336) and which therefore lies from the
+ // moment adoption swaps the symlink until something restarts the
+ // process. "" keeps today's header behaviour byte for byte.
+ `ALTER TABLE apps ADD COLUMN build_probe TEXT NOT NULL DEFAULT '';`,
+```
+
+- [ ] **Step 4: Add the same columns to `migrateAppsKey`'s rebuild**
+
+In `internal/console/store.go`, `migrateAppsKey`, extend `apps_new` and the copy — both, or the rebuild drops them:
+
+```go
+ `CREATE TABLE apps_new (
+ app TEXT NOT NULL,
+ account_id INTEGER NOT NULL DEFAULT 1,
+ created_by TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ deleted_at INTEGER NOT NULL DEFAULT 0,
+ placement TEXT NOT NULL DEFAULT '',
+ control_plane INTEGER NOT NULL DEFAULT 0,
+ build_probe TEXT NOT NULL DEFAULT '',
+ PRIMARY KEY (account_id, app)
+);`,
+ `INSERT INTO apps_new (app, account_id, created_by, created_at, deleted_at, placement, control_plane, build_probe)
+ SELECT app, account_id, created_by, created_at, deleted_at, placement, control_plane, build_probe FROM apps;`,
+```
+
+Extend the existing comment above the INSERT to name the two new columns alongside `deleted_at` and `placement`.
+
+- [ ] **Step 5: Add the accessors**
+
+In `internal/console/store.go`, after `SetAppPlacement`:
+
+```go
+// AppFlags reads the two per-app deployment facts the control-plane work
+// added: whether this app IS the control plane, and the path `carlos deploy`
+// should poll for a build the app's own process reports.
+//
+// One query for both because both doors that read them read them together
+// (the ship gate and the deploy watch), and a second round trip to the same
+// row buys nothing.
+func (s *Store) AppFlags(accountID int64, app string) (controlPlane bool, buildProbe string, err error) {
+ err = s.db.QueryRow(`SELECT control_plane, build_probe FROM apps WHERE account_id = ? AND app = ?`,
+ accountID, app).Scan(&controlPlane, &buildProbe)
+ if errors.Is(err, sql.ErrNoRows) {
+ // No row is not an error here: every caller has already been through
+ // apiAccountApp's byte-identical 404, so reaching this with no row
+ // means a race with a purge, and "not the control plane, no probe" is
+ // the right reading of an app that no longer exists.
+ return false, "", nil
+ }
+ return controlPlane, buildProbe, err
+}
+
+// SetAppControlPlane marks (or unmarks) an app as the control plane. The row
+// is guaranteed to exist: both callers reach this straight after a successful
+// CreateAppIn or behind apiAccountApp's gate.
+func (s *Store) SetAppControlPlane(accountID int64, app string, on bool) error {
+ _, err := s.db.Exec(`UPDATE apps SET control_plane = ? WHERE account_id = ? AND app = ?`,
+ on, accountID, app)
+ return err
+}
+
+// SetAppBuildProbe stamps the deploy watch's probe path. "" clears it back to
+// header-watching.
+func (s *Store) SetAppBuildProbe(accountID int64, app, path string) error {
+ _, err := s.db.Exec(`UPDATE apps SET build_probe = ? WHERE account_id = ? AND app = ?`,
+ path, accountID, app)
+ return err
+}
+```
+
+Confirm `errors` and `database/sql` are already imported in `store.go`; add if not.
+
+- [ ] **Step 6: Run the tests to verify they pass**
+
+```
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/console/ -run 'TestAppFlags' -count=1 -v
+```
+
+Expected: both PASS.
+
+- [ ] **Step 7: Full gate**
+
+```
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off sh -c '
+ go build ./... && go vet ./... && GOOS=darwin go build ./... &&
+ go test ./... -count=1'
+gofmt -l internal/console/store.go internal/console/migrate_test.go
+```
+
+Expected: no failures, no gofmt output.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add internal/console/store.go internal/console/migrate_test.go
+git commit -m "console: control_plane and build_probe on the app row
+
+Both in the ALTER block AND in migrateAppsKey's apps_new copy list. The
+rebuild copies an enumerated column list, so a column in only the first
+place is silently dropped on a database that has not been rebuilt yet —
+which is a self-hoster jumping versions, not a hypothetical. deleted_at
+and placement are already in both for this reason (store.go:280); the new
+test builds a pre-rebuild database by hand and proves it."
+```
+
+---
+
+### Task 2: The #79 owner exemption
+
+**Files:**
+- Modify: `internal/console/server.go:2426` (`claimApp`), `internal/console/server.go:2518-2545` (`handleNewApp`), `internal/console/apicli.go:394` (`handleAPICreateApp`)
+- Test: `internal/console/apps_claim_test.go` (create)
+
+**Interfaces:**
+- Consumes: nothing from Task 1.
+- Produces: `claimApp` gains a final parameter — `func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placement string, ownerClaim bool) (outcome claimOutcome, placed string)`.
+
+**The reasoning** (spec §2): `apps/<sqid>/<app>/` is namespaced **by account**, so objects under `apps/bab/` can only be `ops`'s. An owner of `bab` claiming a name under `bab`'s own prefix is adopting their own objects, not inheriting a stranger's. The guard is right for a stranger and wrong for the owner.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/console/apps_claim_test.go`:
+
+```go
+package console
+
+import (
+ "context"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+// seedBucketApp puts an object under an account's own app prefix WITHOUT a
+// row, which is exactly what a direct-bucket ship does (direct mode skips the
+// claim) and exactly the state that makes #79 unrecoverable: the objects
+// block the claim, and no pass can discover a row that does not exist.
+func seedBucketApp(t *testing.T, fx *consoleFixture, sqid, app string) {
+ t.Helper()
+ key := "apps/" + sqid + "/" + app + "/channels/canary-console.json"
+ if err := fx.rel.Put(context.Background(), key, []byte(`{}`)); err != nil {
+ t.Fatalf("seed %s: %v", key, err)
+ }
+}
+
+// TestOwnerClaimsAppWhoseObjectsExist is #79's fix. Before it, an app first
+// shipped direct-to-bucket could never be claimed and so was permanently
+// invisible to the console, with no recovery but deleting its release history.
+func TestOwnerClaimsAppWhoseObjectsExist(t *testing.T) {
+ fx := newConsoleFixture(t)
+ seedBucketApp(t, fx, "bab", "console")
+ acct, err := fx.st.Account(OpsAccountID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ outcome, _ := fx.srv.claimApp(context.Background(), acct, "console", "paul@keymail.dev", "", true)
+ if outcome != claimOK {
+ t.Fatalf("owner claim over existing objects = %v, want claimOK", outcome)
+ }
+ has, err := fx.st.HasAppIn(OpsAccountID, "console")
+ if err != nil || !has {
+ t.Fatalf("HasAppIn after claim = %v %v, want true", has, err)
+ }
+}
+
+// TestNonOwnerStillRefusedOverExistingObjects: the exemption is OWNERSHIP, not
+// membership. Adopting existing release history is an ownership act.
+func TestNonOwnerStillRefusedOverExistingObjects(t *testing.T) {
+ fx := newConsoleFixture(t)
+ seedBucketApp(t, fx, "bab", "console")
+ acct, err := fx.st.Account(OpsAccountID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ outcome, _ := fx.srv.claimApp(context.Background(), acct, "console", "member@keymail.dev", "", false)
+ if outcome != claimTaken {
+ t.Fatalf("non-owner claim over existing objects = %v, want claimTaken", outcome)
+ }
+}
+
+// TestLegacyPrefixStillBlocksAnOwner: the exemption covers the ACCOUNT-
+// QUALIFIED prefix only. Legacy objects at apps/<app>/ predate account scoping
+// and are owed an `accounts migrate -from legacy`; handing the name out would
+// put an app in the way of that migration.
+func TestLegacyPrefixStillBlocksAnOwner(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := fx.rel.Put(context.Background(), "apps/oldsite/channels/stable.json", []byte(`{}`)); err != nil {
+ t.Fatal(err)
+ }
+ acct, err := fx.st.Account(OpsAccountID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ outcome, _ := fx.srv.claimApp(context.Background(), acct, "oldsite", "paul@keymail.dev", "", true)
+ if outcome != claimTaken {
+ t.Fatalf("owner claim over a LEGACY prefix = %v, want claimTaken — those objects are owed a migrate", outcome)
+ }
+}
+
+// TestGrantStillBlocksAnOwner: a CLI-granted app in ops stays claim-proof.
+// Verified live on flagship 2026-08-23: derp, meep and platform-site hold
+// grants; console does not, which is why the exemption is sufficient for it.
+func TestGrantStillBlocksAnOwner(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := fx.st.Grant("someone@keymail.dev", "derp"); err != nil {
+ t.Fatal(err)
+ }
+ acct, err := fx.st.Account(OpsAccountID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ outcome, _ := fx.srv.claimApp(context.Background(), acct, "derp", "paul@keymail.dev", "", true)
+ if outcome != claimTaken {
+ t.Fatalf("owner claim over a GRANTED name = %v, want claimTaken", outcome)
+ }
+}
+
+// TestServicePrincipalNeverClaimsOverObjects: a service has no role at all, so
+// ownerClaim must be pinned FALSE for it. An account-scoped PrincipalService
+// legitimately reaches claimApp (apicli.go blocks only app-scoped ones), so
+// without the pin the exemption would silently hand every CI credential the
+// power to adopt release history.
+func TestServicePrincipalNeverClaimsOverObjects(t *testing.T) {
+ fx := newConsoleFixture(t)
+ seedBucketApp(t, fx, "bab", "console")
+ svc := serviceToken(t, fx.st, OpsAccountID, "hub", "", svcrole.Publish)
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab", map[string]string{"name": "console"}, svc)
+ if status != http.StatusConflict {
+ t.Fatalf("service claim over existing objects = %d %v, want 409 (taken)", status, out)
+ }
+ has, err := fx.st.HasAppIn(OpsAccountID, "console")
+ if err != nil || has {
+ t.Fatalf("HasAppIn after a service claim = %v %v, want false — a service must never adopt release history", has, err)
+ }
+}
+```
+
+Two things to check against the codebase before running, and fix in the test if they differ: the release-store `Put` helper's name on `fx.rel` (grep `fx.rel.` in existing tests), and `handleAPICreateApp`'s success/failure status codes and request path (read `apicli.go` around line 394 and the route registration in `authcli.go`). Use whatever the existing create tests use.
+
+- [ ] **Step 2: Run to verify they fail**
+
+```
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/console/ -run 'Claim|ClaimsApp|Refused|Blocks|ServicePrincipalNever' -count=1
+```
+
+Expected: FAIL — `too many arguments in call to fx.srv.claimApp`.
+
+- [ ] **Step 3: Add the parameter and the exemption**
+
+In `internal/console/server.go`, change the signature and the bucket guard. Extend `claimApp`'s doc comment with the new paragraph:
+
+```go
+// ownerClaim exempts the ACCOUNT-QUALIFIED prefix check below, and nothing
+// else. The guard exists so a stranger cannot inherit objects somebody else
+// shipped — but apps/<sqid>/<app>/ is namespaced BY ACCOUNT, so objects under
+// a given sqid can only be that account's. An OWNER of that account claiming
+// a name under its own prefix is adopting their own release history, which is
+// the whole of issue #79: an app first shipped direct-to-bucket (direct mode
+// skips the claim) could otherwise never be given a row, and so stayed
+// invisible to the console forever with no recovery but deleting its history.
+//
+// It is OWNER, not member: adopting existing release history is an ownership
+// act. It is FALSE for every service principal — a service has no role at
+// all, and an account-scoped one legitimately reaches this function, so
+// without an explicit pin the exemption would hand every CI credential the
+// same power.
+func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placement string, ownerClaim bool) (outcome claimOutcome, placed string) {
+```
+
+Then the prefix loop. The legacy prefix must keep refusing, so the exemption applies per-prefix, not to the loop:
+
+```go
+ for _, prefix := range prefixes {
+ keys, err := s.cfg.Releases.List(ctx, prefix)
+ if err != nil {
+ return claimStoreFailed, ""
+ }
+ if len(keys) == 0 {
+ continue
+ }
+ // The owner exemption covers the account's OWN prefix only. The
+ // legacy unqualified one (ops only) still refuses whoever is asking:
+ // those objects are owed an `accounts migrate -from legacy`, and a
+ // name handed out over them puts an app in that migration's way.
+ if ownerClaim && prefix != release.LegacyAppPrefix(name) {
+ continue
+ }
+ return claimTaken, ""
+ }
+```
+
+- [ ] **Step 4: Update both call sites**
+
+`internal/console/apicli.go:394` — a service principal is pinned false:
+
+```go
+ // 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
+ outcome, placed := s.claimApp(r.Context(), acct, name, addr, place, ownerClaim)
+```
+
+`internal/console/server.go:2542` — the browser door already has the role and was discarding it:
+
+```go
+ addr, raw, acct, role, ok := s.account(w, r)
+```
+
+```go
+ outcome, _ := s.claimApp(r.Context(), acct, name, addr, "", role == RoleOwner)
+```
+
+And correct `handleNewApp`'s doc comment, whose stated policy this changes:
+
+```go
+// handleNewApp is the browser door onto claimApp: the account page's New app
+// form. Any member may claim a FREE name — creation grants nothing promotion
+// doesn't already gate — and unlike the CLI door it carries no rate limit: a
+// session behind CSRF is not the stolen-credential shape the API budget
+// exists for, and this form is the escape hatch when that budget is honestly
+// spent.
+//
+// An OWNER may additionally claim a name whose objects already exist under
+// this account's own prefix (#79, claimApp's ownerClaim). A plain member
+// cannot: adopting existing release history is an ownership act.
+```
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+```
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/console/ -run 'Claim|ClaimsApp|Refused|Blocks|ServicePrincipalNever' -count=1 -v
+```
+
+Expected: all five PASS.
+
+- [ ] **Step 6: Full gate, then commit**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off sh -c '
+ go build ./... && go vet ./... && GOOS=darwin go build ./... && go test ./... -count=1'
+gofmt -l internal/console/server.go internal/console/apicli.go internal/console/apps_claim_test.go
+git add internal/console/server.go internal/console/apicli.go internal/console/apps_claim_test.go
+git commit -m "console: an owner may claim a name whose own-account objects exist (#79)
+
+apps/<sqid>/<app>/ is namespaced BY ACCOUNT, so objects under a sqid can
+only be that account's — an owner claiming there is adopting their own
+release history, not inheriting a stranger's. That is the whole of #79: an
+app first shipped direct-to-bucket was otherwise unclaimable forever, with
+no recovery but deleting its history.
+
+Owner, not member. Pinned FALSE for every service principal: an
+account-scoped one reaches claimApp and holds no account role, so without
+the pin every CI credential could adopt release history. The legacy
+unqualified prefix still refuses everyone — those objects are owed a
+migrate and the name must stay out of its way."
+```
+
+---
+
+### Task 3: Set the flags at claim time
+
+**Files:**
+- Modify: `internal/console/apicli.go` (`handleAPICreateApp` request struct and post-claim stamping)
+- Modify: `apps.go:379` (`runAppsCreate`)
+- Test: `internal/console/apps_claim_test.go`, `apps_test.go`
+
+**Interfaces:**
+- Consumes: `Store.SetAppControlPlane`, `Store.SetAppBuildProbe` (Task 1); `claimApp(..., ownerClaim)` (Task 2).
+- Produces: `carlos apps create` accepts `-control-plane` and `-build-probe <path>`; the API accepts `"control_plane": bool` and `"build_probe": string`.
+
+**Why at claim time, not a settings page:** the runbook claims `ops/console` **with the flag already set** (spec §10 step 10). A row that exists without the flag is a window in which any ops member or `Publish` service principal can roll the production console. One step, no window. A settings-page toggle is deferred.
+
+- [ ] **Step 1: Write the failing tests**
+
+Add to `internal/console/apps_claim_test.go`:
+
+```go
+// TestCreateAppStampsControlPlaneAndProbe: the flags are set in the SAME call
+// that claims the name. Claiming first and flagging second would leave a
+// window in which the production console is an ordinary app any ops member
+// could roll.
+func TestCreateAppStampsControlPlaneAndProbe(t *testing.T) {
+ fx := newConsoleFixture(t)
+ seedBucketApp(t, fx, "bab", "console")
+ token := cliToken(t, fx, "paul@keymail.dev", "code-create-cp")
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab", map[string]any{
+ "name": "console", "control_plane": true, "build_probe": "/healthz",
+ }, token)
+ if status != http.StatusCreated {
+ t.Fatalf("create = %d %v, want 201", status, out)
+ }
+ cp, probe, err := fx.st.AppFlags(OpsAccountID, "console")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !cp || probe != "/healthz" {
+ t.Fatalf("flags after create = %v %q, want true \"/healthz\"", cp, probe)
+ }
+}
+
+// TestOnlyAnOwnerSetsControlPlane: the flag refuses destruction, so setting it
+// is an ownership act — a member who could set it could also make an app
+// undeletable by its owner.
+func TestOnlyAnOwnerSetsControlPlane(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := fx.st.AddMember(OpsAccountID, "member@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "member@keymail.dev", "code-create-cp-member")
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab", map[string]any{
+ "name": "notyours", "control_plane": true,
+ }, token)
+ if status != http.StatusForbidden {
+ t.Fatalf("member setting control_plane = %d %v, want 403", status, out)
+ }
+ has, err := fx.st.HasAppIn(OpsAccountID, "notyours")
+ if err != nil || has {
+ t.Fatalf("the app was claimed anyway (%v) — a refused flag must refuse the WHOLE create, like -place does", has)
+ }
+}
+
+// TestBuildProbeMustBeAnAbsolutePath: the probe is a URL path the deploy watch
+// dials. Anything else is a typo that would otherwise surface as a deploy that
+// times out for reasons nobody can see.
+func TestBuildProbeMustBeAnAbsolutePath(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-create-probe-bad")
+ for _, bad := range []string{"healthz", "http://x/healthz", "/healthz?x=1", "/health z"} {
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab", map[string]any{
+ "name": "probe" + bad[:1], "build_probe": bad,
+ }, token)
+ if status != http.StatusBadRequest {
+ t.Fatalf("build_probe %q = %d %v, want 400", bad, status, out)
+ }
+ }
+}
+```
+
+Confirm `RoleMember`'s actual identifier by grepping `RoleOwner` in `internal/console`; use whatever the member constant is really called.
+
+- [ ] **Step 2: Run to verify they fail**
+
+```
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/console/ -run 'CreateAppStamps|OnlyAnOwnerSets|BuildProbeMust' -count=1
+```
+
+Expected: FAIL — flags ignored, 201 where 403/400 expected.
+
+- [ ] **Step 3: Extend the API door**
+
+In `internal/console/apicli.go`, `handleAPICreateApp`, extend the request struct and validate **before** `claimApp` — the create limiter charges attempts, so a shape the server is certain to refuse must not cost a claim:
+
+```go
+ var req struct {
+ Name string `json:"name"`
+ Place string `json:"place"`
+ ControlPlane bool `json:"control_plane"`
+ BuildProbe string `json:"build_probe"`
+ }
+```
+
+```go
+ // 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
+ }
+```
+
+Add the validator near `normalizeAppName` in `server.go`:
+
+```go
+// validBuildProbe bounds the deploy watch's probe path to what it actually
+// dials: an absolute path, no query, no spaces. The watch appends its own
+// cache-busting query parameter (deploy.go), so a probe carrying one would
+// produce a URL with two — and a probe that is a full URL would silently
+// point the watch at another host entirely, which is the one mistake here
+// that could report a DIFFERENT deployment's build as this one's.
+func validBuildProbe(p string) bool {
+ if !strings.HasPrefix(p, "/") || strings.ContainsAny(p, "?# \t") {
+ return false
+ }
+ return len(p) <= 256
+}
+```
+
+Then stamp after a successful claim, beside the existing placement stamp:
+
+```go
+ if req.ControlPlane {
+ if err := s.store.SetAppControlPlane(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.SetAppBuildProbe(acct.ID, name, probe); err != nil {
+ log.Printf("console: claim %s/%s: set build_probe: %v", acct.Sqid, name, err)
+ }
+ }
+```
+
+Echo both in the response body alongside `placement`, for the same reason `placed` is echoed: a caller that cannot see what was actually stored cannot tell "claimed and flagged" from "claimed, flag silently didn't".
+
+- [ ] **Step 4: Extend the CLI**
+
+In `apps.go`, `runAppsCreate`:
+
+```go
+ controlPlane := fs.Bool("control-plane", false,
+ "mark the app as the control plane: it refuses trash, purge, rename and transfer, "+
+ "and refuses a service credential's promote (owner only)")
+ buildProbe := fs.String("build-probe", "",
+ `path whose response body carries the running build, e.g. "/healthz" — `+
+ "`carlos deploy` polls it instead of the X-Carlos-Version header, which the edge "+
+ "stamps from the route row and which therefore lies until the process restarts")
+```
+
+Extend the usage string with `[-control-plane] [-build-probe /path]`, send both fields in the request body, and print what came back in the echo so the operator sees the stored truth rather than what they asked for.
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+```
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/console/ ./ -run 'CreateAppStamps|OnlyAnOwnerSets|BuildProbeMust|AppsCreate' -count=1 -v
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Full gate, then commit**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off sh -c '
+ go build ./... && go vet ./... && GOOS=darwin go build ./... && go test ./... -count=1'
+gofmt -l internal/console/apicli.go internal/console/server.go apps.go internal/console/apps_claim_test.go
+git add -A
+git commit -m "apps create: -control-plane and -build-probe, stamped with the claim
+
+Set in the SAME call that claims the name. Claiming first and flagging
+second leaves a window in which the production console is an ordinary app
+any ops member — or any Publish service credential — could roll.
+
+Both validated before claimApp, like -place: a refused flag refuses the
+whole create rather than leaving a claimed name with the wrong shape, and
+costs no attempt against the per-day create budget. Both echoed in the
+response, so a caller can tell 'claimed and flagged' from 'claimed, flag
+silently didn't'."
+```
+
+---
+
+### Task 4: The flag refuses destruction, and refuses a service's promote
+
+**Files:**
+- Modify: `internal/console/ownership.go:293` (`handlePurgeApp`), `internal/console/appsettings.go` (`handleAppRename`, `handleAppDelete`, `handleAppMoveAccount`)
+- Modify: `internal/console/apicli.go` (`handleAPITrashApp`, `handleAPIPromote`, `handleAPIRollback`)
+- Test: `internal/console/controlplane_test.go` (create)
+
+**Interfaces:**
+- Consumes: `Store.AppFlags` (Task 1).
+- Produces: `func (s *Server) refuseControlPlane(w http.ResponseWriter, accountID int64, app, verb string) bool` — returns true when it wrote a refusal and the caller must stop.
+
+**The inversion** (spec §6): an earlier draft gated *promotion* behind fresh auth. That would have made `carlos deploy -app console` ship and then die every time, because `stable`'s 403 has no API-side step-up and `deploy.go:206-208` `log.Fatalf`s after the ship lands. Deploying the console should be easy; destroying it should be hard — and `handlePurgeApp` can currently delete `apps/bab/console/**`, the release history the fleet is *running*, behind an ordinary confirmation.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/console/controlplane_test.go`:
+
+```go
+package console
+
+import (
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+func controlPlaneFixture(t *testing.T, label string) (*consoleFixture, string) {
+ t.Helper()
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "console", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.SetAppControlPlane(OpsAccountID, "console", true); err != nil {
+ t.Fatal(err)
+ }
+ return fx, cliToken(t, fx, "paul@keymail.dev", label)
+}
+
+// TestControlPlaneAppRefusesTrash: the console's release history is not
+// something to delete behind a confirmation dialog. Refused outright, not
+// "with fresh auth" — a gesture is not the guard here, the flag is.
+func TestControlPlaneAppRefusesTrash(t *testing.T) {
+ fx, token := controlPlaneFixture(t, "code-cp-trash")
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/delete", map[string]string{}, token)
+ if status != http.StatusForbidden {
+ t.Fatalf("trash a control-plane app = %d %v, want 403", status, out)
+ }
+ if msg := str(t, out, "error"); !strings.Contains(msg, "control plane") {
+ t.Fatalf("refusal %q does not name the flag the operator has to clear", msg)
+ }
+ trashed, err := fx.st.HasAppIn(OpsAccountID, "console")
+ if err != nil || !trashed {
+ t.Fatalf("app row after a refused trash: HasAppIn = %v %v, want true (untouched)", trashed, err)
+ }
+}
+
+// TestControlPlaneAppAllowsAPersonToPromote is the other half, and the one
+// that matters most: gating promotion is what an earlier draft got wrong.
+// `stable`'s fresh-auth 403 has no API-side step-up and carlos deploy
+// log.Fatalfs on a failed promote AFTER the ship has landed, so a promote gate
+// would break the very command this work exists to create.
+func TestControlPlaneAppAllowsAPersonToPromote(t *testing.T) {
+ fx, token := controlPlaneFixture(t, "code-cp-promote")
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token,
+ []shipPart{metaPart(t, "cp00001", "binary"), {name: "linux-arm64", body: []byte("x")}})
+ if status != http.StatusCreated {
+ t.Fatalf("ship = %d %v", status, out)
+ }
+ status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/promote",
+ map[string]string{"version": "cp00001", "channel": "canary/console"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("a PERSON promoting a control-plane app = %d %v, want 200 — gating this breaks carlos deploy", status, out)
+ }
+}
+
+// TestControlPlaneAppRefusesAServicePromote: a person with ops membership may
+// roll the console; a CI credential may not. This is what the flag buys on the
+// promote path instead of a fresh-auth gate.
+func TestControlPlaneAppRefusesAServicePromote(t *testing.T) {
+ fx, token := controlPlaneFixture(t, "code-cp-svc")
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token,
+ []shipPart{metaPart(t, "cp00002", "binary"), {name: "linux-arm64", body: []byte("x")}})
+ if status != http.StatusCreated {
+ t.Fatalf("ship = %d %v", status, out)
+ }
+ svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Publish)
+ status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/promote",
+ map[string]string{"version": "cp00002", "channel": "canary/console"}, svc)
+ if status != http.StatusForbidden {
+ t.Fatalf("a SERVICE promoting a control-plane app = %d %v, want 403", status, out)
+ }
+}
+
+// TestOrdinaryAppUnaffected: every refusal above is scoped to the flag.
+func TestOrdinaryAppUnaffected(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-cp-ordinary")
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", map[string]string{}, token)
+ if status == http.StatusForbidden {
+ t.Fatalf("trash an ORDINARY app = %d %v, want it to work as before", status, out)
+ }
+}
+
+// TestControlPlaneRefusalIsNotAMembershipOracle: a non-member must meet the
+// byte-identical 404 in apiAccountApp before the flag is ever consulted, or
+// the refusal becomes a way to learn which accounts hold a control-plane app.
+func TestControlPlaneRefusalIsNotAMembershipOracle(t *testing.T) {
+ fx, _ := controlPlaneFixture(t, "code-cp-oracle")
+ stranger := cliToken(t, fx, "eve@keymail.dev", "code-cp-stranger")
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/delete", map[string]string{}, stranger)
+ if status != http.StatusNotFound {
+ t.Fatalf("a non-member = %d %v, want the byte-identical 404", status, out)
+ }
+}
+```
+
+Add `strings` to the imports. Check `handleAPIPromote`'s real request field names (read `apicli.go` around the promote handler) and use them; the ones above are a guess and must be corrected to match.
+
+- [ ] **Step 2: Run to verify they fail**
+
+```
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/console/ -run 'ControlPlane|OrdinaryAppUnaffected' -count=1
+```
+
+Expected: FAIL — trash succeeds, service promote succeeds.
+
+- [ ] **Step 3: Add the shared refusal**
+
+In `internal/console/controlplane.go` (create):
+
+```go
+// Control-plane apps: the flag that says "this app IS the platform".
+//
+// It gates DESTRUCTION, not promotion, and the asymmetry is the whole design.
+// Deploying the console should be as ordinary as deploying any app — that is
+// what this slice exists to make true — so a promote gate would defeat it:
+// `stable`'s fresh-auth 403 has no API-side step-up, and `carlos deploy`
+// log.Fatalfs on a failed promote AFTER the ship has already landed
+// (deploy.go:206-208), so any gate here would leave every console deploy
+// half-done. Destroying the console is the act that deserves a wall, and it
+// had none: handlePurgeApp will delete apps/<sqid>/console/** — the release
+// history the fleet is currently running — behind an owner check, a typed
+// name and a passkey, none of which knows what this app is.
+//
+// Refused OUTRIGHT rather than behind fresh auth. A gesture proves intent,
+// and intent is not the thing in doubt: an operator who means to delete the
+// console can clear the flag first, which is a separate, deliberate act with
+// its own record.
+package console
+
+import (
+ "log"
+ "net/http"
+)
+
+// refuseControlPlane writes a refusal and returns true when app is the control
+// plane. Callers MUST be past their own membership gate first — the
+// byte-identical 404 protects account membership, and consulting the flag
+// before it would turn this refusal into an oracle for which accounts hold a
+// control-plane app.
+//
+// A lookup failure refuses too. The alternative — treating an unreadable row
+// as "not the control plane" — makes a database blip the way past the guard.
+func (s *Server) refuseControlPlane(w http.ResponseWriter, accountID int64, app, verb string) bool {
+ cp, _, err := s.store.AppFlags(accountID, app)
+ if err != nil {
+ log.Printf("console: control-plane check %s/%d: %v", app, accountID, err)
+ jsonError(w, http.StatusServiceUnavailable, "could not check this app's control-plane status — nothing was changed")
+ return true
+ }
+ if !cp {
+ return false
+ }
+ jsonError(w, http.StatusForbidden, app+" is marked as the control plane, so it cannot be "+verb+
+ " — clear the control-plane flag first if you really mean to")
+ return true
+}
+```
+
+For the browser doors, which render pages rather than JSON, add a sibling that sets a flash and redirects, following whatever pattern `handleAppRename` already uses for a refusal.
+
+- [ ] **Step 4: Wire the four destructive doors and the promote pair**
+
+Each call goes **after** the existing membership/owner gate and **before** any write or any fresh-auth prompt — refusing before a passkey round trip is both faster and clearer than asking for a gesture that cannot help:
+
+- `internal/console/apicli.go`, `handleAPITrashApp`: `if s.refuseControlPlane(w, acct.ID, app, "trashed") { return }`
+- `internal/console/appsettings.go`, `handleAppDelete`: refuse with `"trashed"`
+- `internal/console/appsettings.go`, `handleAppRename`: refuse with `"renamed"`
+- `internal/console/appsettings.go`, `handleAppMoveAccount`: refuse with `"moved to another account"`
+- `internal/console/ownership.go`, `handlePurgeApp`: refuse with `"deleted"` — this door takes the app from the trash form, so read the name it resolved
+
+For the promote pair, in `handleAPIPromote` and `handleAPIRollback`, after the existing gates and before `PromoteOnLadder`:
+
+```go
+ // 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.
+ if p.Kind != PrincipalUser {
+ cp, _, err := s.store.AppFlags(acct.ID, app)
+ if err != nil {
+ jsonError(w, http.StatusServiceUnavailable, "could not check this app's control-plane status — nothing was changed")
+ return
+ }
+ if cp {
+ jsonError(w, http.StatusForbidden,
+ app+" is the control plane — a service credential cannot promote it; a person must")
+ return
+ }
+ }
+```
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+```
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/console/ -run 'ControlPlane|OrdinaryAppUnaffected' -count=1 -v
+```
+
+Expected: all five PASS.
+
+- [ ] **Step 6: Full gate, then commit**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off sh -c '
+ go build ./... && go vet ./... && GOOS=darwin go build ./... && go test ./... -count=1'
+gofmt -l internal/console/controlplane.go internal/console/apicli.go internal/console/appsettings.go internal/console/ownership.go internal/console/controlplane_test.go
+git add -A
+git commit -m "console: the control-plane flag refuses destruction, not promotion
+
+Trash, purge, rename and transfer are refused outright for a flagged app;
+a service credential cannot promote one; a PERSON promotes it with
+ordinary ops membership.
+
+The asymmetry is the design. Gating promotion would have broken
+carlos deploy -app console — stable's fresh-auth 403 has no API-side
+step-up and deploy log.Fatalfs on a failed promote after the ship has
+landed. Meanwhile purge could delete the release history the fleet is
+running, behind a confirmation that had no idea what the app was."
+```
+
+---
+
+### Task 5: `/healthz` reports the build
+
+**Files:**
+- Modify: `internal/console/server.go:794-796`
+- Test: `internal/console/server_test.go:212` (extend the existing assertion)
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: `GET /healthz` → `ok <build>\n`, from the console process.
+
+**Existing consumers checked** (CLAUDE.md's parse-the-shape rule): `internal/vet` and the edge health probe assert **status only**, so appending is additive-safe. Re-verify with `grep -rn '/healthz' --include=*.go .` before changing it, and if any consumer compares the body exactly, fix that consumer in this task.
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `internal/console/server_test.go`:
+
+```go
+// TestHealthzCarriesTheBuild: the console's own build, from the PROCESS.
+//
+// This is the deploy watch's exit criterion, and it exists because both cheap
+// answers lie. X-Carlos-Version is stamped by the EDGE from the route row
+// (edge.go:1336), so adoption sets it while the old process keeps serving;
+// `systemctl is-active` says "active" for a process started from the previous
+// version just as happily (tito, 2026-08-09, seconds after adopt and restart
+// both reported success). Only the running process can answer this.
+func TestHealthzCarriesTheBuild(t *testing.T) {
+ fx := newConsoleFixture(t)
+ status, _, body := get(t, nil, fx.ts.URL+"/healthz")
+ if status != 200 {
+ t.Fatalf("/healthz = %d, want 200", status)
+ }
+ if !strings.Contains(body, "ok") {
+ t.Fatalf("/healthz body = %q, want it to still start with ok (vet and the edge probe read this)", body)
+ }
+ fields := strings.Fields(body)
+ if len(fields) != 2 || fields[1] != version.Build {
+ t.Fatalf("/healthz body = %q, want exactly \"ok %s\" — the watch matches a whitespace-delimited token", body, version.Build)
+ }
+}
+```
+
+Match `get`'s real signature from the neighbouring test at `server_test.go:212`, and import `internal/version` and `strings`.
+
+- [ ] **Step 2: Run to verify it fails**
+
+```
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/console/ -run TestHealthzCarriesTheBuild -count=1
+```
+
+Expected: FAIL — body is `"ok\n"`, two fields wanted.
+
+- [ ] **Step 3: Implement**
+
+`internal/console/server.go`:
+
+```go
+ // "ok <build>" — the build comes from THIS PROCESS (ldflags, at link
+ // time), which is the whole point: X-Carlos-Version is stamped by the
+ // edge from the route row, so it reports the version adoption INTENDED
+ // from the moment the symlink moves, whether or not anything restarted.
+ // `carlos deploy` polls this instead for any app declaring a build probe.
+ //
+ // "ok" stays the first token: internal/vet and the edge health probe read
+ // this endpoint and check status, not body, but keeping the prefix means
+ // a human or a script eyeballing it sees what it always saw.
+ s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintln(w, "ok", version.Build)
+ })
+```
+
+Import `github.com/carlosframework/platform/internal/version` in `server.go` if not present.
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+```
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/console/ -run 'Healthz|TestServer' -count=1
+```
+
+Expected: PASS, including the pre-existing `/healthz` assertion at line 212.
+
+- [ ] **Step 5: Full gate, then commit**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off sh -c '
+ go build ./... && go vet ./... && GOOS=darwin go build ./... && go test ./... -count=1'
+gofmt -l internal/console/server.go internal/console/server_test.go
+git add internal/console/server.go internal/console/server_test.go
+git commit -m "console: /healthz reports the build the PROCESS is running
+
+X-Carlos-Version is stamped by the edge from the route row, so it reports
+what adoption intended from the moment the symlink moves — whether or not
+anything restarted. systemctl is-active is no better. Only the process can
+answer this honestly, and now it does.
+
+'ok' stays the first token: vet and the edge probe read status not body,
+but a human eyeballing it should see what they always saw."
+```
+
+---
+
+### Task 6: `carlos deploy` polls the probe instead of the header
+
+**Files:**
+- Modify: `deploy.go` (`watchTarget`, `watchVersion`, and the call site that builds targets)
+- Modify: `internal/console/apicli.go` (serve `build_probe` on an existing per-app GET the deploy path already calls)
+- Test: `deploy_test.go`
+
+**Interfaces:**
+- Consumes: `Store.AppFlags` (Task 1); `/healthz` shape (Task 5).
+- Produces: `watchTarget` gains `Probe string`; when non-empty the watch polls `Origin + Probe` and matches the body, otherwise it polls `/` and matches `X-Carlos-Version` exactly as today.
+
+**Two lies this must not reintroduce** (spec §3b): the ldflags trap (a binary built without `-X …version.Build` reports `dev` forever, so a healthy deploy reports failure) — closed by naming the body in the timeout, and at the door in Task 8; and the edge cache — `watchVersion` already busts every poll because the edge caches a proxied 2xx for up to five minutes, and the probe leg **must inherit that**.
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `deploy_test.go`:
+
+```go
+// TestWatchProbeMatchesAWholeToken: the probe body must carry the build as a
+// whitespace-delimited token. Substring matching would let abc123 satisfy a
+// watch for abc1234 — and short git shas make that a real collision, not a
+// theoretical one.
+func TestWatchProbeMatchesAWholeToken(t *testing.T) {
+ var body atomic.Value
+ body.Store("ok abc123")
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/healthz" {
+ t.Errorf("watch dialled %q, want /healthz", r.URL.Path)
+ }
+ if r.URL.Query().Get("carlos-deploy-probe") == "" {
+ t.Error("probe poll carried no cache-buster — the edge caches a proxied 2xx for up to 5 minutes")
+ }
+ fmt.Fprintln(w, body.Load().(string))
+ }))
+ defer srv.Close()
+ old := watchOrigin
+ watchOrigin = func(host string) string { return srv.URL }
+ defer func() { watchOrigin = old }()
+
+ targets := []watchTarget{{Host: "console.example", Origin: srv.URL, Probe: "/healthz"}}
+ err := watchVersion(io.Discard, targets, "abc1234", 500*time.Millisecond, 50*time.Millisecond, nil)
+ if err == nil {
+ t.Fatal("watch for abc1234 succeeded against a body of \"ok abc123\" — substring match, not token match")
+ }
+
+ body.Store("ok abc1234")
+ if err := watchVersion(io.Discard, targets, "abc1234", 2*time.Second, 50*time.Millisecond, nil); err != nil {
+ t.Fatalf("watch against the matching build = %v, want success", err)
+ }
+}
+
+// TestWatchProbeTimeoutNamesTheBody: a binary built without the version
+// ldflag reports "dev" forever, so the deploy fails while the roll was
+// perfectly healthy. The report has to make that self-diagnosing rather than
+// saying "timed out".
+func TestWatchProbeTimeoutNamesTheBody(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintln(w, "ok dev")
+ }))
+ defer srv.Close()
+ var buf bytes.Buffer
+ targets := []watchTarget{{Host: "console.example", Origin: srv.URL, Probe: "/healthz"}}
+ err := watchVersion(&buf, targets, "abc1234", 300*time.Millisecond, 50*time.Millisecond, nil)
+ if err == nil {
+ t.Fatal("want a timeout")
+ }
+ out := buf.String() + err.Error()
+ if !strings.Contains(out, "dev") {
+ t.Fatalf("timeout report %q does not name what the probe actually said — the ldflags trap stays invisible", out)
+ }
+}
+
+// TestWatchWithoutAProbeStillReadsTheHeader: an app that declares no probe
+// keeps today's behaviour byte for byte.
+func TestWatchWithoutAProbeStillReadsTheHeader(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Carlos-Version", "abc1234")
+ w.WriteHeader(200)
+ }))
+ defer srv.Close()
+ targets := []watchTarget{{Host: "app.example", Origin: srv.URL}}
+ if err := watchVersion(io.Discard, targets, "abc1234", 2*time.Second, 50*time.Millisecond, nil); err != nil {
+ t.Fatalf("header watch = %v, want success", err)
+ }
+}
+```
+
+Read `watchTarget`'s real fields and `watchVersion`'s signature in `deploy.go` first and adjust; add `sync/atomic`, `bytes`, `strings` to imports as needed.
+
+- [ ] **Step 2: Run to verify they fail**
+
+```
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test . -run 'TestWatch(Probe|Without)' -count=1
+```
+
+Expected: FAIL — `unknown field Probe`.
+
+- [ ] **Step 3: Implement the probe leg**
+
+Add `Probe string` to `watchTarget` with a comment naming why it exists. In `watchVersion`'s per-target goroutine, branch on it, keeping the cache-buster on both paths:
+
+```go
+ n := attempt.Add(1)
+ path := "/"
+ if t.Probe != "" {
+ path = t.Probe
+ }
+ url := fmt.Sprintf("%s%s?carlos-deploy-probe=%d", t.Origin, path, n)
+ resp, err := client.Get(url)
+ if err != nil {
+ results[i] = result{diag: "unreachable: " + err.Error()}
+ return
+ }
+ if t.Probe != "" {
+ // The app's own process answers here, so the body IS the
+ // truth — no status/header disagreement to reconcile the
+ // way the header path has to. Read a bounded amount: this
+ // is a health endpoint, and a target that answers with a
+ // stream is misconfigured, not something to buffer.
+ b, rerr := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ resp.Body.Close()
+ if rerr != nil {
+ results[i] = result{diag: "probe unreadable: " + rerr.Error()}
+ return
+ }
+ got := strings.TrimSpace(string(b))
+ if resp.StatusCode/100 != 2 {
+ results[i] = result{diag: fmt.Sprintf("%s answered HTTP %d", path, resp.StatusCode)}
+ return
+ }
+ // Whole-token, never substring: abc123 must not satisfy a
+ // watch for abc1234, and short git shas collide like that
+ // in practice.
+ for _, f := range strings.Fields(got) {
+ if f == ver {
+ results[i] = result{live: true}
+ return
+ }
+ }
+ // Naming the body is what makes the ldflags trap
+ // self-diagnosing: a binary built without
+ // -X …version.Build reports "dev" forever, and "timed
+ // out" would send the operator hunting the wrong thing.
+ results[i] = result{diag: fmt.Sprintf("%s reports %q, waiting for %s", path, got, ver)}
+ return
+ }
+ io.CopyN(io.Discard, resp.Body, 4096)
+ resp.Body.Close()
+ got := resp.Header.Get("X-Carlos-Version")
+ // ... existing header switch unchanged ...
+```
+
+- [ ] **Step 4: Serve and consume the probe**
+
+Server side: include `"build_probe"` in the JSON of the per-app GET the deploy path already calls. Read `deploy.go` to find which call that is (the instance list, or channels) and extend that handler and its client struct rather than adding a door. State in a comment that the probe is app-level and read from the app ROW — not from `provision.Record`, whose `handleAPIInstanceUpdate` is a read-modify-write that would silently drop an unknown field.
+
+Client side: set `Probe` on every `watchTarget` the deploy builds from that response.
+
+- [ ] **Step 5: Run the tests**
+
+```
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test . ./internal/console/ -run 'Watch|Deploy' -count=1 -v
+```
+
+Expected: PASS, existing deploy tests included.
+
+- [ ] **Step 6: Full gate, then commit**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off sh -c '
+ go build ./... && go vet ./... && GOOS=darwin go build ./... && go test ./... -count=1'
+gofmt -l deploy.go deploy_test.go internal/console/apicli.go
+git add -A
+git commit -m "carlos deploy: watch the app's own build report, not the edge's header
+
+An app declaring a build probe is watched by polling that path and
+matching the build as a WHOLE TOKEN in the body — substring matching would
+let abc123 satisfy a watch for abc1234, which short git shas make a real
+collision. No probe declared keeps the header path byte for byte.
+
+The probe leg inherits the cache-buster: the edge caches a proxied 2xx for
+up to five minutes, longer than the watch's own timeout. And the timeout
+NAMES what the probe said, so a binary built without -X version.Build
+diagnoses itself instead of looking like a failed deploy."
+```
+
+---
+
+### Task 7: Adoption starts the unit again after a rolled-back restart
+
+**Files:**
+- Modify: `internal/release/adopt.go:781-815` (the restart-failure block)
+- Modify: `internal/host/agent.go` (`makeRestartRoute`, to expose a start)
+- Test: `internal/release/adopt_test.go`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: the restart callback type gains a start capability. Prefer the smallest change that keeps `Pass`'s signature stable — e.g. a `PassOption` carrying `startUnit func(host string) error`, so callers that pass no start keep today's behaviour.
+
+**This is a tenant fix and the most important defect in the slice** (spec §4b). Verified:
+
+```
+pass 1: PointLive(new) → try-restart FAILS → poison, PointLive(old), no stamp
+ unit: failed. console: DOWN. header: honest (old).
+pass 2: PointLive(new) → try-restart on a NOT-RUNNING unit → NO-OP, exit 0
+ → SetVersions(new)
+ unit: still failed. console: STILL DOWN. header: claims the new build.
+```
+
+`try-restart` being a no-op on a stopped unit is deliberate — it is what lets a deliberately-stopped console stay stopped (`makeRestartRoute`'s comment). The bug is that the rollback leaves the unit stopped in the first place.
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `internal/release/adopt_test.go`, following the file's existing fixture style for a registry + store + route:
+
+```go
+// TestFailedRestartLeavesTheUnitRUNNINGOnTheOldVersion.
+//
+// The rollback repointed the symlink and stopped there, which left the box
+// with a correct symlink and no process. Worse, try-restart is a NO-OP on a
+// unit that is not running — so the NEXT pass exited 0, stamped SetVersions,
+// and produced exactly the lying header the whole mechanism exists to
+// prevent, over a service that was not running at all.
+func TestFailedRestartLeavesTheUnitRUNNINGOnTheOldVersion(t *testing.T) {
+ // Build the fixture the way the neighbouring adopt tests do: a registry
+ // with one route on a channel, a store holding two versions, "old"
+ // already live and recorded.
+ fx := newAdoptFixture(t)
+ fx.setLive("console.example", "old")
+ fx.promote("console.example", "new")
+
+ var started []string
+ restart := func(host string) error { return errors.New("Job for console.service failed") }
+ start := func(host string) error { started = append(started, host); return nil }
+
+ if err := Pass(context.Background(), fx.reg, fx.store, fx.pub, fx.optDir, restart,
+ WithStartUnit(start)); err != nil {
+ t.Fatalf("pass: %v", err)
+ }
+
+ if got := fx.liveVersion("console.example"); got != "old" {
+ t.Fatalf("symlink = %q, want it rolled back to old", got)
+ }
+ if len(started) != 1 || started[0] != "console.example" {
+ t.Fatalf("started = %v, want the unit started once on the restored version — a rollback that leaves the service down is not a rollback", started)
+ }
+ if got := fx.recordedVersion("console.example"); got == "new" {
+ t.Fatal("SetVersions stamped the failed build")
+ }
+}
+
+// TestSecondPassOverAStillBrokenBuildDoesNotStamp: the second-order failure.
+// With the unit running again after pass 1, pass 2's try-restart is a real
+// restart that fails again — so it rolls back again and still does not stamp.
+func TestSecondPassOverAStillBrokenBuildDoesNotStamp(t *testing.T) {
+ fx := newAdoptFixture(t)
+ fx.setLive("console.example", "old")
+ fx.promote("console.example", "new")
+
+ restart := func(host string) error { return errors.New("still broken") }
+ start := func(host string) error { return nil }
+ for i := 0; i < 2; i++ {
+ if err := Pass(context.Background(), fx.reg, fx.store, fx.pub, fx.optDir, restart,
+ WithStartUnit(start)); err != nil {
+ t.Fatalf("pass %d: %v", i, err)
+ }
+ }
+ if got := fx.recordedVersion("console.example"); got == "new" {
+ t.Fatal("the second pass stamped a build that has never run")
+ }
+ if got := fx.liveVersion("console.example"); got != "old" {
+ t.Fatalf("symlink after two passes = %q, want old", got)
+ }
+}
+```
+
+`newAdoptFixture` and its helpers are illustrative — read `internal/release/adopt_test.go` and use the fixture that file already has, or add these helpers to it if none fits.
+
+- [ ] **Step 2: Run to verify they fail**
+
+```
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/release/ -run 'FailedRestartLeaves|SecondPassOver' -count=1
+```
+
+Expected: FAIL — `undefined: WithStartUnit`.
+
+- [ ] **Step 3: Add the option and use it in the rollback**
+
+Add `WithStartUnit` alongside the existing `PassOption`s, then in `adoptHost`'s restart-failure block, after the symlink has gone back and any capability restore has run:
+
+```go
+ // Start the service again on the version the symlink NOW names.
+ // The rollback above put the bytes back; without this the box is
+ // left with a correct symlink and no process — and because
+ // try-restart is a no-op on a unit that is not running (which is
+ // what lets a deliberately-stopped service stay stopped), the
+ // NEXT pass's restart would exit 0, SetVersions would stamp the
+ // build that has never run, and the header would claim it over a
+ // service that is down. Start, not try-restart: the whole premise
+ // here is that the unit is not running.
+ //
+ // A start that also fails changes nothing about the refusal — the
+ // version stays poisoned and unstamped — but it is printed,
+ // because at that point the box is genuinely broken and the
+ // operator needs to know that rather than reading a rollback
+ // message as "recovered".
+ if cfg.startUnit != nil {
+ if serr := cfg.startUnit(rt.Host); serr != nil {
+ fmt.Printf(" %s: rolled back to %s but could not start it: %v — this host is serving nothing\n",
+ rt.Host, cur, serr)
+ }
+ }
+```
+
+Then wire it at the agent: `makeRestartRoute`'s neighbours already have `tryRestartUnit`; add a `startUnit` sibling that resolves the route's `Unit` and runs `systemctl start`, and pass `WithStartUnit` where the agent builds its pass options. A route with no `Unit` has nothing to start — return nil, matching arm 1's "this arm takes no part in the fallthrough" rule.
+
+- [ ] **Step 4: Run the tests**
+
+```
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/release/ ./internal/host/ -count=1
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Full gate, then commit**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off sh -c '
+ go build ./... && go vet ./... && GOOS=darwin go build ./... && go test ./... -count=1'
+gofmt -l internal/release/adopt.go internal/host/agent.go internal/release/adopt_test.go
+git add -A
+git commit -m "adopt: a rolled-back restart starts the service again
+
+The rollback repointed the symlink and stopped, leaving a correct symlink
+and no process. And because try-restart is a no-op on a unit that is not
+running — the property that lets a deliberately-stopped service stay
+stopped — the NEXT pass exited 0 and stamped SetVersions over a service
+that was not running at all: the exact lying header the mechanism exists
+to prevent, now with nothing behind it.
+
+Every unit-backed route has this. It stayed hidden because the pinfra
+roll script's explicit post-pass restart papers over it on the only
+unit-stamped route in the fleet."
+```
+
+---
+
+### Task 8: Ship-time refusals for a control-plane app
+
+**Files:**
+- Modify: `internal/console/apicli.go` (`handleAPIShip`, after the artifact loop)
+- Test: `internal/console/apicli_test.go`
+
+**Interfaces:**
+- Consumes: `Store.AppFlags` (Task 1).
+- Produces: `func checkControlPlaneArtifacts(files map[string][]byte, version string) error` in `internal/console`.
+
+**Both checks verified against a real arm64 console build** — stdlib only, and `-s -w` does not strip either:
+
+```
+debug/buildinfo → Settings["-ldflags"] = "-s -w -X …/internal/version.Build=deccd44"
+debug/elf → Machine = EM_AARCH64
+```
+
+Scoped to control-plane apps deliberately: a tenant may legitimately ship an artifact we would guess wrong about, and refusing their ship on our inference is not our call.
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `internal/console/apicli_test.go`:
+
+```go
+// TestControlPlaneShipRefusesTheWrongArchitecture: failure mode #1, an amd64
+// binary shipped to Graviton boxes. -target linux-arm64 is carlos deploy's
+// default, which protects the careful caller; this closes the path a hand-run
+// ship, a CI job or a future script takes into the same channel.
+func TestControlPlaneShipRefusesTheWrongArchitecture(t *testing.T) {
+ fx, token := controlPlaneFixture(t, "code-cp-arch")
+ liftShipBudget(fx)
+ amd64ELF := buildTestELF(t, "linux", "amd64", "cp00003")
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token,
+ []shipPart{metaPart(t, "cp00003", "binary"), {name: "linux-arm64", body: amd64ELF}})
+ if status != http.StatusBadRequest {
+ t.Fatalf("amd64 ELF as linux-arm64 = %d %v, want 400", status, out)
+ }
+ msg := str(t, out, "error")
+ if !strings.Contains(msg, "linux-arm64") || !strings.Contains(msg, "amd64") {
+ t.Fatalf("refusal %q must name both the declared target and what was found", msg)
+ }
+}
+
+// TestControlPlaneShipRefusesAMismatchedEmbeddedBuild: closes the ldflags trap
+// at the door. Build without -X …version.Build and the roll succeeds while the
+// probe reports "dev" forever — deploy reports failure on a healthy deploy,
+// the exact inverse of the lie the probe exists to kill.
+func TestControlPlaneShipRefusesAMismatchedEmbeddedBuild(t *testing.T) {
+ fx, token := controlPlaneFixture(t, "code-cp-embed")
+ liftShipBudget(fx)
+ wrong := buildTestELF(t, "linux", "arm64", "notthesha")
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token,
+ []shipPart{metaPart(t, "cp00004", "binary"), {name: "linux-arm64", body: wrong}})
+ if status != http.StatusBadRequest {
+ t.Fatalf("embedded build != -version = %d %v, want 400", status, out)
+ }
+ if msg := str(t, out, "error"); !strings.Contains(msg, "notthesha") || !strings.Contains(msg, "cp00004") {
+ t.Fatalf("refusal %q must name both builds", msg)
+ }
+}
+
+// TestControlPlaneShipAcceptsAMatchingArtifact.
+func TestControlPlaneShipAcceptsAMatchingArtifact(t *testing.T) {
+ fx, token := controlPlaneFixture(t, "code-cp-ok")
+ liftShipBudget(fx)
+ good := buildTestELF(t, "linux", "arm64", "cp00005")
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/console/releases", token,
+ []shipPart{metaPart(t, "cp00005", "binary"), {name: "linux-arm64", body: good}})
+ if status != http.StatusCreated {
+ t.Fatalf("matching artifact = %d %v, want 201", status, out)
+ }
+}
+
+// TestOrdinaryAppShipIsNotArchChecked: a tenant may legitimately ship an
+// artifact we would guess wrong about; refusing on our inference is not our
+// call. Only the app that IS the control plane, on boxes we own and know are
+// arm64, gets the check.
+func TestOrdinaryAppShipIsNotArchChecked(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-cp-tenant")
+ liftShipBudget(fx)
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
+ []shipPart{metaPart(t, "t0000001", "binary"), {name: "linux-arm64", body: []byte("not an ELF at all")}})
+ if status != http.StatusCreated {
+ t.Fatalf("ordinary app shipping a non-ELF = %d %v, want 201 (unaffected)", status, out)
+ }
+}
+```
+
+`buildTestELF` compiles a tiny program with `go build` at the requested `GOOS`/`GOARCH` and ldflags, caching per (goos, goarch, build) in a package-level `sync.Map` so the suite compiles each combination once. Write it in this file, `t.Skip` if the toolchain is unavailable, and give it a comment saying why a real binary is used rather than a handcrafted ELF header (the point is to prove `debug/buildinfo` reads what the real build recipe writes).
+
+- [ ] **Step 2: Run to verify they fail**
+
+```
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/console/ -run 'ControlPlaneShip|OrdinaryAppShip' -count=1
+```
+
+Expected: FAIL — all ships accepted.
+
+- [ ] **Step 3: Implement the checks**
+
+Add to `internal/console/controlplane.go`:
+
+```go
+// targetMachine maps an artifact part name — which IS the declared target,
+// "linux-arm64" and friends — to the ELF machine a binary for it must carry.
+// Unknown targets are not refused: this guard exists to catch a wrong build
+// of a target we know, not to bound the target vocabulary, which targetRe
+// already does.
+var targetMachine = map[string]elf.Machine{
+ "linux-arm64": elf.EM_AARCH64,
+ "linux-amd64": elf.EM_X86_64,
+}
+
+// checkControlPlaneArtifacts refuses two ways a control-plane ship can be
+// wrong in a manner nothing downstream would catch.
+//
+// ARCHITECTURE. An amd64 binary on a Graviton box has happened and briefly
+// broke one. `carlos deploy` defaults -target to linux-arm64, which protects
+// the careful caller and nobody else; this closes the same door against a
+// hand-run ship, a CI job, or a script written next year.
+//
+// EMBEDDED BUILD. version.Build is set by ldflags the operator has to
+// remember. Forget it and the artifact says "dev" forever: the roll SUCCEEDS,
+// the deploy watch's probe never matches, and a perfectly healthy deploy
+// reports failure — the exact inverse of the lie the probe exists to kill.
+// Caught here it is one sentence at the door instead of a timeout nobody can
+// explain.
+//
+// Both read the bytes that actually arrived, and both are stdlib. Verified
+// against a real arm64 console build that -s -w strips neither the ELF header
+// nor the build info.
+func checkControlPlaneArtifacts(files map[string][]byte, version string) error {
+ for name, b := range files {
+ want, known := targetMachine[name]
+ if !known {
+ continue
+ }
+ f, err := elf.NewFile(bytes.NewReader(b))
+ if err != nil {
+ return fmt.Errorf("artifact %s is not a readable ELF binary: %w", name, err)
+ }
+ if f.Machine != want {
+ return fmt.Errorf("artifact %s declares target %s but is a %s binary",
+ name, name, machineName(f.Machine))
+ }
+ bi, err := buildinfo.Read(bytes.NewReader(b))
+ if err != nil {
+ return fmt.Errorf("artifact %s carries no Go build info: %w", name, err)
+ }
+ got := embeddedBuild(bi)
+ if got != version {
+ return fmt.Errorf("artifact %s was built with version.Build=%q but is being shipped as %q — "+
+ `rebuild with -ldflags "-X github.com/carlosframework/platform/internal/version.Build=%s"`,
+ name, got, version, version)
+ }
+ }
+ return nil
+}
+```
+
+Write `embeddedBuild(bi *debug.BuildInfo) string` to find the `-ldflags` setting and extract the value after `internal/version.Build=`, returning `""` when absent — and refuse `""` with a message naming the missing ldflag specifically, because that is the common case and deserves its own sentence. Write `machineName(elf.Machine) string` mapping the two known machines to `amd64`/`arm64` and falling back to the `elf.Machine` string.
+
+Call it in `handleAPIShip` after the artifact loop and before `release.Ship`:
+
+```go
+ // After the parts are read (the bytes have to exist to be inspected) and
+ // before anything is written. A control-plane app only.
+ if cp, _, err := s.store.AppFlags(acct.ID, app); err != nil {
+ jsonError(w, http.StatusServiceUnavailable, "could not check this app's control-plane status — nothing was shipped")
+ return
+ } else if cp && meta.Kind != "static" {
+ if err := checkControlPlaneArtifacts(files, version); err != nil {
+ jsonError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ }
+```
+
+`version` here must be the **resolved** version (after minting), not `meta.Version` — read the surrounding code and use whatever holds the final value.
+
+- [ ] **Step 4: Run the tests, then the full gate, then commit**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/console/ -run 'ControlPlaneShip|OrdinaryAppShip' -count=1 -v
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off sh -c '
+ go build ./... && go vet ./... && GOOS=darwin go build ./... && go test ./... -count=1'
+gofmt -l internal/console/controlplane.go internal/console/apicli.go internal/console/apicli_test.go
+git add -A
+git commit -m "ship: a control-plane artifact must match its target and its version
+
+Two refusals read from the bytes that arrived, stdlib only. debug/elf
+catches an amd64 binary declared linux-arm64 — which has happened and
+briefly broke a box, and which -target only protects the careful caller
+from. debug/buildinfo catches a binary whose embedded version.Build is not
+the version being shipped, which is the ldflags trap: forget the flag and
+the roll succeeds while the probe reports 'dev' forever, so a healthy
+deploy reports failure.
+
+Control-plane apps only. A tenant may legitimately ship an artifact we
+would guess wrong about, and refusing their ship on our inference is not
+our call."
+```
+
+---
+
+### Task 9: `carlos deploy` refuses a derived version from a dirty tree
+
+**Files:**
+- Modify: `deploy.go:52-54`
+- Test: `deploy_test.go`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: nothing new; `runDeploy` gains a refusal.
+
+`roll-console.sh` refuses a dirty tree because the stamp is the git short sha, and a stamp naming a commit the binary does not match is how a false "verified" happens. An explicit `-version` is the operator saying they mean it and is not second-guessed. This is a general tenant fix.
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `deploy_test.go`, following the file's existing pattern for driving `runDeploy` against a fake console mux, and using a `t.TempDir()` git repo so the working tree state is controlled:
+
+```go
+// TestDeployRefusesADerivedVersionFromADirtyTree: the default -version is the
+// git short sha, so a dirty tree ships a stamp naming a commit the binary was
+// not built from — which is precisely how a false "verified" happens, and why
+// roll-console.sh refused it. An EXPLICIT -version is the operator saying they
+// mean it and is not second-guessed.
+func TestDeployRefusesADerivedVersionFromADirtyTree(t *testing.T) {
+ dir := initDirtyGitRepo(t)
+ err := deployPreflight(dir, "" /* no explicit -version */)
+ if err == nil {
+ t.Fatal("a dirty tree with a derived version was accepted")
+ }
+ if !strings.Contains(err.Error(), "-version") {
+ t.Fatalf("refusal %q should name the flag that overrides it", err)
+ }
+ if err := deployPreflight(dir, "abc1234"); err != nil {
+ t.Fatalf("an explicit -version from a dirty tree = %v, want accepted", err)
+ }
+}
+```
+
+Factor the check into a small testable `deployPreflight(dir, explicitVersion string) error` rather than testing through `runDeploy`, which `log.Fatal`s. `initDirtyGitRepo` runs `git init`, commits a file, then modifies it; skip the test if `git` is unavailable.
+
+- [ ] **Step 2: Run to verify it fails**
+
+```
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test . -run TestDeployRefusesADerivedVersion -count=1
+```
+
+Expected: FAIL — `undefined: deployPreflight`.
+
+- [ ] **Step 3: Implement**
+
+```go
+// deployPreflight refuses the one combination that produces a release whose
+// name is a lie: a version DERIVED from git (the -version default) taken from
+// a tree with uncommitted changes. The sha then names a commit the artifact
+// was not built from, and every later "which build is this" answer —
+// including the honest ones — inherits the lie.
+//
+// An explicit -version is untouched: that is the operator naming the release
+// themselves, and second-guessing it would break every workflow that versions
+// by something other than a sha.
+func deployPreflight(dir, explicitVersion string) error {
+ if explicitVersion != "" {
+ return nil
+ }
+ out, err := exec.Command("git", "-C", dir, "status", "--porcelain").Output()
+ if err != nil {
+ return nil // not a git checkout, or no git: nothing to be dirty
+ }
+ if len(bytes.TrimSpace(out)) == 0 {
+ return nil
+ }
+ return errors.New("this tree has uncommitted changes and -version defaults to the git short sha, " +
+ "which would name a commit this build does not match — commit, or pass -version explicitly")
+}
+```
+
+Call it in `runDeploy` immediately after `-version` resolution, before anything ships. Track whether `-version` was explicitly set by testing the flag's value **before** the `gitShortHead()` default is applied.
+
+- [ ] **Step 4: Run, gate, commit**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test . -run Deploy -count=1
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off sh -c '
+ go build ./... && go vet ./... && GOOS=darwin go build ./... && go test ./... -count=1'
+gofmt -l deploy.go deploy_test.go
+git add deploy.go deploy_test.go
+git commit -m "carlos deploy: refuse a git-derived version from a dirty tree
+
+-version defaults to the git short sha, so a dirty tree ships a stamp
+naming a commit the artifact was not built from — the false-verified shape
+roll-console.sh has always refused. An explicit -version is the operator
+naming the release themselves and is left alone."
+```
+
+---
+
+### Task 10: The console signals readiness
+
+**Files:**
+- Modify: `cmd/console/main.go:274-276`
+- Create: `internal/host/sdnotify.go` (or `cmd/console/sdnotify.go` if nothing else will use it)
+- Test: `internal/host/sdnotify_test.go`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: `func NotifyReady() error` — writes `READY=1` to `$NOTIFY_SOCKET`, a no-op returning nil when the variable is unset.
+
+**Why** (spec §4a): `systemctl try-restart` on a `Type=simple` unit succeeds the moment the process is exec'd, so a console that starts and then panics on its first request exits 0 — `SetVersions` stamps the new build and nothing rolls back. Readiness signalled *after* the listener is open converts that into a failed start, which Task 7's machinery already handles. The unit-file half (`Type=notify`) is a **pinfra** change in the runbook; this task is the code half and is inert until then.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+// TestNotifyReadyWritesToTheSocket: the whole contract. systemd hands the
+// process a unixgram socket path in NOTIFY_SOCKET and waits for READY=1
+// before calling the unit started — which is what turns "exec'd" into
+// "actually serving" for the adopt pass's restart check.
+func TestNotifyReadyWritesToTheSocket(t *testing.T) {
+ dir := t.TempDir()
+ sock := filepath.Join(dir, "notify.sock")
+ conn, err := net.ListenPacket("unixgram", sock)
+ if err != nil {
+ t.Skipf("unixgram unavailable here: %v", err)
+ }
+ defer conn.Close()
+ t.Setenv("NOTIFY_SOCKET", sock)
+
+ if err := NotifyReady(); err != nil {
+ t.Fatalf("NotifyReady: %v", err)
+ }
+ buf := make([]byte, 64)
+ conn.SetReadDeadline(time.Now().Add(2 * time.Second))
+ n, _, err := conn.ReadFrom(buf)
+ if err != nil {
+ t.Fatalf("nothing arrived on the notify socket: %v", err)
+ }
+ if got := string(buf[:n]); got != "READY=1" {
+ t.Fatalf("sent %q, want READY=1", got)
+ }
+}
+
+// TestNotifyReadyIsANoOpOutsideSystemd: the console runs under `go run`, in
+// tests and on a laptop, and none of those set NOTIFY_SOCKET. Absent is
+// normal, not an error.
+func TestNotifyReadyIsANoOpOutsideSystemd(t *testing.T) {
+ t.Setenv("NOTIFY_SOCKET", "")
+ if err := NotifyReady(); err != nil {
+ t.Fatalf("NotifyReady with no socket = %v, want nil", err)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+```
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/host/ -run TestNotifyReady -count=1
+```
+
+Expected: FAIL — `undefined: NotifyReady`.
+
+- [ ] **Step 3: Implement**
+
+```go
+// NotifyReady tells systemd this process is ready to serve, for a unit
+// declared Type=notify.
+//
+// It exists so that "the process was exec'd" and "the process is serving"
+// stop being the same fact to systemd. With Type=simple, `systemctl restart`
+// succeeds the instant the binary is exec'd — so a console that starts and
+// then dies on its first request, or half-applies a migration, restarts
+// SUCCESSFULLY as far as the adopt pass can tell, and the pass then stamps a
+// version nothing is really running. Signalled after the listener is open,
+// a broken build fails its restart instead, and adoption's existing rollback
+// takes over.
+//
+// No dependency: the protocol is one datagram on the socket systemd names in
+// NOTIFY_SOCKET. An unset variable means "not under systemd" — a laptop, a
+// test, `go run` — and is not an error.
+//
+// An "@" prefix denotes a Linux abstract socket, which net.Dial expects as a
+// leading NUL.
+func NotifyReady() error {
+ sock := os.Getenv("NOTIFY_SOCKET")
+ if sock == "" {
+ return nil
+ }
+ if strings.HasPrefix(sock, "@") {
+ sock = "\x00" + sock[1:]
+ }
+ conn, err := net.Dial("unixgram", sock)
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+ _, err = conn.Write([]byte("READY=1"))
+ return err
+}
+```
+
+- [ ] **Step 4: Call it after the listener is open**
+
+`cmd/console/main.go` — the current last line is `log.Fatal(http.ListenAndServe(addr, srv))`, which cannot signal readiness because it never returns. Split it:
+
+```go
+ addr := envOr("CONSOLE_ADDR", "127.0.0.1:8090")
+ ln, err := net.Listen("tcp", addr)
+ if err != nil {
+ log.Fatal(err)
+ }
+ log.Printf("console: %s listening on %s", origin, addr)
+ // AFTER the listener is open and the store has migrated (openConsoleDB
+ // and NewServer both ran above), so readiness means what it says. With
+ // console.service declared Type=notify, a build that dies before this
+ // line fails its restart — and the adopt pass rolls the symlink back and
+ // starts the previous version, instead of stamping a build that never
+ // served (internal/release/adopt.go).
+ if err := host.NotifyReady(); err != nil {
+ log.Printf("console: notifying systemd of readiness: %v", err)
+ }
+ log.Fatal(http.Serve(ln, srv))
+```
+
+A failed notify is logged, not fatal: under `Type=notify` systemd will time the start out anyway, and under any other `Type=` the console must keep serving.
+
+- [ ] **Step 5: Run, gate, commit**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off \
+ go test ./internal/host/ -run TestNotifyReady -count=1 -v
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off sh -c '
+ go build ./... && go vet ./... && GOOS=darwin go build ./... && go test ./... -count=1'
+gofmt -l cmd/console/main.go internal/host/sdnotify.go internal/host/sdnotify_test.go
+git add -A
+git commit -m "console: signal readiness to systemd after the listener is open
+
+With Type=simple, systemctl restart succeeds the instant the binary is
+exec'd — so a console that dies on its first request restarts
+'successfully' as far as the adopt pass can tell, and the pass stamps a
+version nothing is running. Readiness signalled after migrate + listen
+makes a broken build fail its restart, which adoption's rollback already
+handles.
+
+Inert until console.service is declared Type=notify, which is the pinfra
+half of this change."
+```
+
+---
+
+## Rollout runbook
+
+Not TDD tasks — an ordered operational sequence. **Do not start it until tasks 1–10 are merged.** Each step states how to know it worked. Tito first throughout: smaller blast radius, and its console going down does not take carloku with it.
+
+### R0 — Survey both boxes (read-only)
+
+Flagship's readings are in the spec's appendix. **Tito's are not, and it is deployed first.** For each box collect: `systemctl show -p ExecStart -p Type --value console`; every `console` route with its channel and `unit` (`sqlite3 /data/registry.db`); `sqlite3 /data/console/console.db "select count(*) from owners where app='console'"`; whether `apps/console/` (legacy, unqualified) exists in the bucket; the bucket production flag on `console`.
+
+**Stop and re-plan if:** `owners` has a `console` row (the grant refuses the claim before the owner exemption is reached), or a legacy `apps/console/` prefix exists.
+
+### R1 — Deliver the code by the legacy path, one last time
+
+```
+roll-console.sh <clean-checkout-at-merge-sha> both
+```
+
+Tasks 1–10 are console code; they reach the boxes only this way. **Without this, R5 runs against a console that predates its own probe and cannot succeed.** Also cut a CLI release carrying the new watch, or use a locally built `carlos` for R5–R6.
+
+Verify: `/proc/<pid>/exe` names the merge sha on both boxes, and `curl -s https://<origin>/healthz` returns `ok <sha>` — the first proof Task 5 is live.
+
+### R2 — pinfra: `Type=notify` on `console.service`
+
+Add `Type=notify` (and a `TimeoutStartSec` that comfortably exceeds console startup) to `console.service` in the pinfra repo; apply to both boxes; `systemctl daemon-reload` and restart once. Task 10's code is already deployed by R1, so readiness will be signalled.
+
+Verify: `systemctl show -p Type --value console` says `notify`, and the console is active and serving.
+
+### R3 — Stamp the right route
+
+From R0: the `Unit` stamp must be on **the host named in `ExecStart`**, and on exactly one route. Flagship today has these disagreeing — `ExecStart` names `platform.carlosframework.com`, the stamp is on `console.carloku.com`.
+
+```
+carlos route -registry /data/registry.db -host <execstart-host> -unit console.service
+carlos route -registry /data/registry.db -host <previously-stamped-host> -unit ""
+```
+
+Run as `carlos`, not root. Verify by reading the routes back, then run one `carlos adopt` and confirm it is a no-op.
+
+### R4 — Claim `ops/console` with the flag, in one step
+
+```
+carlos apps create -app console -account ops -control-plane -build-probe /healthz \
+ -console https://carlos.tito.io
+```
+
+then the same against `https://platform.carlosframework.com`. One step, not two: a row without the flag is a window in which any ops member or `Publish` service credential can roll the production console.
+
+Verify: `carlos channels -app console -account ops -console <origin>` lists the existing history — proof the claim adopted the objects rather than creating an empty app.
+
+### R5 — First `carlos deploy -app console`, on tito
+
+Build and deploy in the recipe the spec's failure-mode table demands — **`cmd/console`, not the root binary** (the 8-minute outage of 2026-08-08):
+
+```
+GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build \
+ -ldflags "-s -w -X github.com/carlosframework/platform/internal/version.Build=$(git rev-parse --short HEAD)" \
+ -o /tmp/console ./cmd/console
+
+carlos deploy -app console -account ops -console https://carlos.tito.io \
+ -channel canary/console -host <tito-execstart-host> -target linux-arm64 /tmp/console
+```
+
+Verify: the command reports live off the **probe**, and independently `verify-console.sh tito <sha>` agrees via `/proc`. Both, this once — that agreement is what retires the script.
+
+**If it fails:** `roll-console.sh <checkout> tito` is the break-glass path.
+
+### R6 — Flagship
+
+The same, against `https://platform.carlosframework.com`. Verify identically.
+
+### R7 — Prove the ordinary apps
+
+For `kass`, `keymail`, `platform-site`: apply R3's precondition **first** (their `Unit` stamps are unsurveyed, and `kass`/`keymail` are addr-backed bespoke services). An unstamped `keymail` "proof" run is the #110 shape — symlink swapped, nothing restarted, header lies — and would certify a deploy that did not happen. Then `carlos deploy -app <name> -account <acct>` for each.
+
+### R8 — Documentation
+
+Update CLAUDE.md's "Deploy reality", which currently sends the reader to the pinfra scripts for all four apps. Reclassify `roll-console.sh` and `verify-console.sh` as **break-glass** in their header comments. File the two follow-ups: adoption deduping named-unit restarts to the end of a pass (spec §4c), and how a **fresh deployment** gets its `ops/console` row (spec §11).
+
+---
+
+## Self-review
+
+**Spec coverage.** §2 → Task 2. §3/§3a/§3b → Tasks 5, 6, and 8 (the ldflags trap is closed at the door as well as in the timeout). §4a → Task 10 + R2. §4b → Task 7. §4c → R0/R3. §5 → Task 8. §6 → Tasks 1, 3, 4. §7 → Task 9. §8 → Task 1. §9 → R8. §10 → the runbook, same order. §11's three open questions are carried into R0 (stop conditions) and R8 (filed follow-ups).
+
+**Placeholders.** None. Four places name a verification the implementer must do against the codebase rather than a value I could read: `handleAPIPromote`'s request field names (Task 4 step 1), the release-store `Put` helper and create-door status codes (Task 2 step 1), the member-role constant (Task 3 step 1), and `internal/release/adopt_test.go`'s existing fixture (Task 7 step 1). Each says what to check and what to do with the answer — these are "match the surrounding code", not "decide the design".
+
+**Type consistency.** `AppFlags(accountID int64, app string) (bool, string, error)` is used identically in Tasks 3, 4, 6 and 8. `refuseControlPlane(w, accountID, app, verb) bool` is defined in Task 4 and used only there. `watchTarget.Probe` is added in Task 6 and read only there. `NotifyReady() error` is defined in Task 10 and called once. `claimApp`'s new trailing `ownerClaim bool` is added in Task 2 and both call sites are updated in the same task.
+
+**One deliberate deviation from the spec**, flagged for the reviewer: §3a argues the build probe onto "the app row" without naming a column; this plan adds `build_probe TEXT` in the same migration as `control_plane` rather than in a separate task, because splitting them means touching `migrateAppsKey`'s copy list twice and that list is exactly where the spec's §8 trap lives.