Bendik Aagaard LynghaugandClaude Sonnet 5 1a88212bd9
Lint and reload / lint (push) Successful in 2s
Lint and reload / reload (push) Successful in 0s
Propose-a-change: pick the file from the repo itself
target_path becomes a select whose options are the live listing of
questions/ from Gitea's contents API (the engine's existing
select-from-resource mechanism - no code change), with a free path
field kept for proposals that create a new file. No more hand-typed,
typo-able paths for existing pages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 07:14:56 +02:00

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

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

S
Description
Question/alternative content for the portal app, kept separate so content edits never need a Rust rebuild.
Readme
228 KiB
Languages
JavaScript 100%