# Filesystem routes, sections, and where chains fit Status: implemented in v0.2.0 (2026-08-24) — all three phases, with one deviation: dynamic-page params substitute into resource keys via server-side `resolve_question` at lookup time (get_question, find_feature, submit_answer all resolve concrete paths), so no param threading exists client-side. The user-facing routing contract is documented in uhhm/questions' README ("Routing: the tree is the router"); this file stays as the design rationale. ## What exists today, precisely - A question's `id` doubles as its URL. Filenames are meaningless: `questions/` is loaded as a **flat** directory (both the Gitea loader and `question_lint --path`), every `*.yaml` becomes a `Question` keyed by its own declared `id`. - Hierarchy exists only as strings: `action: /proposed` edges, plus a manual `followup: true` flag that hides post-submission pages from the nav. The graph is invisible in a repo listing — you open every file to learn the flow. (uhhm's actual graph: `/` fans out to three followups; `/develop` → `/develop-proposal` → `/proposed` is a two-step flow flattened into three top-level files.) - Criteria are per-file: `qualifies: ` on a question, `requires_group` on a resource. Gating a whole area means repeating the flag in every file of that area. - Chains are query-string lineage: submitting hashes `(question, parents, responses, ts)` into `chain_hash`, the next page is `action + ?chain=`, and holding a chain is itself a capability (`self_transition` + email second factor). `chain.rs` reserves DAG shape (multiple parents) but nothing produces it yet. - `Question.route` is a declared-but-never-read field — a fossil of this exact idea. ## The proposal, in three phases ### Phase 1 — the tree is the router Directory structure becomes the URL structure, next-js style: ``` questions/ index.yaml → / applied.yaml → /applied develop/ index.yaml → /develop proposal.yaml → /develop/proposal proposed.yaml → /develop/proposed review/ index.yaml → /review ``` - `id:` becomes optional and **derived from the path** (`index.yaml` names its directory). An explicit `id:` still wins so both content repos keep working unchanged; lint warns when it disagrees with the path, and the field can retire later along with `route`. - `action:` accepts **relative references**: `action: proposed` resolves against the file's directory, `action: /subscribed` stays absolute. Resolution happens at load time, so validation and the runtime see absolute ids exactly as today. A flow directory becomes self-contained: rename `develop/` and every internal edge moves with it. - **`followup:` is inferred**: `index.yaml` files are nav pages, non-index files are followups unless they say `nav: true`. This matches the real content exactly (uhhm's four `followup: true` files are precisely its non-index leaf pages) and deletes a flag people must remember. - Loader: switch from the per-directory contents API to Gitea's git trees API (`/git/trees/{branch}?recursive=true`) — one request for the whole tree instead of one per directory, which the flat loader should be using anyway. ### Phase 2 — sections: criteria scoped by URL prefix A `_section.yaml` in any directory applies to everything beneath it (underscore = not a page, like next's private folders): ``` questions/review/_section.yaml: qualifies: portal_owners responsible: { name: Bendik, contact: … } ``` This is the URL/criteria interplay actually worth having: **a URL prefix becomes a trust boundary**. "Everything under /review is owner-only" is one line in one place, instead of a flag per file that drifts. Per-question `qualifies` still overrides (tighter or looser — lint should warn on looser). Sections are also the natural home for shared `responsible` contacts and, later, per-section branding accents. ### Phase 3 — dynamic segments, and chains stay out of the path Two criteria axes exist: **who you are** (Kanidm group) and **what you've done** (holding a chain). Phase 2 scopes the first by prefix; phase 3 does the same for the second, plus gives records addresses: - `[record].yaml` — a dynamic segment, one YAML file rendered per record: `questions/review/[record].yaml` serves `/review/`, with the param available to the page's `ResourceSpec.key`. Today a single record is only reachable through a desk row or a `?chain=` link; this gives every record a real, gated URL — linkable from review desks, automations, and n8n notifications. - `requires_chain: ` (page- or section-level): the page only renders for a visitor whose chain tip answers the referenced question. Today's followup pages are soft-hidden (out of nav) but fully reachable; this makes "you must have come from X" an actual criterion, declared with a relative ref like actions are. **Deliberately not proposed: encoding the chain in the path.** The page tree is static structure; the chain is runtime lineage and a bearer capability. Putting it in the path makes it look canonical and shareable — exactly what a capability URL shouldn't invite — and a DAG (the reserved multi-parent shape) doesn't linearize into a path anyway. `?chain=` stays a query parameter: pages keep one canonical URL, lineage rides along only when it's actually held. ## Migration Phase 1 is fully backward compatible (explicit `id` wins, flat repos are just trees of depth one). The content repos migrate by `git mv`: uhhm's develop flow nests under `develop/`, its followups either stay top-level (`/applied` keeps its URL) or move with their flows if URL churn is acceptable; redoal's three files are already the tree. Lint learns the same resolution rules in the same commit, so a bad reference stays a caught push, never a 404. ## Order of value Phase 1 is cheap and pays immediately (the repo listing becomes the sitemap). Phase 2 is small and unlocks gated areas properly. Phase 3 is the real feature work — `[record]` pages change what desks and automations can link to — and can wait until something concrete needs it.