- 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)
9 KiB
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)
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 —
/homenever 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
// 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"}
PARAM1is 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
webis 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
KindLoginclassification is set by the door (session check), not the pure model — the model returnsKindRouteand the door upgrades it toKindLoginwhen no session exists. requestsis 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. |