# questions Content for the [`portal`](https://project.uhhm.no/uhhm/portal) runtime at [uhhm.no](https://uhhm.no) — every page, form, review desk, and state machine on the site is declared in this repo's YAML, kept apart from the Rust so a content change never needs a rebuild. The intent: the person asking a question is only responsible for *asking it well* — declaring the context it's valid in, the alternatives a visitor can choose, what each alternative needs to be answerable, and where an answer's story goes next. The runtime handles everything else. ## How it runs Portal loads every `questions/*.yaml` file (one page each) plus `aggregates.yaml` (the state graphs) from this repo over Gitea's contents API — at boot, and again on every push here: this repo's CI lints the content with portal's own `question_lint` binary, then publishes a NATS message that makes every running portal instance re-fetch and atomically swap the new content in. Bad content never replaces good: a failed lint stops the push's reload, and a running instance keeps serving its last-good content even if a reload slips past. What the runtime provides underneath: - **Portal** (Rust/Leptos) — renders pages, takes submissions, enforces the state graphs. - **NATS** — every submission and decision publishes to `portal.answers.submitted`; JetStream holds the durable per-record event log and the KV buckets pages read. - **Gitea** — hosts this repo, serves the content API, runs the lint CI, and gates the proposal loop's merges (branch protection on `main` requires the lint check). - **Kanidm** — identity; a page or resource naming a group (`qualifies`, `requires_group`) is gated to signed-in members of it. - **n8n** — automations subscribed to the NATS subject act on specific decisions (send the newsletter, onboard an invited applicant, commit an approved development proposal). ## The optimal usecase, in order A full concern — say, tracking a new kind of actor — is stood up entirely in this repo, in this order: 1. **Declare the state graph** (`aggregates.yaml`). Name a bucket, its states, the event each state is entered by, and the legal transitions: ```yaml - bucket: partners # illustrative initial: open states: open: { event: introduced } in_dialogue: { event: entered_dialogue } committed: { event: committed } declined: { event: declined } transitions: open: [in_dialogue, declined] in_dialogue: [committed, declined] ``` This graph is the authority: the runtime refuses any transition the graph doesn't declare, no matter who asks. A bucket with no entry here still works as plain storage — declare a graph when the records have a lifecycle worth enforcing. 2. **Give it a way in** — a submission alternative on some page, with `record_as` naming the bucket: ```yaml - name: A bold statement to get behind action: /thanks # follow-up page to advance to consequence: [The button's label] record_as: partners features: - name: A way to reach you description: Say why the input is needed, then hand over the field. requirements: - name: email label: Email type: email ``` Each submission becomes a record in `open` (the graph's `initial`), hashed into the visitor's answer chain, logged as the record's first event, and published on NATS. 3. **Give it a way through** — a review alternative reading the bucket back, offering the graph's transitions as buttons. `from` scopes a button to rows actually in that state; one shared button confirms every selection at once: ```yaml - name: Partners action: /review consequence: [Confirm] features: - name: "" resource: source: { kind: kv, bucket: partners } requires_group: owners transitions: - { to: in_dialogue, label: Open dialogue } - { to: declined, label: Decline } - { from: in_dialogue, to: committed, label: Commit } ``` 4. **Let automations react** (optional). Every decision publishes `question_id` + the transition's `label` on NATS — an n8n workflow gates on those two strings and does the rest (see the infrastructure repo's `n8n-workflows/`). Rename a page or a label and its workflow must be updated in lockstep. 5. **Change the system through itself.** `/develop-proposal` takes a proposed replacement for any content file (`questions/*.yaml` or `aggregates.yaml` — nothing else is in scope) — from a person, or eventually a locally-run model, through the same public form. An owner approves it on `/develop`; approval triggers the automation that branches, commits, opens a PR, and merges only when the same lint that gates every human push passes. Nothing merges on autopilot. ## Voice The rules every page here follows: - **The title is the only question.** A page asks one thing — its `name`. Nothing below it asks anything. - **Alternatives are bold statements**, argued against each other — positions we'd like a visitor to get behind, in our voice, never the visitor's presumed voice and never a question. - **Explain why, then hand over the field.** Where input is needed, the feature's name and description state the reason ("the reply is personal — it needs an address"), and labels are nouns, not questions. - **Don't bucket people into roles.** We join the visitor's narrative in the simplest way: statements they can get behind and information that gives them an answer — not segments to self-select into. The question nav (every page's footer) surfaces all questions the current visitor qualifies for, so nothing depends on front-page space; post-submission pages carry `followup: true` and only join the nav once the visitor holds an answer chain. ## Schema quick reference The exact structs live in portal's `src/content.rs`; the shape: ``` Question id (doubles as the URL path), name, description, qualifies (Kanidm group gate), followup (nav-hidden until the visitor carries a chain), responsible {name, contact}, alternatives[] Alternative name, description, action, consequence[label], encouragements[], images[] (1 = banner, 2+ = card deck), record_as (bucket), self_transition {bucket, to, label}, features[] Feature name, description, color (CSS accent), icon (Iconify name, e.g. lucide:star), requirements[], resource Requirement name, label, type, optional, multiple, placeholder, accept (file), resource + id_field (select options), bind {field, param, resource} (load this field's value from a resource whenever the named sibling changes - {param} templates into a url source's path), relay (gesture only - ws(s):// redoal-relay URL) ResourceSpec source (kv | gitea_starred | gitea_org_repos | gitea_releases | url), key, public, requires_group, transitions[{from, to, label}], jq (reshape filter) ``` Requirement `type`: `text` (default), `textarea`, `email`, `tel`, `select`, `file`, `prosekit` (rich text), `gesture` (draw a stroke on a canvas; submits `{points, key}` - with `relay` set it announces the stroke to a redoal-relay and shows echoes of similar strokes; mark it `optional: true`, hidden inputs skip HTML required-validation), or any HTML input type. A repo may also carry an optional `site.yaml` at its root (sibling of `aggregates.yaml`) declaring instance branding: `title`, `wordmark` (image URL), and `hero {kind: yes | gesture | plain, relay}` for the landing page. Absent file = the historical uhhm look (YES canvas, /wordmark.svg). `self_transition` is the anonymous, single-record counterpart to a review resource's transitions — fireable by whoever holds one specific record's `?chain=` link plus its matching email (the unsubscribe pattern). ## Adding a page Drop a `questions/.yaml` with a unique `id`, reference that `id` from some alternative's `action`, push. Lint runs, portal hot-reloads, the page is live — no registration, no deploy. Or propose it through `/develop-proposal` and let the loop do the pushing.