# SPEC-002 — `internal/core/routing` (the pure model) | Field | Value | |---|---| | **Status** | **Implemented** (2026-10-13 — `go build` · `go vet` · `go test` green; purity confirmed via `go list -deps`) | | **Created** | 2026-10-13 | | **Author** | OpenArchitect | | **Target milestone** | **M0** (first slice — the *Stage-0* gate) | | **Governing decisions** | **D-001** · **D-004** · **D-017** · **D-018** · **D-032** · **D-039** · `PROJECT_PLAN.md` §4 | | **Related** | SPEC-001 (git) · SPEC-003 (the door) | --- ## 1. Goal Define the **pure routing model** — **no HTTP, no DB** — that resolves a request into `(module, function, params)` for **both doors** (ROUTER + HANDLER). It must be exhaustive and unit-testable **in isolation**: identity is an **injected interface**, never a concrete lookup. > This is the **Stage-0 gate** (ROUTING.md §11). Get the model right here before any server code. --- ## 2. Non-goals - The HTTP server, the door pipeline, sessions, cookies → **SPEC-003**. - Identity storage, users, roles, the chain → **SPEC-004**. - Module implementations and the registry contents → **SPEC-008**. - The `/setup`, enroll and login **flows** (the model only *classifies* them). --- ## 3. The model ### 3.1 Inputs | Input | Meaning | |---|---| | `path` | the request path | | `do`, `action` | the routing fields (may be empty) | | `Resolver` | **injected**: GUID → `(found, enrolled)` — the model never touches a DB | ### 3.2 Types (indicative contracts) ```go type Params []string // exploded path segments — empty segments PRESERVED type Kind int const ( KindRoute Kind = iota // -> (module, function, params) KindAction // -> (module, function, params) [via do/action] KindRedirect // -> Redirect (the ONE canonical redirect) KindNotFound // -> 404 KindEnroll // -> the enrollment screen (GUID found, not enrolled) KindLogin // -> the login screen (enrolled, no session — set by the door) KindSetup // -> the setup wizard (setup mode only) ) type Resolution struct { Kind Kind Workflow string // PARAM1 classification: "home" | "access" | "setup" | "api"… | a GUID | "" Module string Function string Params Params // the remainder the function receives Redirect string } ``` ### 3.3 The level-1 dispatch (D-039) ``` Dispatch(path, do, action) → Resolution if do != "" || action != "" → Handler(do, action, path) else → Router(path) ``` **Keyed on the routing fields, never the HTTP method.** ### 3.4 `Parse` - Strip the leading `/`, split on `/`, **preserve empty segments**. | Input | Output | |---|---| | `"/"` | `{}` | | `"/home"` | `{"home"}` | | `"/home/"` | `{"home", ""}` | | `"/{guid}/request/pto"` | `{guid, "request", "pto"}` | | `"/a//b"` | `{"a", "", "b"}` | **Presence rules** (the three rules, applied per level): - an **absent** param (path ended) → *the default* if optional, **404** if required; - a **trailing empty** segment (a trailing slash) → treated as **absent** → the default; - an **interior empty** segment (`//`) → a **mismatch** → **404**. ### 3.5 `Router(path)` — the RESERVED switch, then the GUID classifier ``` Router(p): if len(p) == 0 → Redirect "/home" (the ONE redirect) switch p[0]: "setup" → KindSetup "home" → the neutral landing workflow (no DB) "access" → the recovery workflow "api" | "static" | "media" | "ws" | "dev" → the reserved infra workflows default: → Resolver.GUID(p[0]): not found → NotFound found · NOT enrolled → KindEnroll found · enrolled → Module(p[1], p[2], p[3:]) ``` - Reserved cases are checked **before** the resolver — `/home` never touches identity. - `Module(…, "request", "pto")` → `(module="requests", function="pto", params={})`. - Empty `p[1]` inside a resolved GUID → the **module default** (the dashboard). ### 3.6 `Handler(do, action, path)` — the action door ``` Handler(do, action, p): m = Registry.Module(do) → not found → NotFound f = m.Action(action) → not found → NotFound → (module=m, function=f, params=p[2:]) // the GUID from the path (identity stays in the path) ``` ### 3.7 Module + function resolution - `PARAM2` = the **module** — from the registry (whitelist). Unknown or **disabled** → `NotFound`. - `PARAM3` = the **function** — the module's own whitelist, plus its **classifier** for unknown values. | Module `requests` | Function | |---|---| | `empty` | the default list | | `open` · `closed` | the filtered list | | `pto` · `equipment` | new-request forms | | *numeric* | `detail` with `params = {id}` (classifier) | | anything else | **404** | --- ## 4. Reserved names — the single source ```go // ONE constant. A GUID may never equal a reserved name; validate on create AND update. var ReservedWorkflows = []string{"home", "access", "setup", "api", "static", "media", "ws", "dev"} ``` - **`PARAM1`** is reserved against this set. - **`PARAM2`** (module names) is reserved against the **registry** (same principle, different source). - A module's **function** names are reserved within that module. --- ## 5. Test matrix (acceptance — table-driven, no HTTP, no DB) | # | Input | Expect | |---|---|---| | 1 | `/` | `KindRedirect` → `/home` | | 2 | `/home` | workflow `home`; **resolver not called** | | 3 | `/home/` | workflow `home` (trailing empty = absent → default) | | 4 | `/access` | workflow `access` | | 5 | `/setup` | `KindSetup` | | 6 | `/holy-shit` | `KindNotFound` (unknown · not reserved · not a GUID) | | 7 | `/{guid}` found+enrolled | `KindRoute`, module = default (dashboard) | | 8 | `/{guid}` not found | `KindNotFound` | | 9 | `/{guid}` found+**not** enrolled | `KindEnroll` | | 10 | `/{guid}/requests/pto` | module `requests`, function `pto` | | 11 | `/{guid}/requests/open` | module `requests`, function `open` | | 12 | `/{guid}/requests/42` | module `requests`, function `detail`, params `{42}` | | 13 | `/{guid}/requests/bogus` | `KindNotFound` | | 14 | `/{guid}/badmodule/x` | `KindNotFound` | | 15 | `/{guid}//pto` | `KindNotFound` (interior empty = mismatch) | | 16 | `/{guid}/requests` | module `requests`, function = default (list) | | 17 | `/api/v1/…` · `/static/x.css` | workflow `api` · `static` | | 18 | `do=hr&action=save-data` | module `hr`, function `save-data` | | 19 | `do=nope&action=x` | `KindNotFound` | | 20 | `do=hr&action=nope` | `KindNotFound` | | 21 | `do=hr&action=save-data` **and** `/some/path` | **Handler wins** (routing fields) | | 22 | a GUID equal to `home`/`setup`/… | **impossible** — reserved validation rejects it on create & update | **Purity assertion:** every case runs with an in-memory fake `Resolver` and a fake registry; **no HTTP import, no DB import** in the package's test graph. --- ## 6. Notes / assumptions - `web` is not yet a reserved workflow; only the set in §4 is asserted. Adding one later is a single-line change to the constant **and** a test row. - The `KindLogin` classification is **set by the door** (session check), not the pure model — the model returns `KindRoute` and the door upgrades it to `KindLogin` when no session exists. - `requests` is used as the example module in §3.7; the registry itself is **SPEC-008**. --- ## 7. Implementation notes (accepted rulings) Built 2026-10-13. `go build` · `go vet` · `go test` all green; purity independently confirmed by `go list -deps` (no `net/http`, no `database/sql` in the build **or** test graph). | # | Choice made by the builder | Ruling | |---|---|---| | **D-1** | `Dispatch`/`Router`/`Handler` are **methods on a `Dispatcher`** struct holding the injected deps (constructor `New`), plus a package-level convenience. | **Accepted** — a global-free struct is the correct shape. | | **D-2** | `Module`/`Registry` interface shapes; the **classifier is module-owned**; `IsNumeric` ships as the built-in numeric classifier. | **Accepted** — §3.7 makes the function set the module's own. | | **D-3** | Reserved workflows resolve with `Workflow = PARAM1`, `Module/Function = ""`, `Params = remainder`. | **Accepted (resolves Q-1).** The door (SPEC-003) switches on `Workflow` for reserved cases. | | **D-4** | `DefaultModule = "home"`, resolved **through the registry** (a disabled `home` → 404). | **Accepted** — matches §4.4 level-2. | | **D-5** | `Handler` sets `Resolution.Workflow` from the path's `PARAM1`. | **Accepted** — keeps identity in the path (D-032). | | **D-6** | Resolved `Params` drop a phantom trailing `""` (`Parse` still preserves empties). | **Accepted.** | | — | `ReservedWorkflows` is an exported **mutable** slice (read-only by contract). | **Noted** — treated as read-only; guarded by `IsReservedWorkflow` / `ValidateWorkflowName`. |