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