NextMyDay/docs/specs/spec-002-routing-model.md
Claus Lohmar 57610cdbae docs: add the project plan, M0/M0.1 specs, agent guide and README
- PROJECT_PLAN.md: the decision log of record through D-042 and the M0.1 slice
- docs/specs: SPEC-002…009 — routing · door · identity/sessions · /setup ·
  enroll/login · roles/chain/recovery · module registry · the web layer & PWA
  (plus docs/specs/README.md, the index)
- AGENT.md: the working rules — commit AND push at every submilestone (gate
  green), Conventional Commits, never commit secrets
- README.md: the quickstart, the first-run/SSH-tunnel steps and the
  localhost/loopback caveat (SPEC-009 §13 Q-3): with an https base URL the
  CSRF/session cookies are Secure, so reach the app at localhost/127.0.0.1
- config.example.yaml: the settings template (D-035)
2026-10-14 10:53:44 +00:00

210 lines
9 KiB
Markdown

# 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`. |