carlos / platform Public

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
Paul Campbell 2ef101e2d893f6409a2547e81a2c83fa06d09fd4 parent 3b8e323
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.