# 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). ## This repo runs the instance Besides the content, this repo owns the uhhm.no portal instance itself. `.gitea/workflows/deploy.yml` pins the portal version: ```yaml env: PORTAL_RELEASE: v0.2.3 # a release tag on uhhm/portal ``` Upgrading (or downgrading) portal is bumping that line and pushing — the workflow downloads the pinned release artifact, ships it, rewrites the instance env from this repo's Actions variables/secrets, restarts `app@uhhm-portal`, and refreshes the Caddy route. Nothing else deploys this site; a push to the portal repo only publishes a new version for repos like this one to opt into. Day to day you rarely touch it: content edits (any `*.yaml` here) hot-reload without a deploy, and the deploy workflow only triggers on changes to itself (or a manual run from the Actions tab). ## 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 (the `questions/` tree, `aggregates.yaml`, or `site.yaml` — nothing else is in scope) — from a person, or eventually a locally-run model, through the same public form. The submission itself becomes a branch and a draft pull request at once (n8n "Portal: proposal received"), so lint reports on it immediately and the submitter gets one acknowledgement mail if they left an address. An owner approves it on `/develop`; approval un-drafts the PR and merges it once the same lint that gates every human push passes ("Portal: commit approved development proposal"). Nothing merges on autopilot. The alternative is `disabled: true` while this loop is being finished — announced, not yet open. ## 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 draw people in.** Each one suggests a real path a visitor could take — a position to get behind or a thing to do next, in our voice, never the visitor's presumed voice and never a question. A category label ("Our Composition") is not an alternative; "Follow the build as it lands" is. - **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 (nested non-index files, or `followup: true`) 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 (derived from the file's path - see routing below - declare only to override), name, description, qualifies (Kanidm group gate), requires_chain (question ref the visitor's ?chain= lineage must end at), followup (nav-hidden until the visitor carries a chain; inferred from the tree when unset), responsible {name, contact}, alternatives[] Alternative name, description, action, disabled (announced, not yet takeable), 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), empty (text when the resource yields nothing; default "Nothing here yet.") ``` 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 the landing page's `hero`: ```yaml hero: kind: module # plain (default) | module module: hero.js # repo-relative path, served by portal at /site/hero.js ``` A `module` hero is a JavaScript module this repo ships, exporting `mount(container) -> handle` where the handle has `stop()`. Portal serves it same-origin (Gitea's raw endpoint sends no CORS headers, so `/site/` proxies any plain-segment path from the repo), starts it at HTML parse time, adopts it on hydration, and stops it on navigation. Everything visual is the module's - it builds its own DOM inside `.hero-piece` and may inject its own stylesheet, including rules for the `.hero-module` header itself. uhhm's `hero.js` is the YES canvas piece; redoal's is the sine-swings band. Absent file = plain hero, portal's `SITE_NAME`, `/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). **Attended buckets** (lint-enforced): every `record_as` bucket must be read by some `kv` resource in this repo (a desk or listing) — or its `aggregates.yaml` entry must carry `attended_by: ` naming the automation that does. A bucket nothing reads fails lint, so no publicly collected answer can land where no one will ever see it. ## Routing: the tree is the router The `questions/` directory tree is the URL tree — `index.yaml` names its directory, everything else appends its stem: ``` questions/ index.yaml / applied.yaml /applied develop/ index.yaml /develop proposal.yaml /develop/proposal proposed.yaml /develop/proposed review/ _section.yaml (not a page - defaults for the directory) index.yaml /review [record].yaml /review/ ``` - `id:` is derived from the path; declaring it still wins (legacy), with a lint warning when it disagrees. - `action:` and `requires_chain:` take relative refs — `proposed` names a sibling, `../x` climbs, `/x` is absolute. A directory is a self-contained flow: `git mv` renames every internal edge with it. - Files nested in a subdirectory infer `followup: true` unless they're the directory's `index.yaml` — declare `followup: false` on a nested page that should stay in the nav. Top-level files keep the flat-repo default (not a followup). - `_section.yaml` applies `qualifies`, `requires_chain`, and `responsible` to every page at or below its directory (nearest ancestor wins; a page's own declaration always overrides). A URL prefix is a trust boundary. - Any other `_`-prefixed file is skipped entirely — drafts live in the tree without being served. - `[name].yaml` is a dynamic page: it serves every `/dir/`, with the segment substituted into `{name}` placeholders in the page's resource `key`s (`/review/` shows that one record). One per directory; never in the nav; not a valid `action` target. - `requires_chain: ` gates a page on provenance instead of identity: the visitor's `?chain=` lineage must verifiably end at an answer to the referenced question, otherwise the page renders a pointer there instead of its alternatives. ## Adding a page Drop a YAML file where its URL should live (`questions/foo.yaml` → `/foo`, `questions/flow/step.yaml` → `/flow/step`), reference it 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.