carlos / platform Public

plan: multi-account — eight tasks, read-fallback, migration as a verb

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 0b8caeaad3ca138a378572f177e4e26f6756b9dc parent 81662fb
1 file changed, +373 −0
  • docs/superpowers/plans/2026-08-02-plan-multi-account.md +373 −0
diff --git a/docs/superpowers/plans/2026-08-02-plan-multi-account.md b/docs/superpowers/plans/2026-08-02-plan-multi-account.md
new file mode 100644
index 000000000..632f279d1
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-02-plan-multi-account.md
@@ -0,0 +1,373 @@
+# Multi-Account 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:** Accounts as the tenancy primitive — console-minted durable ids, owner/member memberships replacing per-app grants, app identity scoped to the account in bucket keys and the console, a Stripe-like switcher with MRU landing and keymail invites, and migration verbs — per spec `docs/superpowers/specs/2026-08-02-multi-account-design.md` (read it first; it governs).
+
+**Architecture:** Account identity is minted by the console as a random 40-bit id, written to `accounts/<sqid>/account.json` in the bucket, and only *materialized* by the registry — never assigned there. Bucket keys gain an account-sqid segment with a read-fallback to legacy unqualified keys so the code can land before the data moves. The console's `owners` grant table is superseded by memberships.
+
+**Tech Stack:** Go stdlib + existing deps. Sqid encoder (`internal/domains`) is unchanged — only what feeds it changes.
+
+## Global Constraints
+
+- **The sqid encoder and its frozen goldens are untouched.** This plan changes the *source* of the id, never the encoding.
+- Additive-only migrations everywhere (registry + console), same append-to-the-ALTER-list pattern.
+- **Read-fallback is mandatory on every read path** until Task 8 removes it: try `apps/<sqid>/<app>/…`, fall back to `apps/<app>/…`. Writes always qualified.
+- **Nothing deletes a legacy bucket object in this plan.** Deletion is an operator step behind the §5 verification gate.
+- `ops` is id 1, sqid `bab`, created by migration on every deployment; CLI `-account` defaults to it.
+- Non-member access to an account URL returns the **byte-identical 404** a nonexistent page returns (match `/fleet`'s existing pattern in `internal/console/server.go`).
+- The last owner of an account can never be removed or demoted.
+- Tests: `GOCACHE=/tmp/claude-1001/gocache go test ./... -count=1`. Heavy why-comments. Commits `<area>: <what> — <why>` + `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`.
+- **Consumers of `reg.List()` must be asked the account question** (lesson from the domains build: minted routes were a new row *kind* and only the writers got reviewed). Current consumers: adopt, status, edge, activator, console.
+
+## File Structure
+
+- `internal/registry/registry.go` — accounts table gains `sqid`, materialize-not-assign
+- `internal/release/release.go` — key builders take an account; `internal/release/fallback.go` (new) — the qualified-then-legacy read helper
+- `internal/console/store.go` — accounts, memberships, invites, `last_account`; `internal/console/account.go` (new) — id minting + manifest writes
+- `internal/console/server.go` + `view.go` + templates — switcher, MRU, `/a/<sqid>/…` routes, invite flow
+- `internal/domains/reconcile.go` — materialize from `accounts/*/account.json`, linkage from key paths
+- `cli.go` — `-account` on app verbs; `accounts` verb (create/list/migrate --dry-run)
+- `e2e_test.go`, `README.md`
+
+---
+
+### Task 1: Registry accounts — materialize, never assign
+
+**Files:** Modify `internal/registry/registry.go`; Test `internal/registry/registry_test.go`
+
+**Interfaces produced:**
+```go
+// accounts table gains sqid: (id INTEGER PRIMARY KEY, sqid TEXT UNIQUE NOT NULL, name TEXT NOT NULL)
+// NOTE: id is NO LONGER AUTOINCREMENT — it is supplied by the caller.
+func (r *Registry) PutAccount(id int64, sqid, name string) error // idempotent upsert BY ID
+func (r *Registry) AccountBySqid(sqid string) (id int64, name string, err error) // id 0 when absent
+func (r *Registry) Accounts() ([]Account, error) // Account{ID int64; Sqid, Name string}
+```
+`EnsureAccount(name)` from the domains work is **removed** — it assigned ids, which is the bug this fixes. Migration seeds `(1, "bab", "ops")` with `INSERT OR IGNORE`.
+
+- [ ] **Step 1: Failing test** — the durability property this whole spec exists for:
+
+```go
+func TestAccountsMaterializeIdenticallyAfterRebuild(t *testing.T) {
+ dir := t.TempDir()
+ r1, _ := registry.Open(filepath.Join(dir, "registry.db"))
+ if err := r1.PutAccount(793481920157, "kqmv", "Kass"); err != nil { t.Fatal(err) }
+ r1.Close()
+ // Rebuild from scratch — the id comes from the bucket, so it must survive.
+ os.Remove(filepath.Join(dir, "registry.db"))
+ r2, _ := registry.Open(filepath.Join(dir, "registry.db"))
+ if err := r2.PutAccount(793481920157, "kqmv", "Kass"); err != nil { t.Fatal(err) }
+ id, name, err := r2.AccountBySqid("kqmv")
+ if err != nil || id != 793481920157 || name != "Kass" {
+ t.Fatalf("rebuild lost identity: id=%d name=%q err=%v", id, name, err)
+ }
+}
+
+func TestOpsAccountIsAlwaysOne(t *testing.T) {
+ reg := openTestRegistry(t)
+ id, name, err := reg.AccountBySqid("bab")
+ if err != nil || id != 1 || name != "ops" {
+ t.Fatalf("ops: id=%d name=%q err=%v, want 1/ops", id, name, err)
+ }
+}
+
+func TestPutAccountIdempotentAndRenames(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.PutAccount(42, "cdef", "Old"); err != nil { t.Fatal(err) }
+ if err := reg.PutAccount(42, "cdef", "New"); err != nil { t.Fatal(err) }
+ _, name, _ := reg.AccountBySqid("cdef")
+ if name != "New" { t.Errorf("rename not applied: %q", name) }
+ accts, _ := reg.Accounts()
+ if len(accts) != 2 { t.Errorf("want ops + one, got %d", len(accts)) } // no duplicate row
+}
+```
+
+- [ ] **Step 2: Run — FAIL.** **Step 3: Implement** (add `sqid` column via additive ALTER + the ops seed; `PutAccount` = `INSERT INTO accounts(id,sqid,name) VALUES(?,?,?) ON CONFLICT(id) DO UPDATE SET sqid=excluded.sqid, name=excluded.name`; delete `EnsureAccount` and fix its one caller in `internal/domains/reconcile.go` to compile — full rewire is Task 5). **Step 4: PASS.** **Step 5: Commit** `registry: accounts carry their sqid — the bucket assigns, the registry materializes`.
+
+### Task 2: Account-scoped bucket keys + read-fallback
+
+**Files:** Modify `internal/release/release.go`; Create `internal/release/fallback.go`; Test `internal/release/release_test.go`
+
+**Interfaces produced:**
+```go
+// Key builders gain an account sqid. acct "" is INVALID (callers must pass
+// "bab" for ops) — a "" account would silently rebuild legacy keys.
+func ManifestKey(acct, app, version string) string // apps/<acct>/<app>/releases/<version>/manifest.json
+func ArtifactKey(acct, app, version, file string) string
+func ChannelKey(acct, app, channel string) string
+// Legacy shapes, for the fallback and the migration verb only:
+func LegacyManifestKey(app, version string) string // apps/<app>/releases/<version>/manifest.json
+func LegacyArtifactKey(app, version, file string) string
+func LegacyChannelKey(app, channel string) string
+
+// GetWithFallback reads key, and on not-found reads legacy instead. It
+// reports which one answered so callers can log/verify the migration.
+func GetWithFallback(ctx context.Context, s Store, key, legacy string) (body []byte, etag string, usedLegacy bool, err error)
+```
+
+- [ ] **Step 1: Failing tests:**
+
+```go
+func TestKeysAreAccountScoped(t *testing.T) {
+ if got := release.ChannelKey("kqmv", "kass", "stable"); got != "apps/kqmv/kass/channels/stable.json" {
+ t.Errorf("ChannelKey = %q", got)
+ }
+ if got := release.ManifestKey("bab", "console", "abc1234"); got != "apps/bab/console/releases/abc1234/manifest.json" {
+ t.Errorf("ManifestKey = %q", got)
+ }
+}
+
+func TestGetWithFallbackPrefersQualifiedThenFallsBack(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ s.Put(ctx, "apps/kass/channels/stable.json", []byte("legacy"), "")
+ body, _, used, err := release.GetWithFallback(ctx, s, "apps/kqmv/kass/channels/stable.json", "apps/kass/channels/stable.json")
+ if err != nil || string(body) != "legacy" || !used {
+ t.Fatalf("fallback: body=%q used=%v err=%v", body, used, err)
+ }
+ s.Put(ctx, "apps/kqmv/kass/channels/stable.json", []byte("qualified"), "")
+ body, _, used, err = release.GetWithFallback(ctx, s, "apps/kqmv/kass/channels/stable.json", "apps/kass/channels/stable.json")
+ if err != nil || string(body) != "qualified" || used {
+ t.Fatalf("qualified must win: body=%q used=%v err=%v", body, used, err)
+ }
+}
+
+func TestGetWithFallbackPropagatesRealErrors(t *testing.T) {
+ // A store error that is NOT not-found must surface, never be masked as
+ // a fallback miss — masking would make a broken bucket look like an
+ // unmigrated app.
+ s := &erroringStore{err: errors.New("bucket on fire")} // test double in this file
+ _, _, _, err := release.GetWithFallback(context.Background(), s, "a", "b")
+ if err == nil || !strings.Contains(err.Error(), "on fire") {
+ t.Fatalf("want the real error, got %v", err)
+ }
+}
+```
+
+- [ ] **Step 2: FAIL. Step 3: Implement** — update the three builders, add the three Legacy ones, write `fallback.go`; then fix every caller in `internal/release/*.go` (Ship/Promote/Channel/Rollback/GetManifest/adopt) to thread an account through, using `GetWithFallback` on every read of a manifest/channel/artifact. Callers that don't yet know an account pass `"bab"` for now (Task 6 threads the real one from routes). **Step 4: PASS** — whole `internal/release` package plus anything that compiles against it. **Step 5: Commit** `release: account-scoped keys with a legacy read-fallback — no flag day`.
+
+### Task 3: Console — accounts, memberships, invites, MRU
+
+**Files:** Modify `internal/console/store.go`; Create `internal/console/account.go`; Test `internal/console/store_test.go`
+
+**Interfaces produced:**
+```go
+// Tables (all additive CREATE TABLE IF NOT EXISTS):
+// accounts (id INTEGER PRIMARY KEY, sqid TEXT UNIQUE, name TEXT, created_at INTEGER)
+// memberships (account_id INTEGER, address TEXT, role TEXT, added_at INTEGER, PRIMARY KEY(account_id,address))
+// invites (token TEXT PRIMARY KEY, account_id INTEGER, address TEXT, invited_by TEXT, created_at INTEGER, expires_at INTEGER, accepted_at INTEGER)
+// users gains last_account INTEGER NOT NULL DEFAULT 0
+// apps gains account_id INTEGER NOT NULL DEFAULT 1 (PK stays `app` until Task 8's migration; see note)
+func NewAccountID() int64 // crypto/rand, 40 bits, never 0 or 1 (1 is reserved for ops)
+func (s *Store) CreateAccount(id int64, sqid, name, ownerAddress string, now time.Time) error // account + owner membership in one tx
+func (s *Store) AccountsFor(address string) ([]Account, error) // Account{ID, Sqid, Name, Role}
+func (s *Store) Role(accountID int64, address string) (string, error) // "" when not a member
+func (s *Store) AddMember(accountID int64, address, role string, now time.Time) error
+func (s *Store) RemoveMember(accountID int64, address string) error // errors if last owner
+func (s *Store) SetLastAccount(address string, accountID int64) error
+func (s *Store) LastAccount(address string) (int64, error)
+func (s *Store) CreateInvite(accountID int64, address, invitedBy string, now time.Time) (token string, err error)
+func (s *Store) AcceptInvite(token, address string, now time.Time) (accountID int64, err error) // single-use, expiry-checked, address must match
+func (s *Store) RevokeInvite(token string) error
+func (s *Store) PendingInvites(accountID int64) ([]Invite, error)
+```
+Note on `apps.account_id`: the column lands now with default 1 (ops); the PK change to `(account_id, app)` requires a table rebuild and is deliberately deferred to the migration verb (Task 7) so this task stays additive.
+
+- [ ] **Step 1: Failing tests** — write all of these before implementing:
+
+```go
+func TestNewAccountIDIsRandomAndNeverReserved(t *testing.T) {
+ seen := map[int64]bool{}
+ for i := 0; i < 200; i++ {
+ id := console.NewAccountID()
+ if id <= 1 { t.Fatalf("id %d collides with ops/zero", id) }
+ if id > 1<<40 { t.Fatalf("id %d exceeds 40 bits", id) }
+ if seen[id] { t.Fatalf("repeat within 200 draws: %d", id) }
+ seen[id] = true
+ }
+}
+
+func TestCreateAccountMakesOwner(t *testing.T) {
+ st := testStore(t)
+ if err := st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) }
+ role, _ := st.Role(42, "paul@keymail.dev")
+ if role != "owner" { t.Errorf("creator role = %q, want owner", role) }
+ if role, _ := st.Role(42, "someone@keymail.dev"); role != "" {
+ t.Errorf("non-member role = %q, want empty", role)
+ }
+}
+
+func TestLastOwnerCannotBeRemoved(t *testing.T) {
+ st := testStore(t)
+ st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", time.Now())
+ if err := st.RemoveMember(42, "paul@keymail.dev"); err == nil {
+ t.Fatal("removing the last owner must fail")
+ }
+ st.AddMember(42, "sam@keymail.dev", "owner", time.Now())
+ if err := st.RemoveMember(42, "paul@keymail.dev"); err != nil {
+ t.Fatalf("removable once a second owner exists: %v", err)
+ }
+}
+
+func TestInviteSingleUseAndExpiring(t *testing.T) {
+ st := testStore(t)
+ now := time.Now()
+ st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", now)
+ tok, _ := st.CreateInvite(42, "sam@keymail.dev", "paul@keymail.dev", now)
+ if _, err := st.AcceptInvite(tok, "wrong@keymail.dev", now); err == nil {
+ t.Error("an invite must only be acceptable by its addressee")
+ }
+ if _, err := st.AcceptInvite(tok, "sam@keymail.dev", now); err != nil { t.Fatal(err) }
+ if _, err := st.AcceptInvite(tok, "sam@keymail.dev", now); err == nil {
+ t.Error("invite must be single-use")
+ }
+ tok2, _ := st.CreateInvite(42, "kim@keymail.dev", "paul@keymail.dev", now)
+ if _, err := st.AcceptInvite(tok2, "kim@keymail.dev", now.Add(8*24*time.Hour)); err == nil {
+ t.Error("invite must expire after 7 days")
+ }
+}
+
+func TestLastAccountRoundTrips(t *testing.T) {
+ st := testStore(t)
+ st.CreateAccount(42, "cdef", "Kass", "paul@keymail.dev", time.Now())
+ if err := st.SetLastAccount("paul@keymail.dev", 42); err != nil { t.Fatal(err) }
+ if got, _ := st.LastAccount("paul@keymail.dev"); got != 42 {
+ t.Errorf("LastAccount = %d, want 42", got)
+ }
+}
+```
+
+- [ ] **Step 2: FAIL. Step 3: Implement** (`account.go` holds `NewAccountID` — `crypto/rand` read into a uint64, mask to 40 bits, retry while ≤1 — plus the manifest writer from Task 4; the rest goes in store.go beside the existing tables). **Step 4: PASS.** **Step 5: Commit** `console: accounts, memberships, invites — membership is the permission`.
+
+### Task 4: Account manifests — console writes, agent materializes
+
+**Files:** Modify `internal/console/account.go`, `internal/domains/reconcile.go`; Test both packages
+
+**Interfaces:**
+- Consumes: Task 1 `PutAccount`, Task 3 store, Task 2 keys.
+- Produces:
+```go
+// console side:
+func WriteAccountManifest(ctx context.Context, s release.Store, id int64, sqid, name string, created time.Time) error
+// key accounts/<sqid>/account.json, body {"id","sqid","name","created_at"},
+// overwrite-safe (read etag, put, one retry) like PublishFleet's putOverwrite.
+
+// domains side — replaces the apps/<app>/account.json reader entirely:
+// Reconcile now lists accounts/*/account.json, calls reg.PutAccount(id, sqid, name),
+// and derives app->account from the KEY PATH of apps/<sqid>/<app>/... .
+// Legacy unqualified apps/<app>/... map to ops ("bab") until migrated.
+```
+
+- [ ] **Step 1: Failing tests:**
+
+```go
+// console
+func TestWriteAccountManifestRoundTrips(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ if err := console.WriteAccountManifest(context.Background(), s, 42, "cdef", "Kass", time.Unix(1700000000, 0)); err != nil { t.Fatal(err) }
+ body, _, err := s.Get(context.Background(), "accounts/cdef/account.json")
+ if err != nil { t.Fatal(err) }
+ var m struct{ ID int64 `json:"id"`; Sqid, Name string }
+ json.Unmarshal(body, &m)
+ if m.ID != 42 || m.Sqid != "cdef" || m.Name != "Kass" { t.Errorf("manifest = %+v", m) }
+}
+
+// domains
+func TestReconcileMaterializesAccountsFromManifests(t *testing.T) {
+ s, reg := dirStore(t), regWith(t /* no routes */)
+ writeAccountManifest(t, s, 793481920157, "kqmv", "Kass")
+ if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { t.Fatal(err) }
+ id, name, _ := reg.AccountBySqid("kqmv")
+ if id != 793481920157 || name != "Kass" {
+ t.Errorf("materialized id=%d name=%q — the bucket's id must be taken as given", id, name)
+ }
+}
+
+func TestReconcileDerivesAccountFromKeyPath(t *testing.T) {
+ // An app under apps/kqmv/kass/... belongs to kqmv; a legacy app under
+ // apps/oldapp/... belongs to ops.
+ s, reg := dirStore(t), regWith(t,
+ registry.Route{Host: "kass.training", Kind: "instance", App: "kass", Status: "ready", Addr: "127.0.0.1:9000"},
+ registry.Route{Host: "old.example.com", Kind: "instance", App: "oldapp", Status: "ready", Addr: "127.0.0.1:9001"})
+ writeAccountManifest(t, s, 793481920157, "kqmv", "Kass")
+ s.Put(ctx, "apps/kqmv/kass/channels/stable.json", []byte("{}"), "")
+ s.Put(ctx, "apps/oldapp/channels/stable.json", []byte("{}"), "")
+ domains.Reconcile(ctx, s, reg, cfg(t))
+ // kass mints under kqmv; oldapp mints under bab (ops)
+ assertRouteExists(t, reg, "kass.kqmv.oncarlos.test")
+ assertRouteExists(t, reg, "oldapp.bab.oncarlos.test")
+}
+```
+
+- [ ] **Step 2: FAIL. Step 3: Implement** — delete the `apps/<app>/account.json` reader and `appAccount` helper; add the accounts lister and a key-path parser (`apps/<sqid>/<app>/…` → account, app; `apps/<app>/…` → ops, app — distinguish by whether the first segment matches a known account sqid, which is unambiguous because app names and sqids live in different namespaces and the account set is known first). **Step 4: PASS.** **Step 5: Commit** `domains: accounts come from the bucket's own manifests — key paths carry the linkage`.
+
+### Task 5: Console UI — switcher, MRU landing, account-scoped routes, invites
+
+**Files:** Modify `internal/console/server.go`, `view.go`, templates; Test `internal/console/server_test.go`
+
+**Interfaces:** Consumes Tasks 3-4. Produces routes:
+- `GET /` → redirect to `/a/<sqid>/` for `LastAccount` (or first membership; or mint a personal account named after the address when there are none).
+- `GET /a/{sqid}/` — account home, app list; `GET /a/{sqid}/apps/{app}` — the existing app page, scoped.
+- `POST /a/{sqid}/apps/new`, `/promote`, `/rollback`, `/canary`, `/canary/retire` — the existing slice 2/3 handlers, re-rooted; membership (any role) required.
+- `POST /a/{sqid}/invite` (owner only) — creates the invite and sends the sealed link via the existing keymail send path used for sign-in confirmations; `POST /a/{sqid}/invite/revoke` (owner only); `GET /invite/{token}` — accept, then redirect to `/a/<sqid>/`.
+- `POST /accounts/new` — create an account (any signed-in user), mints id, writes manifest, sets MRU.
+- Every `/a/{sqid}/…` handler: session → `Role(accountID, address)`; empty role → `http.NotFound` (byte-identical to a nonexistent route).
+- Switcher partial rendered top-left on every account page from `AccountsFor(address)`.
+
+- [ ] **Step 1: Failing tests:**
+
+```go
+func TestNonMemberGetsIdenticalNotFound(t *testing.T) {
+ // same assertion shape as the existing TestFleetOperatorOnly: compare the
+ // full body against a genuinely nonexistent path
+ // ... signed-in non-member GET /a/cdef/ == GET /definitely-not-a-page
+}
+func TestLandingRedirectsToLastAccount(t *testing.T) // MRU honored
+func TestLandingMintsPersonalAccountWhenNoMemberships(t *testing.T) // account created + manifest written + owner
+func TestOwnerOnlyActionsRefuseMembers(t *testing.T) // invite/revoke 403-or-404 for a member
+func TestInviteAcceptCreatesMembership(t *testing.T) // token flow end to end
+func TestSwitcherListsOnlyMyAccounts(t *testing.T) // another user's account never appears
+```
+
+- [ ] **Step 2: FAIL. Step 3: Implement** — re-root the handlers under `/a/{sqid}`, add the switcher partial to the layout, wire MRU writes on every account page view, and reuse the sealed-envelope sender from sign-in confirmations for invites (read `internal/console/keymail.go` for the exact send call). **Step 4: PASS** (whole console package). **Step 5: Commit** `console: the switcher, the invite, the account you were last in`.
+
+### Task 6: CLI `-account` + `accounts` verb
+
+**Files:** Modify `cli.go`, `main.go`; Test `e2e_test.go` (CLI subprocess, as `TestStatusCLI` does)
+
+**Interfaces:**
+- `-account <sqid-or-name>` on `ship`, `promote`, `channels`, `rollback`, `add`; default `ops`. Name resolution: exact sqid wins; else unique name match; else error listing the candidates.
+- `carlos accounts list` — sqid, name, app count.
+- `carlos accounts create -name <name>` — mints an id (same 40-bit rule), writes the manifest, prints the sqid. (For CLI-only deployments; the console does its own.)
+- `carlos accounts migrate -app <app> -to <sqid> [-dry-run]` — copies the app's objects to the qualified prefix, updates registry routes' account, reports what moved. **Never deletes.** `-dry-run` prints the plan and touches nothing.
+
+- [ ] **Step 1: Failing test** — build the binary and drive it (model: `TestStatusCLI`): `accounts create -name Kass` prints a 4-char sqid; `accounts list` includes it; `accounts migrate -app demo -to <sqid> -dry-run` lists the objects and leaves the store byte-identical; without `-dry-run` the qualified objects exist AND the legacy ones still exist.
+- [ ] **Step 2: FAIL. Step 3: Implement. Step 4: PASS. Step 5: Commit** `cli: -account everywhere, accounts create/list/migrate — the migration is a verb, not a runbook`.
+
+### Task 7: Wire accounts through the read paths
+
+**Files:** Modify `internal/release/adopt.go`, `internal/host/agent.go`, `internal/console/view.go`
+
+The lesson from the domains build: **every `reg.List()` consumer must be asked the account question.** Adopt resolves channels per route — it must use `route.Account` (falling back to `"bab"` when empty) to build keys, via `GetWithFallback`.
+
+- [ ] **Step 1: Failing test** in `internal/release/adopt_test.go`: two routes, same app name, different accounts, different channel pointers → each adopts its own account's version (this is the test that proves per-account app names actually work end to end).
+- [ ] **Step 2: FAIL. Step 3: Implement. Step 4: PASS** (full suite). **Step 5: Commit** `release: adopt resolves per account — same app name, different accounts, different builds`.
+
+### Task 8: e2e + docs
+
+**Files:** `e2e_test.go`, `README.md`
+
+- [ ] **Step 1:** e2e: create two accounts, an app of the same name in each, ship different versions, adopt, assert the edge serves each account's build on its own host. Plus: legacy-key app still adopts via fallback.
+- [ ] **Step 2:** README: accounts section (what an account is, roles, the switcher, invites, `-account`, `accounts migrate`, and the explicit statement that legacy fallback exists until the migration completes).
+- [ ] **Step 3:** Full suite + vet + build. **Step 4: Commit** `multi-account: e2e + docs — same name, different account, different app`.
+
+---
+
+## Deployment + migration follow-ups (operator, NOT in this plan)
+
+Ship/adopt both boxes → create accounts for kass and keymail via the console → `carlos accounts migrate` each (dry-run first) → verification gate (every channel pointer resolves qualified with the fallback disabled; one adopt pass is a no-op) → delete legacy objects → remove the fallback code → set `PLATFORM_APPS_DOMAIN=oncarlos.com` on the flagship → Woodstar migrates as the first app born into its own account.
+
+## Self-review notes
+
+Spec coverage: §1→T1/T3/T4, §2→T2/T6/T7, §3→T5, §4→T4, §5→T6+follow-ups, §6→each task's tests, §7→follow-ups, §8 not built. Names cross-checked: `PutAccount`/`AccountBySqid`/`Accounts`, `NewAccountID`/`CreateAccount`/`AccountsFor`/`Role`/`AddMember`/`RemoveMember`/`SetLastAccount`/`LastAccount`/`CreateInvite`/`AcceptInvite`/`RevokeInvite`/`PendingInvites`, `WriteAccountManifest`, `GetWithFallback`, `Legacy*Key` — consistent. Deliberate deferrals stated in-place: `apps` PK change (T3 note → T7 migration), legacy deletion + fallback removal (operator follow-ups).