carlos / platform Public

plan: account slugs — six tasks, symmetric resolution everywhere

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QGdTfzwT492ysLPDaWfpvJ
Paul Campbell 084e16fac6b85826c63e1fee09bd42368b5b6705 parent 2ef101e
1 file changed, +914 −0
  • docs/superpowers/plans/2026-08-04-account-slugs.md +914 −0
diff --git a/docs/superpowers/plans/2026-08-04-account-slugs.md b/docs/superpowers/plans/2026-08-04-account-slugs.md
new file mode 100644
index 000000000..b0ce27f07
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-04-account-slugs.md
@@ -0,0 +1,914 @@
+# Account Slugs 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 account names resolve wherever sqids do — `/a/amadan/` in the console, `-account amadan` in the CLI — via symmetric membership-scoped resolution, with ambiguity surfaced (picker page / error naming `-account-id`), per `docs/superpowers/specs/2026-08-04-account-slugs-design.md`.
+
+**Architecture:** No storage change. A name validator in `internal/domains`; symmetric resolution in the console's `s.account` gate and `apiAccountApp`; a pure `pickAccount` core in the CLI's `resolveAccount`; a `preferredSeg` helper so generated links are pretty while form actions stay on sqid paths.
+
+**Tech Stack:** Go stdlib only (net/http, html/template, database/sql); no new dependencies.
+
+## Global Constraints
+
+- Build/test/vet: `go build ./... && go vet ./... && go test ./... -count=1` — all 14 packages, from the repo root, before every commit.
+- Sandbox: prefix go commands with `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; "read-only file system" from the toolchain means run with sandbox disabled.
+- `gofmt -l` must be clean for every file you touch.
+- **Wire shapes are additive** — no manifest/registry/console schema change is permitted by this plan; if a task seems to need one, stop and say so.
+- **Console security-gate order is fixed**: session → CSRF → member/owner (byte-identical 404) → `requireFreshAuth` LAST. A non-member must get the byte-identical `http.NotFound` body for names exactly as for sqids.
+- **Production channel pins** (`internal/release/laddercheck_test.go`) must stay green and untouched.
+- Commit after each task with the trailer:
+ `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>` and
+ `Claude-Session: https://claude.ai/code/session_01QGdTfzwT492ysLPDaWfpvJ`
+- Model policy (dispatcher): Task 1 → **Haiku**; Tasks 4, 6 → **Sonnet**; Tasks 2, 3, 5 → **Opus** (they touch the 404 gate and the deploy-target chooser). **Opus reviews every task.**
+
+---
+
+### Task 1: `domains.ValidAccountName` (model: Haiku)
+
+**Files:**
+- Create: `internal/domains/slugname.go`
+- Test: `internal/domains/slugname_test.go`
+
+**Interfaces:**
+- Produces: `func ValidAccountName(name string) bool` in package `domains` — later tasks (console picker/preferred-segment, creation-edge validation) call exactly this.
+
+- [ ] **Step 1: Write the failing test**
+
+`internal/domains/slugname_test.go`:
+
+```go
+package domains
+
+import "testing"
+
+func TestValidAccountName(t *testing.T) {
+ valid := []string{"amadan", "a", "kass-2", "ops", "a-b-c", "0day", "x9"}
+ invalid := []string{
+ "", "Amadan", "-lead", "trail-", "two words", "paul@keymail.dev",
+ "dots.no", "unicode-é", "UPPER",
+ // 64 chars — one past the limit.
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ }
+ for _, name := range valid {
+ if !ValidAccountName(name) {
+ t.Errorf("ValidAccountName(%q) = false, want true", name)
+ }
+ }
+ for _, name := range invalid {
+ if ValidAccountName(name) {
+ t.Errorf("ValidAccountName(%q) = true, want false", name)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run it, expect FAIL** — `go test ./internal/domains/ -run TestValidAccountName -count=1` fails with "undefined: ValidAccountName".
+
+- [ ] **Step 3: Implement**
+
+`internal/domains/slugname.go`:
+
+```go
+package domains
+
+import "regexp"
+
+// accountNameRe pins what an account NAME may be so it can live in a URL
+// path segment and a CLI flag: 1–63 characters of lowercase a-z, 0-9 and
+// hyphen, no leading or trailing hyphen. Names are still deliberately
+// non-unique (identity is the sqid); this constrains shape, not ownership.
+var accountNameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
+
+// ValidAccountName reports whether name is usable as an account name.
+// Applied at the edges where a human TYPES a name (console new-account
+// form, POST /auth/cli/accounts, `carlos accounts create`) — never at
+// auto-mint, where the personal account is named by keymail address and a
+// refusal would strand a signed-in user.
+func ValidAccountName(name string) bool { return accountNameRe.MatchString(name) }
+```
+
+- [ ] **Step 4: Run it, expect PASS**, then the full suite.
+- [ ] **Step 5: Commit** — `feat(domains): ValidAccountName pins the slug shape account names must take`
+
+---
+
+### Task 2: Console symmetric resolution + picker page (model: Opus)
+
+**Files:**
+- Modify: `internal/console/server.go` (`s.account`, ~line 525–560; `page` struct ~line 281)
+- Create: `internal/console/templates/picker.html`
+- Create: `internal/console/resolve.go` (`matchAccounts`)
+- Test: `internal/console/server_account_test.go` (extend), `internal/console/resolve_test.go`
+
+**Interfaces:**
+- Consumes: `store.AccountsFor(addr) ([]Account, error)` (fills `Role`), `page` fields, `s.render`.
+- Produces: `func matchAccounts(accts []Account, seg string) []*Account` — Task 3 mirrors its semantics; Task 4 relies on `s.account`'s resolved account being membership-scoped. New `page` fields `PickerSeg string`, `PickerAccounts []Account`.
+
+**Semantics to implement** (from spec §2): `/a/{seg}` resolves against the viewer's memberships, matching `seg` as sqid OR name (case-insensitive). One match → proceed as today. Zero → byte-identical `http.NotFound`. Several → GET renders the picker (200); any other method gets `http.NotFound` (forms post to sqid paths, so an ambiguous non-GET has no legitimate author). Signed-out still redirects to `/`.
+
+- [ ] **Step 1: Write failing tests**
+
+`internal/console/resolve_test.go`:
+
+```go
+package console
+
+import "testing"
+
+func TestMatchAccounts(t *testing.T) {
+ accts := []Account{
+ {ID: 1, Sqid: "bab", Name: "ops"},
+ {ID: 2, Sqid: "bah", Name: "amadan"},
+ {ID: 3, Sqid: "bad", Name: "amadan"},
+ {ID: 4, Sqid: "baf", Name: "bah"}, // name colliding with #2's sqid
+ {ID: 5, Sqid: "bag", Name: "Mixed"},
+ }
+ cases := []struct {
+ seg string
+ want []int64 // matched account IDs, in accts order
+ }{
+ {"bab", []int64{1}}, // sqid only
+ {"ops", []int64{1}}, // name only
+ {"amadan", []int64{2, 3}}, // two accounts share the name
+ {"bah", []int64{2, 4}}, // sqid of one, name of another
+ {"mixed", []int64{5}}, // names match case-insensitively
+ {"zzz", nil}, // nothing
+ }
+ for _, c := range cases {
+ got := matchAccounts(accts, c.seg)
+ var ids []int64
+ for _, a := range got {
+ ids = append(ids, a.ID)
+ }
+ if len(ids) != len(c.want) {
+ t.Errorf("matchAccounts(%q) = %v, want %v", c.seg, ids, c.want)
+ continue
+ }
+ for i := range ids {
+ if ids[i] != c.want[i] {
+ t.Errorf("matchAccounts(%q) = %v, want %v", c.seg, ids, c.want)
+ break
+ }
+ }
+ }
+}
+```
+
+Extend `internal/console/server_account_test.go` (use the existing fixture helpers — `newConsoleFixture`, `newBrowser`, `signUp`, `get`, `postForm`, `testAccountID`, `accountSqid`; read the fixture first to learn the fixture account's name):
+
+```go
+// A member can address an account by its name; the page is the same page
+// its sqid serves.
+func TestAccountNameResolvesForMember(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-1")
+ id := testAccountID()
+ sqid := accountSqid(id)
+ if err := fx.st.CreateAccount(id, sqid, "amadan", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ st, _, byName := get(t, c, fx.ts.URL+"/a/amadan/")
+ if st != http.StatusOK {
+ t.Fatalf("GET /a/amadan/ = %d, want 200", st)
+ }
+ if !strings.Contains(byName, "amadan") {
+ t.Fatalf("name-resolved page does not mention the account")
+ }
+}
+
+// A name the viewer cannot resolve — someone ELSE's account — answers with
+// the byte-identical 404 an unknown page gets, exactly as sqids do.
+func TestAccountNameNotProbeable(t *testing.T) {
+ fx := newConsoleFixture(t)
+ id := testAccountID()
+ if err := fx.st.CreateAccount(id, accountSqid(id), "secretco", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("bob@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-bob")
+ st, _, body := get(t, c, fx.ts.URL+"/a/secretco/")
+ _, _, ghost := get(t, c, fx.ts.URL+"/definitely-not-a-page")
+ if st != http.StatusNotFound || body != ghost {
+ t.Fatalf("non-member name lookup: status %d, body match %v — want the byte-identical 404", st, body == ghost)
+ }
+}
+
+// Two memberships sharing a name: GET renders the picker naming both sqids;
+// a POST to the ambiguous segment is refused with the same 404.
+func TestAmbiguousNameRendersPicker(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-1")
+ idA, idB := testAccountID(), testAccountID()
+ sqidA, sqidB := accountSqid(idA), accountSqid(idB)
+ for _, x := range []struct {
+ id int64
+ sqid string
+ }{{idA, sqidA}, {idB, sqidB}} {
+ if err := fx.st.CreateAccount(x.id, x.sqid, "amadan", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ }
+ st, _, body := get(t, c, fx.ts.URL+"/a/amadan/")
+ if st != http.StatusOK {
+ t.Fatalf("ambiguous GET = %d, want 200 picker", st)
+ }
+ if !strings.Contains(body, "/a/"+sqidA+"/") || !strings.Contains(body, "/a/"+sqidB+"/") {
+ t.Fatalf("picker must link both candidates by sqid:\n%s", body)
+ }
+ if st, _, _ := postForm(t, c, fx.ts.URL+"/a/amadan/invite", url.Values{"address": {"x@keymail.dev"}}); st != http.StatusNotFound {
+ t.Errorf("POST to ambiguous segment = %d, want 404", st)
+ }
+}
+
+// A name that equals another membership's SQID is ambiguous the same way —
+// symmetric resolution never silently prefers one space over the other.
+func TestNameSqidCollisionRendersPicker(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-1")
+ idA := testAccountID()
+ sqidA := accountSqid(idA)
+ if err := fx.st.CreateAccount(idA, sqidA, "amadan", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ idB := testAccountID()
+ if err := fx.st.CreateAccount(idB, accountSqid(idB), sqidA, "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ st, _, body := get(t, c, fx.ts.URL+"/a/"+sqidA+"/")
+ if st != http.StatusOK || !strings.Contains(body, "/a/"+accountSqid(idB)+"/") {
+ t.Fatalf("sqid-vs-name collision: status %d — want the picker listing both", st)
+ }
+}
+```
+
+- [ ] **Step 2: Run, expect FAIL** — `matchAccounts` undefined; name URLs 404.
+
+- [ ] **Step 3: Implement**
+
+`internal/console/resolve.go`:
+
+```go
+package console
+
+import "strings"
+
+// matchAccounts resolves one path/flag segment against the accounts the
+// VIEWER belongs to, matching it as a sqid or a name — symmetrically, with
+// no precedence between the two spaces. Precedence is how a reference
+// silently changes meaning the day a colliding account appears; a multi-
+// match is returned whole so the caller can be loud instead (picker page,
+// CLI error). Names match case-insensitively; an account whose name equals
+// its own sqid still counts once.
+func matchAccounts(accts []Account, seg string) []*Account {
+ var out []*Account
+ for i := range accts {
+ if accts[i].Sqid == seg || strings.EqualFold(accts[i].Name, seg) {
+ out = append(out, &accts[i])
+ }
+ }
+ return out
+}
+```
+
+Rewrite `s.account` in `internal/console/server.go` (keeping its comment's contract, updated):
+
+```go
+// account is the gate on every /a/{seg}/… route: a session, and a segment
+// that resolves — as a sqid OR a name, symmetrically — to an account the
+// viewer belongs to. Resolution and membership proof are deliberately the
+// same lookup (the membership set IS the resolution scope), so an unknown
+// account, someone else's account, and a name that resolves to nothing all
+// answer with the BYTE-IDENTICAL http.NotFound an unrouted URL gets.
+// Several matches (a shared name, or a name colliding with a sqid) render
+// the picker on GET and the same 404 otherwise — forms always post to sqid
+// paths, so an ambiguous mutation has no legitimate author.
+func (s *Server) account(w http.ResponseWriter, r *http.Request) (addr, raw string, acct *Account, role string, ok bool) {
+ addr, raw, sok := s.session(r)
+ if !sok {
+ http.Redirect(w, r, "/", http.StatusFound)
+ return "", "", nil, "", false
+ }
+ accts, err := s.store.AccountsFor(addr)
+ if err != nil {
+ log.Printf("console: account lookup: %v", err)
+ http.NotFound(w, r)
+ return "", "", nil, "", false
+ }
+ seg := r.PathValue("sqid")
+ matches := matchAccounts(accts, seg)
+ switch len(matches) {
+ case 1:
+ a := matches[0]
+ return addr, raw, a, a.Role, true
+ case 0:
+ http.NotFound(w, r)
+ return "", "", nil, "", false
+ default:
+ if r.Method != http.MethodGet {
+ http.NotFound(w, r)
+ return "", "", nil, "", false
+ }
+ p := s.basePage(r)
+ p.Title = "Which " + seg + "?"
+ p.PickerSeg = seg
+ for _, m := range matches {
+ p.PickerAccounts = append(p.PickerAccounts, *m)
+ }
+ s.render(w, http.StatusOK, "picker", p)
+ return "", "", nil, "", false
+ }
+}
+```
+
+Note: `role` now comes from `AccountsFor`'s `m.Role` — verify `AccountsFor` fills it (it selects `m.role`; it does). The old `AccountBySqid`+`Role` pair is no longer called here; leave those store methods alone (other callers exist).
+
+Add to `page` struct (near the account chrome block):
+
+```go
+ PickerSeg string // the ambiguous segment the picker is disambiguating
+ PickerAccounts []Account // every account of the viewer's that segment matches
+```
+
+`internal/console/templates/picker.html` (match the house template style — `{{define}}`, `top`/`bottom`):
+
+```html
+{{define "picker"}}{{template "top" .}}
+<h1>Which “{{.PickerSeg}}”?</h1>
+<p class="lede">More than one of your accounts answers to
+<code>{{.PickerSeg}}</code>. Pick the one you meant — a sqid link is
+permanent and never asks this question.</p>
+<table>
+ <thead><tr><th>Name</th><th>Sqid</th><th>Your role</th><th>Created</th></tr></thead>
+ <tbody>
+ {{range .PickerAccounts}}
+ <tr>
+ <td><a href="/a/{{.Sqid}}/">{{.Name}}</a></td>
+ <td><code>/a/{{.Sqid}}/</code></td>
+ <td>{{.Role}}</td>
+ <td>{{.CreatedAt.Format "2006-01-02"}}</td>
+ </tr>
+ {{end}}
+ </tbody>
+</table>
+{{template "bottom" .}}{{end}}
+```
+
+- [ ] **Step 4: Run the new tests, then the whole console package, then the full suite.** `TestNonMemberGetsIdenticalNotFound` and every existing `/a/<sqid>` test must pass unchanged.
+- [ ] **Step 5: Commit** — `feat(console): /a/{seg} resolves names symmetrically; ambiguity renders a picker, never a guess`
+
+---
+
+### Task 3: Console API symmetric resolution + `exact=sqid` (model: Opus)
+
+**Files:**
+- Modify: `internal/console/apicli.go` (`apiAccountApp`, lines ~30–100)
+- Test: alongside the existing API-CLI tests (grep `apiAccountApp\|/api/cli` in `internal/console/*_test.go` and extend that file; if none exists, add `internal/console/apicli_test.go` using the console fixture + a CLI token the way the existing authcli tests mint one)
+
+**Interfaces:**
+- Consumes: `store.AccountsFor`, `jsonError`, `s.cliUser`.
+- Produces: the query contract Task 5's client relies on — `?exact=sqid` on any `/api/cli/apps/{account}/{app}/...` URL makes the `{account}` segment match as a sqid only; the ambiguity refusal is HTTP 409 whose message contains `-account-id`.
+
+- [ ] **Step 1: Write failing tests** — through the HTTP surface (`GET /api/cli/apps/{account}/{app}/channels` is the simplest authed route):
+ - caller with two accounts named `amadan` (one holding app `web`): request with segment `amadan` → **409**, body mentions both sqids and the string `-account-id`.
+ - same setup, segment `<sqidA>?exact=sqid` → 200 (or the route's normal answer for an app that exists there).
+ - caller whose account named `bah` collides with another membership's sqid `bah`: segment `bah` → **409** (symmetric — today's code would silently pick the sqid match).
+ - `?exact=sqid` with a segment that is a NAME (`amadan`) → 404 (never name-matches).
+ - non-member account segment (name or sqid) → 404, `http.NotFound` shape.
+
+- [ ] **Step 2: Run, expect FAIL** (the name/sqid collision silently resolves today; `exact` is ignored).
+
+- [ ] **Step 3: Implement** — replace the resolution block of `apiAccountApp`:
+
+```go
+ seg := r.PathValue("account")
+ app := r.PathValue("app")
+ accts, err := s.store.AccountsFor(addr)
+ if err != nil {
+ jsonError(w, http.StatusInternalServerError, "could not load accounts")
+ return "", nil, "", false
+ }
+ var matches []*Account
+ if r.URL.Query().Get("exact") == "sqid" {
+ // -account-id: the reference that never guesses. Match the segment
+ // as a sqid only — a name, however unique, does not answer.
+ for i := range accts {
+ if accts[i].Sqid == seg {
+ matches = append(matches, &accts[i])
+ break
+ }
+ }
+ } else {
+ matches = matchAccounts(accts, seg)
+ }
+ var acct *Account
+ switch len(matches) {
+ case 1:
+ acct = matches[0]
+ case 0:
+ // acct stays nil — the same 404 an unknown or someone-else's
+ // account gets, below.
+ default:
+ alts := make([]string, len(matches))
+ for i, m := range matches {
+ alts[i] = fmt.Sprintf("%s (%q)", m.Sqid, m.Name)
+ }
+ jsonError(w, http.StatusConflict,
+ fmt.Sprintf("account %q is ambiguous — pass -account-id with one of: %s", seg, strings.Join(alts, ", ")))
+ return "", nil, "", false
+ }
+```
+
+Update the function's doc comment: resolution is now symmetric (no sqid precedence), and `exact=sqid` is the `-account-id` transport. Keep everything from the `if acct == nil` 404 onward unchanged.
+
+- [ ] **Step 4: Run new tests + console package + full suite.**
+- [ ] **Step 5: Commit** — `feat(console): API account resolution goes symmetric; exact=sqid carries -account-id`
+
+---### Task 4: Preferred segments — links pretty, actions canonical (model: Sonnet)
+
+**Files:**
+- Modify: `internal/console/server.go` (`accountPage` ~1068, `redirectToCurrentAccount` ~430, `page` struct — add `Nav`; every `Base:` assignment gets a `Nav:` sibling — grep `Base:`)
+- Modify: `internal/console/store.go` (`Account` struct — add view-only `Seg` field)
+- Modify: `internal/console/resolve.go` (add `preferredSeg`)
+- Modify templates: `layout.html` (lines 229, 241), `apps.html` (line 12), `newaccount.html` (line 17)
+- Test: `internal/console/resolve_test.go`, `internal/console/server_account_test.go`
+
+**Interfaces:**
+- Consumes: `matchAccounts` (Task 2), `domains.ValidAccountName` (Task 1).
+- Produces: `func preferredSeg(accts []Account, a *Account) string`; `page.Nav string` (link base, pretty); `Account.Seg string` (switcher hrefs). **Form actions keep using `page.Base` (sqid) — do not touch any `action=` attribute.**
+
+- [ ] **Step 1: Write failing tests**
+
+`internal/console/resolve_test.go`:
+
+```go
+func TestPreferredSeg(t *testing.T) {
+ accts := []Account{
+ {ID: 1, Sqid: "bab", Name: "ops"},
+ {ID: 2, Sqid: "bah", Name: "amadan"},
+ {ID: 3, Sqid: "bad", Name: "amadan"},
+ {ID: 4, Sqid: "baf", Name: "paul@keymail.dev"},
+ {ID: 5, Sqid: "bag", Name: "bah"}, // collides with #2's sqid
+ }
+ cases := []struct {
+ id int64
+ want string
+ }{
+ {1, "ops"}, // unique slug-shaped name → the name
+ {2, "bah"}, // name shared with #3 → sqid
+ {3, "bad"}, // same
+ {4, "baf"}, // address-named (auto-mint) → sqid
+ {5, "bag"}, // name equals #2's sqid → sqid
+ }
+ for _, c := range cases {
+ var a *Account
+ for i := range accts {
+ if accts[i].ID == c.id {
+ a = &accts[i]
+ }
+ }
+ if got := preferredSeg(accts, a); got != c.want {
+ t.Errorf("preferredSeg(#%d) = %q, want %q", c.id, got, c.want)
+ }
+ }
+}
+```
+
+`server_account_test.go` — landing and chrome:
+
+```go
+// Landing prefers the readable URL: a viewer whose account has a unique
+// slug-shaped name lands on /a/<name>/, and the page's nav links use it,
+// while its forms keep posting to the sqid path.
+func TestLandingPrefersNameSegment(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.setUser("solo@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-solo")
+ id := testAccountID()
+ sqid := accountSqid(id)
+ if err := fx.st.CreateAccount(id, sqid, "amadan", "solo@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if st, _, _ := get(t, c, fx.ts.URL+"/a/"+sqid+"/"); st != http.StatusOK {
+ t.Fatal("visit to set MRU")
+ }
+ st, loc, _ := get(t, c, fx.ts.URL+"/")
+ if st != http.StatusFound || loc != "/a/amadan/" {
+ t.Fatalf("landing = %d -> %q, want 302 -> /a/amadan/", st, loc)
+ }
+ _, _, body := get(t, c, fx.ts.URL+"/a/amadan/")
+ if !strings.Contains(body, `href="/a/amadan/"`) {
+ t.Errorf("nav links should use the name segment")
+ }
+ if !strings.Contains(body, `action="/a/`+sqid+`/`) {
+ t.Errorf("form actions must stay on the sqid path")
+ }
+}
+```
+
+(If the fixture's own account name is slug-shaped and unique for `paul@keymail.dev`, existing assertions like `loc != "/a/"+sqid+"/"` in `TestLandingRedirectsToLastAccount` may now see the name — run the suite and update any such assertion to the preferred segment, stating why in the diff.)
+
+- [ ] **Step 2: Run, expect FAIL.**
+
+- [ ] **Step 3: Implement**
+
+`internal/console/resolve.go`:
+
+```go
+// preferredSeg is the path segment the console GENERATES for an account:
+// its name, when the name is slug-shaped and resolves uniquely among the
+// viewer's own accounts; the sqid otherwise. Links and redirects use it —
+// forms always post to the sqid path (page.Base), because a mutation must
+// name its target exactly and prettiness is for links.
+func preferredSeg(accts []Account, a *Account) string {
+ if !domains.ValidAccountName(a.Name) {
+ return a.Sqid
+ }
+ for i := range accts {
+ if accts[i].ID == a.ID {
+ continue
+ }
+ if strings.EqualFold(accts[i].Name, a.Name) || accts[i].Sqid == a.Name {
+ return a.Sqid
+ }
+ }
+ return a.Name
+}
+```
+
+(import `github.com/carlosframework/platform/internal/domains` — the console already depends on it.)
+
+`store.go` `Account` struct — add:
+
+```go
+ // Seg is the path segment the console generates for this account in the
+ // viewer's chrome (preferredSeg) — display state, never stored.
+ Seg string
+```
+
+`accountPage` — after the existing `p.Accounts` fallback block:
+
+```go
+ for i := range p.Accounts {
+ p.Accounts[i].Seg = preferredSeg(p.Accounts, &p.Accounts[i])
+ }
+ p.Nav = "/a/" + url.PathEscape(preferredSeg(p.Accounts, acct))
+```
+
+`page` struct — next to `Base`:
+
+```go
+ Nav string // "/a/<name-or-sqid>" — what LINKS use; forms keep posting to Base
+```
+
+Every other `Base:` assignment (grep — e.g. the `/accounts/new` page builder) gets `Nav` set the same way; where the accounts list isn't loaded, set `Nav` equal to `Base` rather than loading it.
+
+`redirectToCurrentAccount` — before the redirect:
+
+```go
+ seg := sqid
+ if accts, err := s.store.AccountsFor(addr); err == nil {
+ for i := range accts {
+ if accts[i].Sqid == sqid {
+ seg = preferredSeg(accts, &accts[i])
+ break
+ }
+ }
+ }
+ http.Redirect(w, r, "/a/"+url.PathEscape(seg)+"/", code)
+```
+
+Templates — hrefs only:
+- `layout.html:229`: `<a href="/a/{{.Seg}}/">…{{.Name}}</a>`
+- `layout.html:241`: `<a href="{{.Nav}}/">Apps</a>`
+- `apps.html:12`: `<a href="{{$.Nav}}/apps/{{.App}}">{{.App}}</a>`
+- `newaccount.html:17`: `<a href="{{.Nav}}/">Back to {{.Account.Name}}</a>`
+
+Redirects **after POST** (ownership.go's `/a/"+acct.Sqid+"/`, `handleNewAccount`'s) stay on sqid paths — do not change them.
+
+- [ ] **Step 4: Run console package, then full suite; fix any landing assertions per Step 1's note.**
+- [ ] **Step 5: Commit** — `feat(console): links and landings prefer the account name; forms stay on sqid paths`
+
+---
+
+### Task 5: CLI — symmetric `resolveAccount`, `-account-id`, API-mode threading (model: Opus)
+
+**Files:**
+- Modify: `accounts.go` (`resolveAccount` ~line 622 — extract pure `pickAccount`)
+- Modify: `cli.go` (`accountFlags` ~738 and its call sites ~816, 984, 1052, 1108; the manual `-account` registrations at ~69/130 and ~267/345; `ledgerAccountFlag` ~1237 and its call sites)
+- Modify: `apimode.go` (`apiPath` ~169; the `apiSession` methods that take an `account string`)
+- Test: `accounts_resolve_test.go` (new, package main)
+
+**Interfaces:**
+- Consumes: `registry.Account{ID, Sqid, Name}`, `release.ValidAccount`, the `exact=sqid` query contract from Task 3.
+- Produces:
+ - `func pickAccount(accts []registry.Account, want string) (string, error)` — pure resolution core.
+ - `func resolveAccount(regPath, want, wantID string) string` — new third parameter.
+ - `func accountFlags(fs *flag.FlagSet) (raw, rawID *string, resolve func() string)`.
+ - `func apiAccount(raw, rawID *string) apiAcct` and `type apiAcct struct { Seg string; Exact bool }` — every `apiSession` method that took `account string` takes `apiAcct`; `apiPath(a apiAcct, app, tail string)` appends `?exact=sqid` when `a.Exact` (verify no existing `tail` carries a query string first).
+
+- [ ] **Step 1: Write failing tests**
+
+`accounts_resolve_test.go`:
+
+```go
+package main
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func TestPickAccount(t *testing.T) {
+ accts := []registry.Account{
+ {ID: 1, Sqid: "bab", Name: "ops"},
+ {ID: 2, Sqid: "bah", Name: "amadan"},
+ {ID: 3, Sqid: "bad", Name: "amadan"},
+ {ID: 4, Sqid: "baf", Name: "bah"}, // name collides with #2's sqid
+ }
+ if got, err := pickAccount(accts, "bab"); err != nil || got != "bab" {
+ t.Errorf("sqid: %q, %v", got, err)
+ }
+ if got, err := pickAccount(accts, "ops"); err != nil || got != "bab" {
+ t.Errorf("unique name: %q, %v", got, err)
+ }
+ if got, err := pickAccount(accts, "OPS"); err != nil || got != "bab" {
+ t.Errorf("case-insensitive name: %q, %v", got, err)
+ }
+ // A sqid nobody has materialized passes through — a fresh box may not
+ // have reconciled, and CI has no registry at all.
+ if got, err := pickAccount(accts, "zzz"); err != nil || got != "zzz" {
+ t.Errorf("passthrough sqid: %q, %v", got, err)
+ }
+ if _, err := pickAccount(accts, "not-a-sqid-or-name"); err == nil {
+ t.Error("unresolvable non-sqid name must error")
+ }
+ // The two ambiguity shapes: a shared name, and a name/sqid collision.
+ // Both must refuse, and the fix-up named must be -account-id.
+ for _, want := range []string{"amadan", "bah"} {
+ _, err := pickAccount(accts, want)
+ if err == nil || !strings.Contains(err.Error(), "-account-id") {
+ t.Errorf("pickAccount(%q) = %v, want ambiguity error naming -account-id", want, err)
+ }
+ }
+ if _, err := pickAccount(accts, "bah"); err == nil ||
+ !strings.Contains(err.Error(), "bah") || !strings.Contains(err.Error(), "baf") {
+ t.Error("name/sqid conflict error must list both candidates")
+ }
+}
+
+func TestApiAccountExact(t *testing.T) {
+ acct, id := "ops", "bah"
+ a := apiAccount(&acct, &id)
+ if a.Seg != "bah" || !a.Exact {
+ t.Errorf("apiAccount with -account-id = %+v, want {bah true}", a)
+ }
+ id = ""
+ a = apiAccount(&acct, &id)
+ if a.Seg != "ops" || a.Exact {
+ t.Errorf("apiAccount without -account-id = %+v, want {ops false}", a)
+ }
+ if p := apiPath(apiAcct{Seg: "bah", Exact: true}, "web", "/channels"); !strings.HasSuffix(p, "?exact=sqid") {
+ t.Errorf("apiPath exact = %q, want ?exact=sqid suffix", p)
+ }
+ if p := apiPath(apiAcct{Seg: "ops"}, "web", "/channels"); strings.Contains(p, "exact") {
+ t.Errorf("apiPath non-exact = %q, must carry no exact param", p)
+ }
+}
+```
+
+- [ ] **Step 2: Run, expect FAIL** (`pickAccount`, `apiAccount`, `apiAcct` undefined).
+
+- [ ] **Step 3: Implement**
+
+`accounts.go` — split `resolveAccount`:
+
+```go
+// pickAccount is resolveAccount's core, symmetric over the two identity
+// spaces: want matches an account by sqid OR by name (case-insensitively),
+// with NO precedence between them. Precedence is how a script's -account
+// silently changes target the day a colliding account materializes; a
+// conflict here is loud instead, and names -account-id, the reference that
+// never guesses. A sqid-shaped want that matches nothing passes through (a
+// fresh box may not have reconciled; CI has no registry at all).
+func pickAccount(accts []registry.Account, want string) (string, error) {
+ var candidates []registry.Account
+ for _, a := range accts {
+ if a.Sqid == want || strings.EqualFold(a.Name, want) {
+ candidates = append(candidates, a)
+ }
+ }
+ switch len(candidates) {
+ case 1:
+ return candidates[0].Sqid, nil
+ case 0:
+ if release.ValidAccount(want) {
+ return want, nil
+ }
+ return "", fmt.Errorf("no account %q here — `carlos accounts list` shows what this box knows", want)
+ default:
+ alts := make([]string, len(candidates))
+ for i, a := range candidates {
+ alts[i] = fmt.Sprintf("%s (%q)", a.Sqid, a.Name)
+ }
+ return "", fmt.Errorf("account %q is ambiguous — pass -account-id with one of: %s",
+ want, strings.Join(alts, ", "))
+ }
+}
+```
+
+`resolveAccount` keeps its shell (empty/"ops" shortcut, registry-open with its existing no-registry fallback and messages) and gains the id short-circuit; the matching loops are replaced by one `pickAccount` call whose error goes to `log.Fatalf`:
+
+```go
+func resolveAccount(regPath, want, wantID string) string {
+ if wantID = strings.TrimSpace(wantID); wantID != "" {
+ if !release.ValidAccount(wantID) {
+ log.Fatalf("-account-id %q is not a sqid — it is matched exactly, never as a name", wantID)
+ }
+ return wantID
+ }
+ // ... existing empty/"ops" shortcut and registry-open fallback unchanged ...
+ sqid, err := pickAccount(accts, want)
+ if err != nil {
+ log.Fatal(err)
+ }
+ return sqid
+}
+```
+
+`cli.go` `accountFlags`:
+
+```go
+func accountFlags(fs *flag.FlagSet) (raw, rawID *string, resolve func() string) {
+ acct := fs.String("account", "ops", "account (sqid or name) the app belongs to")
+ acctID := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account; the flag ambiguity errors tell you to use")
+ regPath := fs.String("registry", defaultRegistry(), "registry database (resolves -account)")
+ return acct, acctID, func() string { return resolveAccount(*regPath, *acct, *acctID) }
+}
+```
+
+Every `accountFlags` call site becomes `acctRaw, acctIDRaw, acctOf := accountFlags(fs)`; API-mode branches build `apiAccount(acctRaw, acctIDRaw)` instead of `*acctRaw` (this changes `runShipAPI`'s signature — pass both pointers). The two manual `-account` registrations (`cli.go` ~69 and ~267) each gain their own `fs.String("account-id", "", …)` threaded into their `resolveAccount` calls (the ~267 route-filter keeps its `"all"` semantics: `all` never reaches `resolveAccount`, so nothing changes there). `ledgerAccountFlag` grows into:
+
+```go
+func ledgerAccountFlags(fs *flag.FlagSet) (raw, rawID *string) {
+ return fs.String("account", "ops", "account (sqid or name) the app belongs to — resolved by the console"),
+ fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+}
+```
+
+with its call sites building `apiAccount(raw, rawID)`.
+
+`apimode.go`:
+
+```go
+// apiAcct is one resolved -account/-account-id pair, API-mode: the segment
+// the console will resolve, and whether it must match as a sqid only.
+type apiAcct struct {
+ Seg string
+ Exact bool
+}
+
+// apiAccount is resolveAccount's API-mode counterpart. Names resolve
+// SERVER-side against the caller's memberships, so -account travels
+// verbatim; -account-id travels as the segment plus exact=sqid, so the
+// console never name-matches it.
+func apiAccount(raw, rawID *string) apiAcct {
+ if id := strings.TrimSpace(*rawID); id != "" {
+ if !release.ValidAccount(id) {
+ log.Fatalf("-account-id %q is not a sqid — it is matched exactly, never as a name", id)
+ }
+ return apiAcct{Seg: id, Exact: true}
+ }
+ return apiAcct{Seg: *raw}
+}
+```
+
+`apiPath` takes the pair (update its comment: account still resolves server-side):
+
+```go
+func apiPath(a apiAcct, app, tail string) string {
+ p := "/api/cli/apps/" + url.PathEscape(a.Seg) + "/" + url.PathEscape(app) + tail
+ if a.Exact {
+ p += "?exact=sqid"
+ }
+ return p
+}
+```
+
+Change each `apiSession` method (`promote`, `rollback`, `channels`, `ship`, the five `ledger*`) from `account string` to `a apiAcct`, passing it through to `apiPath`. First verify no call passes a `tail` containing `?`; if one does, switch `apiPath` to `url.Values` instead of string concat and say so in the commit message.
+
+- [ ] **Step 4: Run new tests, then the full suite** — `apimode_test.go` exercises this client surface end-to-end and must stay green (it will need its call sites updated for the new signatures; behavior assertions stay).
+- [ ] **Step 5: Commit** — `cli: -account resolves symmetrically and loudly; -account-id is the reference that never guesses`
+
+---
+
+### Task 6: Validate names at the three creation edges (model: Sonnet)
+
+**Files:**
+- Modify: `internal/console/server.go` (`handleNewAccount` ~633; `mintAccount` comment ~500)
+- Modify: `internal/console/authcli.go` (`handleCLINewAccount` ~686)
+- Modify: `accounts.go` (`runAccountsCreate` ~79)
+- Modify: `internal/console/templates/newaccount.html` (the name input)
+- Test: `internal/console/server_account_test.go` (or the file housing existing new-account tests — grep `accounts/new`)
+
+**Interfaces:**
+- Consumes: `domains.ValidAccountName` (Task 1).
+- Produces: nothing new — this closes the front doors.
+
+- [ ] **Step 1: Write failing tests**
+
+```go
+// The three edges where a human types a name all refuse a non-slug; the
+// auto-mint path (address-named personal accounts) must NOT be validated —
+// a mint that can refuse strands a signed-in user.
+func TestNewAccountNameValidated(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-1")
+ before, err := fx.st.AccountsFor("paul@keymail.dev")
+ if err != nil {
+ t.Fatal(err)
+ }
+ st, loc, _ := postForm(t, c, fx.ts.URL+"/accounts/new", url.Values{"name": {"Paul's Stuff!"}})
+ if st != http.StatusSeeOther || loc != "/accounts/new" {
+ t.Fatalf("bad name = %d -> %q, want 303 back to the form", st, loc)
+ }
+ after, err := fx.st.AccountsFor("paul@keymail.dev")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(after) != len(before) {
+ t.Fatal("a rejected name must not create an account")
+ }
+ if st, _, _ := postForm(t, c, fx.ts.URL+"/accounts/new", url.Values{"name": {"amadan"}}); st != http.StatusSeeOther {
+ t.Fatalf("good name = %d, want 303 into the new account", st)
+ }
+}
+```
+
+(Adapt the CSRF plumbing to how existing `/accounts/new` tests post the form — copy their shape. Add the equivalent 400 assertion for `POST /auth/cli/accounts` in the file that already tests `handleCLINewAccount`, posting `{"name":"Paul's Stuff!"}` with a valid CLI token and expecting `http.StatusBadRequest`.)
+
+- [ ] **Step 2: Run, expect FAIL** (free-text names are accepted today).
+
+- [ ] **Step 3: Implement**
+
+`handleNewAccount` — replace the `name == "" || len(name) > 64` check:
+
+```go
+ name := strings.TrimSpace(r.FormValue("name"))
+ if !domains.ValidAccountName(name) {
+ s.setFlash(w, "err", "Account names appear in URLs: 1–63 characters of a-z, 0-9 and hyphen, no leading or trailing hyphen.")
+ http.Redirect(w, r, "/accounts/new", http.StatusSeeOther)
+ return
+ }
+```
+
+`handleCLINewAccount` — same predicate:
+
+```go
+ name := strings.TrimSpace(req.Name)
+ if !domains.ValidAccountName(name) {
+ jsonError(w, http.StatusBadRequest,
+ "an account name appears in URLs and -account: 1-63 characters of a-z, 0-9 and hyphen, no leading or trailing hyphen")
+ return
+ }
+```
+
+`runAccountsCreate` — after the existing empty-name usage check:
+
+```go
+ if !domains.ValidAccountName(strings.TrimSpace(*name)) {
+ log.Fatalf("accounts create: %q is not a usable account name — 1-63 characters of a-z, 0-9\n"+
+ "and hyphen, no leading or trailing hyphen. The name appears in console URLs and\n"+
+ "resolves -account.", *name)
+ }
+```
+
+Also update the `-name` flag's help text (`"account name (free text, need not be unique)"` → `"account name (a-z, 0-9, hyphen; need not be unique)"`).
+
+`mintAccount` — add one line to its comment:
+
+```go
+// The name is NOT validated here: auto-mint names personal accounts by
+// keymail address, and landing must never refuse to mint (spec §1).
+```
+
+`newaccount.html` — on the name `<input>`, add `pattern="[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?"` and a hint line under it: `a-z, 0-9 and hyphens — it appears in your URLs`. Adjust any copy that says names are free text.
+
+- [ ] **Step 4: Run the console package and full suite** — `TestLandingMintsPersonalAccountWhenNoMemberships` (auto-mint) must stay green; update any existing test that created accounts through these handlers with non-slug names.
+- [ ] **Step 5: Commit** — `feat: account names are validated at the three doors a human types one through`
+
+---
+
+### Task 7: Verification sweep (dispatcher, with Opus review of the whole diff)
+
+- [ ] `go build ./... && go vet ./... && go test ./... -count=1` — all packages, zero failures.
+- [ ] `gofmt -l .` — no touched file listed (the three pre-existing unformatted files elsewhere are left alone).
+- [ ] `git log --oneline main..HEAD` — one commit per task, plan and spec committed.
+- [ ] Opus reviews the full branch diff against the spec (superpowers:requesting-code-review), with explicit attention to: byte-identical 404 preserved for names; no `action=` attribute moved off `Base`; no storage shape changed; `laddercheck_test.go` untouched.