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

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 — /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

// 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.