# 0004 — Handles at the root, application routes grouped under prefixes

**Status:** accepted · **Date:** 2026-08-04 · **Supersedes:** [0003](0003-scoped-urls.md)

## Context

[0003](0003-scoped-urls.md) scoped handles under `/user/{handle}` to eliminate the
reserved-word problem. It worked, but it paid for that with the thing Steid is
supposedly about: `/james` is a URL you put on a CV, `/user/james` is a URL an app
gives you. For a portfolio-first product the profile URL is part of the product, and
0003 traded it away for an implementation concern.

The reserved-word problem is real, though. Handles at the root share a namespace with
application routes, so `/login` and a user called `login` cannot coexist.

The insight that makes both possible: the denylist only has to grow per *route* if
routes live at the root. Group them under functional prefixes and the list grows per
*area* instead — rarely, and predictably.

## Decision

Handles live at the root. Application routes are grouped under functional prefixes, and
content below a handle is grouped by type.

```
/james                     profile
/james/repos/{name}        repository
/james/posts/{slug}        writing

/auth/login                sign in
/auth/logout
/auth/setup                first-run claim
/api/...                   JSON
```

A lean denylist in `OrgName::new` reserves the prefixes, checked against the normalised
lowercase form so `API` and `api` are the same handle:

```
about  admin  api  assets  auth  explore  help  search  settings  static
```

Adding `/auth/reset-password` costs nothing. Only a genuinely new area — `/explore` —
would add an entry, and the likely ones are reserved already.

## Alternatives considered

- **`/user/{handle}` scoping** ([0003](0003-scoped-urls.md)). Zero denylist, no
  ambiguity. Rejected for the URL it produces; the maintenance it avoided turned out to
  be small once routes were grouped.
- **A sigil for system routes** (`/-/login`, as GitLab uses). Genuinely zero denylist —
  `OrgName` already rejects handles starting or ending with a hyphen, so `-` is
  structurally unclaimable. Rejected because `/auth/login` says what it is and `/-/login`
  makes you learn a convention, and readable URLs are worth a ten-word list on a product
  where URLs are part of the presentation.
- **Root handles with a per-route denylist** (GitHub, Gitea). What grouping exists to
  avoid: the list grows every time a route is added and fails silently when someone
  forgets.

## Consequences

- **`/james` is the profile URL.** Clone URLs come out as
  `https://host/james/repos/steid.git`, comparable to GitHub's.
- **Content types cannot collide with each other**, because everything below a handle is
  grouped: `/james/repos/x` and `/james/posts/x` coexist, and a repo named `posts` is
  fine at `/james/repos/posts`.
- **A small denylist exists and must be maintained**, unlike under 0003. It is enforced
  in `OrgName::new` with tests, so it fails loudly at claim time rather than producing a
  shadowed route.
- **Reserve early.** Adding an entry later is a breaking change for whoever holds that
  handle — the account must be renamed and its links break. Reserving while unclaimed is
  free, which is why the list covers areas that do not exist yet.
- **An unknown root path is ambiguous** — `/jmaes` could be a typo'd handle or a missing
  page. Both 404, so this costs little in practice.
- **`orgs.kind` is no longer needed for routing.** 0003 required it to tell `/user/`
  from `/org/`; with one root namespace, both are just `/{handle}`. It may still be
  worth having for display, but it is not load-bearing and is dropped from Milestone 2.
- Topcoat serves its assets from `/_topcoat/`, which the character rules already exclude,
  so it needs no reservation.
