- 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)
210 lines
9 KiB
Markdown
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`. |
|