proposed_yaml binds to the file select: the selected file's content fetches through Gitea's contents API (base64-decoded by the jq filter) and lands in the editor. On-site contributions no longer start from a blank box. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
questions
Content for the portal runtime
at 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
mainrequires 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:
-
Declare the state graph (
aggregates.yaml). Name a bucket, its states, the event each state is entered by, and the legal transitions:- 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.
-
Give it a way in — a submission alternative on some page, with
record_asnaming the bucket:- 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: emailEach submission becomes a record in
open(the graph'sinitial), hashed into the visitor's answer chain, logged as the record's first event, and published on NATS. -
Give it a way through — a review alternative reading the bucket back, offering the graph's transitions as buttons.
fromscopes a button to rows actually in that state; one shared button confirms every selection at once:- 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 } -
Let automations react (optional). Every decision publishes
question_id+ the transition'slabelon NATS — an n8n workflow gates on those two strings and does the rest (see the infrastructure repo'sn8n-workflows/). Rename a page or a label and its workflow must be updated in lockstep. -
Change the system through itself.
/develop-proposaltakes a proposed replacement for any content file (questions/*.yamloraggregates.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: trueand 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)
ResourceSpec source (kv | gitea_starred | gitea_org_repos | url),
key, public, requires_group, transitions[{from, to, label}],
jq (reshape filter)
Requirement type: text (default), textarea, email, tel,
select, file, prosekit (rich text), or any HTML input type.
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/<name>.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.