# 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   dashboard  docs    explore  help   legal
notifications    privacy  search  security  settings  static  status  steid  support  terms
```

Adding `/auth/reset-password` costs nothing. Only a genuinely new *area* adds an entry.

`steid` is reserved so nobody can hold the project's own name and impersonate the
software. The legal cluster (`privacy`, `terms`, `legal`, `security`) is there because a
publicly hosted instance conventionally wants those at the root.

**The test for adding one:** could this ever be a top-level route? Under grouping,
almost nothing is — a health check is `/api/health`, sign-up is `/auth/register`. Words
that would live under a prefix do not belong on the list.

## 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. The same holds for `/.well-known/` (federation,
  `security.txt`), `robots.txt`, `favicon.ico` and `sitemap.xml` — every conventional
  root path that contains a `.` or `_` is unreachable as a handle for free.

## How this ages — prior art

GitHub's namespace is flat, and it pays for it: community mirrors of its reserved list
run to [590+ names](https://github.com/shouldbee/reserved-usernames), with no official
list published. You discover a name is taken when signup rejects it.

GitLab is the more useful comparison, because they hit it hard enough to build
machinery. Their reserved names live in `namespace_validator.rb` and
`project_path_validator.rb`, and their docs state plainly that as new functionality is
introduced, more restrictions will be added — the list is expected to grow forever. Two
details bear on us:

- **Their reservation is depth-dependent.** A group named `api` is rejected at the top
  level but allowed nested under another namespace, where nothing collides. That is
  exactly the property grouping gives us, and it is what our substring and nesting tests
  pin down.
- **They built `RenameReservedPathsMigration`** — tooling to forcibly rename users who
  already hold a newly-reserved path. That is the escape hatch for the breaking change
  described above, and the reason their list can keep growing.

The cost runs both ways: GitLab has a long-standing open issue,
[*Rename GitLab-reserved paths that our users want to use*](https://gitlab.com/gitlab-org/gitlab/-/issues/16854),
trying to give paths back. Over-reserving is not free either.

**Deliberately not built yet:** a rename path. With a single user who is also the
operator, adding a reserved word means renaming yourself. It becomes worth having around
Milestone 7, when multi-user registration means handles stop being ours to reassign
freely — but building it now would be machinery for a problem that does not exist.
