| 1 | # 0003 — Scope every URL under an explicit prefix |
| 2 | |
| 3 | **Status:** superseded by [0004](0004-root-handles-grouped-routes.md) · **Date:** 2026-08-04 |
| 4 | |
| 5 | > **Superseded the same day.** The scoping principle held, but applying it to the |
| 6 | > handle itself cost the short profile URL — which is part of the product for a |
| 7 | > portfolio-first tool. [0004](0004-root-handles-grouped-routes.md) keeps handles at the |
| 8 | > root and groups the *application's* routes instead, which turns out to shrink the |
| 9 | > denylist to a rarely-changing ten words rather than eliminating it. Kept for the |
| 10 | > reasoning, which still applies below the handle. |
| 11 | |
| 12 | ## Context |
| 13 | |
| 14 | Milestone 2 introduces the profile page, which fixes the shape of every URL after it. |
| 15 | Forges conventionally put handles at the root — `/james`, `/james/my-repo` — following |
| 16 | GitHub, GitLab, and Gitea. |
| 17 | |
| 18 | Root-level handles share one namespace with the application's own routes. `/setup`, |
| 19 | `/login`, `/logout`, and `/api` already exist, and nothing stops a user claiming the |
| 20 | handle `login`. That forces a reserved-word denylist which must grow every time a |
| 21 | top-level route is added, and forgetting once means a profile shadows a real route or |
| 22 | the reverse. It also makes a 404 ambiguous: unknown handle and unknown page are |
| 23 | indistinguishable. |
| 24 | |
| 25 | ## Decision |
| 26 | |
| 27 | Scope every URL under an explicit prefix. Handles are never at the root. |
| 28 | |
| 29 | ``` |
| 30 | /user/{handle} personal profile |
| 31 | /user/{handle}/repos/{name} repository |
| 32 | /user/{handle}/posts/{slug} writing |
| 33 | /org/{handle} shared organisation (Milestone 7) |
| 34 | |
| 35 | /login /logout /setup /api/... application routes, free to grow |
| 36 | ``` |
| 37 | |
| 38 | Every segment says what the next one means, at every level. This is a general |
| 39 | preference, not a one-off: prefer an explicit scope over an implicit one wherever the |
| 40 | choice arises. |
| 41 | |
| 42 | Two prefixes rather than one because in Steid every handle is an `Organization` — a |
| 43 | user's handle is their personal org's name. `/user/acme-corp` would be wrong once real |
| 44 | organisations exist. Distinguishing them requires a `kind` discriminator on `orgs` |
| 45 | (`personal` | `shared`), which is better modelling regardless: the two differ in |
| 46 | whether they belong to exactly one user. |
| 47 | |
| 48 | ## Alternatives considered |
| 49 | |
| 50 | - **Root handles plus a denylist** (GitHub, Gitea). Shortest URLs and the familiar |
| 51 | shape. Rejected: the denylist is unbounded maintenance, and the failure mode is a |
| 52 | silent collision rather than an error. |
| 53 | - **Root handles plus a sigil for system routes** (`/-/login`, as GitLab moved to). |
| 54 | One reserved prefix instead of a growing list, and keeps `/james` clean. Rejected as |
| 55 | less consistent — it scopes the application's routes but leaves content unscoped, so |
| 56 | `/james/my-repo` and `/james/my-post` still share a namespace and can collide with |
| 57 | each other. |
| 58 | - **A single neutral prefix for all handles** (`/o/{handle}`). Avoids needing a `kind` |
| 59 | discriminator. Rejected: opaque to read, and the discriminator is worth having on its |
| 60 | own merits. |
| 61 | |
| 62 | ## Consequences |
| 63 | |
| 64 | - **No reserved words.** Any top-level route can be added forever without checking a |
| 65 | list, and handle validation stays purely about format. |
| 66 | - **404s are unambiguous.** An unknown handle under `/user/` is a missing user; an |
| 67 | unknown top-level path is a missing page. |
| 68 | - **Content types can't collide with each other.** `/user/james/repos/x` and |
| 69 | `/user/james/posts/x` coexist. Root-level schemes have to arbitrate. |
| 70 | - **URLs are longer, and that costs something real.** `/user/james` is an app URL where |
| 71 | `/james` is a CV URL, and Steid is portfolio-first, so this is a genuine trade against |
| 72 | the product's own framing. Clone URLs inherit it: |
| 73 | `git clone https://host/user/james/repos/steid.git`. |
| 74 | - **`orgs` needs a `kind` column** before Milestone 7. Adding it at Milestone 2, while |
| 75 | there is one row, is free. |
| 76 | - Expensive to reverse once URLs are public — which is why it is being decided before |
| 77 | the first real page rather than after. |