Files
questions/README.md
T
Bendik Aagaard LynghaugandClaude Fable 5 77ff58e524
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 1s
Docs: this repo runs the uhhm.no instance (PORTAL_RELEASE pin)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 21:49:32 +02:00

9.2 KiB

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 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:

env:
  PORTAL_RELEASE: v0.1.0   # 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:

    - 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:

    - 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:

    - 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/<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.