Files
portal/docs/design/filesystem-routes.md
T
2026-08-24 21:58:40 +02:00

5.7 KiB

Filesystem routes, sections, and where chains fit

Status: proposal (researched 2026-08-24, nothing implemented).

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: <kanidm-group> 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=<hash>, 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/<key>, 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: <question-ref> (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.