spec: account names as addresses — slugs without a namespace
Design approved 2026-08-04: the existing non-unique name becomes URL/CLI-addressable via symmetric membership-scoped resolution; ambiguity surfaces a picker (console) or an error naming -account-id (CLI); sqid URLs stay permanent and canonical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QGdTfzwT492ysLPDaWfpvJ
1 file changed,
+187
−0
docs/superpowers/specs/2026-08-04-account-slugs-design.md+187 −0
diff --git a/docs/superpowers/specs/2026-08-04-account-slugs-design.md b/docs/superpowers/specs/2026-08-04-account-slugs-design.md| new file mode 100644 |
| index 000000000..312961682 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-04-account-slugs-design.md |
| @@ -0,0 +1,187 @@ |
| +# Account names as addresses — slugs without a namespace |
| + |
| +`/a/bah/` works but nobody can read it. This spec makes the account **name** |
| +usable wherever the sqid is usable today — `/a/amadan/` in the console, |
| +`-account amadan` in the CLI — without creating a global name registry, |
| +changing any storage shape, or weakening the byte-identical-404 property. |
| + |
| +Extends `2026-08-02-multi-account-design.md` (§1, §3): names stay |
| +**deliberately non-unique** — identity is still the sqid, so there is still |
| +nothing to squat. What changes is that a name now *resolves*, per viewer, |
| +against the accounts that viewer is a member of. |
| + |
| +## 1. The name becomes the slug |
| + |
| +There is no new field. The existing `name` — already carried by the account |
| +manifest, the registry row and the console row — is the slug, constrained at |
| +the edges where a human types one: |
| + |
| +- **Charset**: `[a-z0-9-]`, 1–63 characters, no leading or trailing hyphen. |
| + Lowercase only; there is no case-folding ambiguity to litigate later. |
| +- **Validated at the three user-input edges**: the console's new-account |
| + form, `POST /auth/cli/accounts` (the console-mediated |
| + `carlos accounts create -console` path), and headless |
| + `carlos accounts create` writing straight to the bucket. One validator, |
| + `domains.ValidAccountName`, used by all three — the CLI and the console |
| + must not disagree about what a name is. |
| +- **Not validated at auto-mint.** The personal account minted on first |
| + sign-in is named by keymail address (`paul@keymail.dev`), which is not |
| + slug-shaped and must never fail: landing mints, and a mint that can refuse |
| + strands a signed-in user. Address-named accounts are simply not |
| + URL-addressable by name; their sqid works as it always has. |
| +- **Existing names are left alone.** No migration, no normalization pass. |
| + A stored name that happens to be slug-shaped (every real tenant account |
| + today: `ops`, `kass`, `keymail`, `bae`…) becomes addressable the day this |
| + ships; one that isn't, isn't. Name matching stays case-insensitive, as the |
| + CLI's resolver has always been. |
| + |
| +**Wire shape: unchanged.** No manifest field is added, removed or |
| +re-interpreted; anything written before this change parses exactly as |
| +before. Validation is a front-door policy, not a storage property. |
| + |
| +A free-text **display label** is named future work: if someone ever wants |
| +"Paul's Experiments 🧪" on the page while `amadan` addresses it, that is an |
| +additive `label` field on console row + manifest, defaulting to the name. |
| +Not built now. |
| + |
| +## 2. Resolution is symmetric and membership-scoped |
| + |
| +One rule, console and CLI alike: |
| + |
| +> A reference resolves against the viewer's own account set, matching as |
| +> **either** sqid or name. Exactly one account matches → that account. More |
| +> than one → surface the ambiguity, never pick. Zero → exactly what a |
| +> nonexistent account produces today. |
| + |
| +**Symmetric, not precedence-ordered — this is the collision answer.** The |
| +CLI today gives an exact sqid match silent precedence, which means a script |
| +targeting an account *named* `bah` silently changes target the day its owner |
| +is invited to an account whose *sqid* is `bah`. Collisions between the two |
| +spaces cannot be prevented: sqids are minted from sequential ids (nobody |
| +chooses them), names have no global registry to check (by design), and the |
| +sqid alphabet is 23 lowercase letters so most good names — `amadan` |
| +included — are valid future sqids. Prevention being off the table, the rule |
| +is that a collision is **loud, never wrong**: a reference matching two |
| +different accounts is an ambiguity in both surfaces, identical to two |
| +accounts sharing a name. |
| + |
| +### Console |
| + |
| +`/a/{seg}` resolves `seg` against the signed-in viewer's memberships: |
| + |
| +- **One match** → the account page, exactly as `/a/<sqid>` behaves today. |
| +- **Several matches** (same name twice, or a name/sqid collision) → a |
| + **picker page**: each candidate's name, sqid, the viewer's role and the |
| + creation date, linking to `/a/<sqid>/`. This is the disambiguation map — |
| + it lists only accounts the viewer is a member of, so it discloses nothing. |
| +- **No match** → the byte-identical 404 (`http.NotFound`), same bytes as an |
| + account that does not exist. The resolution scope *is* the membership |
| + set, so the invariant — a non-member cannot probe whether an account |
| + exists — holds by construction, for names as well as sqids. |
| +- **Mutations don't negotiate.** The picker renders for GET only. Forms are |
| + always generated against sqid paths (§3), so a non-GET arriving at an |
| + ambiguous segment has no legitimate author; it gets the 404. |
| + |
| +The security gate order is untouched: session → CSRF → membership/owner |
| +(byte-identical 404) → `requireFreshAuth` last. Name resolution happens |
| +inside the membership step, not before it. |
| + |
| +**One consequence, accepted and pinned**: `/a/<sqid>` for an account you are |
| +NOT a member of has always 404'd. Under membership-scoped resolution, if |
| +that same string happens to be the *name* of an account you are in, you land |
| +on your own account instead. Nothing leaks — the response is derived |
| +entirely from your own memberships — but a shared sqid link can now resolve |
| +differently for a recipient who owns that name. Mildly surprising, and |
| +strictly better than the alternative: resolving sqids outside the membership |
| +set is exactly what the 404 invariant forbids. |
| + |
| +### CLI |
| + |
| +`resolveAccount` becomes symmetric over the registry's materialized |
| +accounts: collect the sqid match and the case-insensitive name matches; if |
| +they disagree about which account is meant, fail listing every candidate |
| +and naming the fix (`-account-id`). The existing behaviors that are not |
| +precedence stay: `ops` short-circuits, a sqid-shaped string that matches |
| +nothing passes through verbatim (a fresh box may not have reconciled; CI |
| +has no registry at all), a non-sqid-shaped name that matches nothing is |
| +fatal. |
| + |
| +**`-account-id` — the reference that never guesses.** Registered alongside |
| +`-account` by `accountFlags`, so every account-scoped verb gets it. It |
| +accepts a sqid only (`release.ValidAccount` shape), never consults the |
| +registry, never matches a name. When both flags are given, `-account-id` |
| +wins — that is its purpose: the disambiguation error tells you to add it, |
| +and adding it must work without editing the rest of the command. Scripts |
| +that must never be surprised use it exclusively. |
| + |
| +## 3. URLs: links are pretty, actions are canonical |
| + |
| +No compound URL form. `/a/amadan-bah` was considered and rejected: hyphen is |
| +legal inside slugs, so the form is ambiguous grammar (an account literally |
| +named `amadan-bah` shadows it), and its only job — being a working link on |
| +the picker page — is already done by `/a/<sqid>`, which exists, is |
| +permanent, and is what the picker links to. A reserved separator |
| +(`amadan.bah`) would be unambiguous but is new grammar with no job. |
| + |
| +**Sqid URLs are permanent and canonical.** Every URL that must keep meaning |
| +the same thing forever — bookmarks that predate this change, links in |
| +invites, form actions — is a sqid URL. Nothing that works today stops |
| +working. |
| + |
| +**Name URLs are the human layer on top**, and the console prefers them |
| +where they are unambiguous *for this viewer*: |
| + |
| +- One helper — preferred segment: the account's name if it is slug-shaped |
| + and resolves uniquely within the viewer's memberships (no other |
| + membership shares the name or holds it as a sqid); otherwise the sqid. |
| +- `/` landing redirects and the account switcher use it — signing in lands |
| + on `/a/amadan/`, organically. |
| +- In-page **navigation links** use it, so the URL bar stays readable while |
| + browsing. |
| +- **Form actions and redirects-after-POST use the sqid path**, always. A |
| + mutation must name its target exactly; prettiness is for links. This is |
| + also why the picker never needs to handle a POST. |
| + |
| +A name URL can degrade to the picker later (you get invited to a colliding |
| +account) — degrade loudly, never resolve differently silently. |
| + |
| +## 4. Testing |
| + |
| +- **Validator**: table-driven — accepts `amadan`, `a`, `kass-2`; rejects |
| + empty, uppercase, `-lead`, `trail-`, 64 chars, `paul@keymail.dev`, inner |
| + spaces, unicode. |
| +- **Console resolution**: name resolves for a member; non-member gets the |
| + byte-identical 404 for a name exactly as for a sqid (bytes compared |
| + against the unknown-route 404, extending the existing test); two |
| + same-named memberships render the picker listing both sqids; name/sqid |
| + collision renders the picker; POST to an ambiguous segment 404s; sqid |
| + paths behave exactly as before throughout. |
| +- **Preferred segment**: address-named account falls back to sqid; unique |
| + slug name is chosen; collision within the viewer's set falls back to |
| + sqid; landing redirect and switcher hrefs agree with it. |
| +- **Auto-mint**: first sign-in still mints the address-named account — |
| + validation does not reach that path. |
| +- **CLI resolver**: existing cases stay green (ops, passthrough sqid, |
| + unique name, ambiguous name lists candidates); new: name/sqid conflict |
| + is fatal and lists both; `-account-id` bypasses name matching entirely |
| + and beats `-account` when both are set. |
| +- **Ladder/production pins** (`laddercheck_test.go`) untouched — nothing |
| + here goes near channels. |
| + |
| +## 5. Rollout |
| + |
| +Code-only; no data changes, no migration, no deploy ordering constraints |
| +beyond the ordinary ship → promote → adopt → restart (console is a |
| +long-running unit and needs the restart to serve name URLs). Existing |
| +sqid links keep working before, during and after. |
| + |
| +## 6. Future work (named, not built) |
| + |
| +- **Display label** (§1) — additive field, defaults to name. |
| +- **Rename** — already named owner-capability in the multi-account spec, |
| + still unimplemented; when built, the rename form applies |
| + `domains.ValidAccountName`, and renames simply change what resolves — |
| + sqid URLs never break, which is the point of keeping them canonical. |
| +- **Slug-shaped names for personal accounts** — derive `paul` from |
| + `paul@keymail.dev` at auto-mint, with a collision fallback. Deferred: it |
| + invents naming policy nobody asked for; rename covers it once built. |