# 0003 — Scope every URL under an explicit prefix

**Status:** superseded by [0004](0004-root-handles-grouped-routes.md) · **Date:** 2026-08-04

> **Superseded the same day.** The scoping principle held, but applying it to the
> handle itself cost the short profile URL — which is part of the product for a
> portfolio-first tool. [0004](0004-root-handles-grouped-routes.md) keeps handles at the
> root and groups the *application's* routes instead, which turns out to shrink the
> denylist to a rarely-changing ten words rather than eliminating it. Kept for the
> reasoning, which still applies below the handle.

## Context

Milestone 2 introduces the profile page, which fixes the shape of every URL after it.
Forges conventionally put handles at the root — `/james`, `/james/my-repo` — following
GitHub, GitLab, and Gitea.

Root-level handles share one namespace with the application's own routes. `/setup`,
`/login`, `/logout`, and `/api` already exist, and nothing stops a user claiming the
handle `login`. That forces a reserved-word denylist which must grow every time a
top-level route is added, and forgetting once means a profile shadows a real route or
the reverse. It also makes a 404 ambiguous: unknown handle and unknown page are
indistinguishable.

## Decision

Scope every URL under an explicit prefix. Handles are never at the root.

```
/user/{handle}                    personal profile
/user/{handle}/repos/{name}       repository
/user/{handle}/posts/{slug}       writing
/org/{handle}                     shared organisation   (Milestone 7)

/login  /logout  /setup  /api/... application routes, free to grow
```

Every segment says what the next one means, at every level. This is a general
preference, not a one-off: prefer an explicit scope over an implicit one wherever the
choice arises.

Two prefixes rather than one because in Steid every handle is an `Organization` — a
user's handle is their personal org's name. `/user/acme-corp` would be wrong once real
organisations exist. Distinguishing them requires a `kind` discriminator on `orgs`
(`personal` | `shared`), which is better modelling regardless: the two differ in
whether they belong to exactly one user.

## Alternatives considered

- **Root handles plus a denylist** (GitHub, Gitea). Shortest URLs and the familiar
  shape. Rejected: the denylist is unbounded maintenance, and the failure mode is a
  silent collision rather than an error.
- **Root handles plus a sigil for system routes** (`/-/login`, as GitLab moved to).
  One reserved prefix instead of a growing list, and keeps `/james` clean. Rejected as
  less consistent — it scopes the application's routes but leaves content unscoped, so
  `/james/my-repo` and `/james/my-post` still share a namespace and can collide with
  each other.
- **A single neutral prefix for all handles** (`/o/{handle}`). Avoids needing a `kind`
  discriminator. Rejected: opaque to read, and the discriminator is worth having on its
  own merits.

## Consequences

- **No reserved words.** Any top-level route can be added forever without checking a
  list, and handle validation stays purely about format.
- **404s are unambiguous.** An unknown handle under `/user/` is a missing user; an
  unknown top-level path is a missing page.
- **Content types can't collide with each other.** `/user/james/repos/x` and
  `/user/james/posts/x` coexist. Root-level schemes have to arbitrate.
- **URLs are longer, and that costs something real.** `/user/james` is an app URL where
  `/james` is a CV URL, and Steid is portfolio-first, so this is a genuine trade against
  the product's own framing. Clone URLs inherit it:
  `git clone https://host/user/james/repos/steid.git`.
- **`orgs` needs a `kind` column** before Milestone 7. Adding it at Milestone 2, while
  there is one row, is free.
- Expensive to reverse once URLs are public — which is why it is being decided before
  the first real page rather than after.
