Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y42TyF8Zu7NGRR2893vNcZ
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).
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:
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:
-
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 (thequestions/tree,aggregates.yaml, orsite.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 isdisabled: truewhile 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), event {starts, duration, place}
(announced page - see below), 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),
value (preset; on a dynamic page {name} takes the
URL segment - value: "{key}" hands a voice field
its address), relay (gesture/voice - 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, voice, or any
HTML input type.
gesturedraws a stroke on a canvas and submits{points, key}. Withrelayset it announces the stroke to a redoal-relay: the relay answers with the key and its decode (drawn under the stroke), with who is at a similar shape right now, and with places - keys that hold recordings. Picking a place makes its key the field's: the value becomes{points, key, own_key, selected_from, selected_distance}. Mark itoptional: true(hidden inputs skip HTML required-validation).voicerecords in the browser and sends the audio to the relay, which keeps it at the field'svaluekey (value: "{key}"on a/shape/[key]page). The value becomes{key, digest, duration_ms}. Needsrelay;optional: truefor the same reason.
A [name].yaml page's segment also substitutes into a url resource
source (https://relay.redoal.com/place/{key}), and a resource item
with an https audio field renders an <audio> player.
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:
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/<path> 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).
Descriptions are inline markdown (alternative and feature):
[text](https://…) links, *emphasis*, **strong**, `code`.
One paragraph - block structure flattens; raw HTML is dropped; link
targets may be https, mailto or a site-relative path, anything else
renders as plain text.
Announced pages. A question carrying an event: block
event:
starts: 2026-09-12T18:00:00+02:00 # RFC 3339, with offset
duration: 3h # m / h / d, e.g. "1d 6h"
place: Galleri X, Oslo # optional, shown verbatim
is announced in the header of every page (name, when, "in 3 days")
while its window is open, instead of listed in the footer nav. The
page itself is an ordinary question - RSVP, directions, whatever its
alternatives say. When the window closes the page turns into a
followup (only visitors carrying an answer chain still see it) and
portal moves the page's record in the runtime-owned portal_events
bucket from announced to awaiting_summary, publishing that on
portal.answers.submitted like any decision (alternative "Summary
due") - the "post what happened" task. /review's "Announced pages"
desk reads that bucket; Posted what happened closes the task.
Lint warns when pages carry event: but nothing reads
portal_events.
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: <who/what consumes it> 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/<any value>
id:is derived from the path; declaring it still wins (legacy), with a lint warning when it disagrees.action:andrequires_chain:take relative refs —proposednames a sibling,../xclimbs,/xis absolute. A directory is a self-contained flow:git mvrenames every internal edge with it.- Files nested in a subdirectory infer
followup: trueunless they're the directory'sindex.yaml— declarefollowup: falseon a nested page that should stay in the nav. Top-level files keep the flat-repo default (not a followup). _section.yamlappliesqualifies,requires_chain, andresponsibleto 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].yamlis a dynamic page: it serves every/dir/<value>, with the segment substituted into{name}placeholders in the page's resourcekeys (/review/<chain-hash>shows that one record). One per directory; never in the nav; not a validactiontarget.requires_chain: <ref>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.