2026-08-12 22:56:54 +02:00
|
|
|
# questions
|
2026-07-29 12:51:55 +02:00
|
|
|
|
2026-08-12 22:56:54 +02:00
|
|
|
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.
|
2026-07-29 12:51:55 +02:00
|
|
|
|
2026-08-12 22:56:54 +02:00
|
|
|
## How it runs
|
2026-07-29 12:51:55 +02:00
|
|
|
|
2026-08-12 22:56:54 +02:00
|
|
|
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.
|
2026-07-29 12:51:55 +02:00
|
|
|
|
2026-08-12 22:56:54 +02:00
|
|
|
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).
|
|
|
|
|
|
2026-08-24 21:49:32 +02:00
|
|
|
## 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:
|
2026-08-25 17:04:45 +02:00
|
|
|
PORTAL_RELEASE: v0.2.3 # a release tag on uhhm/portal
|
2026-08-24 21:49:32 +02:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
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).
|
|
|
|
|
|
2026-08-12 22:56:54 +02:00
|
|
|
## 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
|
2026-08-12 23:33:53 +02:00
|
|
|
- bucket: partners # illustrative
|
2026-08-12 22:56:54 +02:00
|
|
|
initial: open
|
|
|
|
|
states:
|
2026-08-12 23:33:53 +02:00
|
|
|
open: { event: introduced }
|
2026-08-12 22:56:54 +02:00
|
|
|
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
|
2026-08-12 23:33:53 +02:00
|
|
|
- name: A bold statement to get behind
|
|
|
|
|
action: /thanks # follow-up page to advance to
|
|
|
|
|
consequence: [The button's label]
|
|
|
|
|
record_as: partners
|
2026-08-12 22:56:54 +02:00
|
|
|
features:
|
2026-08-12 23:33:53 +02:00
|
|
|
- name: A way to reach you
|
|
|
|
|
description: Say why the input is needed, then hand over the field.
|
2026-08-12 22:56:54 +02:00
|
|
|
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
|
2026-08-12 23:33:53 +02:00
|
|
|
- name: Partners
|
2026-08-12 22:56:54 +02:00
|
|
|
action: /review
|
|
|
|
|
consequence: [Confirm]
|
|
|
|
|
features:
|
|
|
|
|
- name: ""
|
|
|
|
|
resource:
|
2026-08-12 23:33:53 +02:00
|
|
|
source: { kind: kv, bucket: partners }
|
2026-08-12 22:56:54 +02:00
|
|
|
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.
|
|
|
|
|
|
2026-08-25 17:04:45 +02:00
|
|
|
5. **Change the system through itself.** `/develop/proposal` takes a
|
2026-08-13 00:23:05 +02:00
|
|
|
proposed replacement for any content file (`questions/*.yaml` or
|
|
|
|
|
`aggregates.yaml` — nothing else is in scope) — from a person, or
|
2026-08-12 22:56:54 +02:00
|
|
|
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.
|
|
|
|
|
|
2026-08-12 23:29:53 +02:00
|
|
|
## 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.
|
2026-08-25 17:04:45 +02:00
|
|
|
- **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.
|
2026-08-12 23:29:53 +02:00
|
|
|
- **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.
|
2026-08-12 23:35:02 +02:00
|
|
|
- **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
|
2026-08-25 17:04:45 +02:00
|
|
|
space; post-submission pages (nested non-index files, or `followup: true`) only join
|
2026-08-12 23:35:02 +02:00
|
|
|
the nav once the visitor holds an answer chain.
|
2026-08-12 23:29:53 +02:00
|
|
|
|
2026-08-12 22:56:54 +02:00
|
|
|
## Schema quick reference
|
|
|
|
|
|
|
|
|
|
The exact structs live in portal's `src/content.rs`; the shape:
|
|
|
|
|
|
|
|
|
|
```
|
2026-08-24 22:33:16 +02:00
|
|
|
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[]
|
2026-08-12 22:56:54 +02:00
|
|
|
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,
|
2026-08-13 13:50:29 +02:00
|
|
|
accept (file), resource + id_field (select options),
|
|
|
|
|
bind {field, param, resource} (load this field's
|
|
|
|
|
value from a resource whenever the named sibling
|
2026-08-24 21:24:05 +02:00
|
|
|
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),
|
2026-08-12 22:56:54 +02:00
|
|
|
key, public, requires_group, transitions[{from, to, label}],
|
|
|
|
|
jq (reshape filter)
|
2026-07-29 12:51:55 +02:00
|
|
|
```
|
|
|
|
|
|
2026-08-12 22:56:54 +02:00
|
|
|
Requirement `type`: `text` (default), `textarea`, `email`, `tel`,
|
2026-08-24 21:24:05 +02:00
|
|
|
`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`
|
2026-08-25 17:28:45 +02:00
|
|
|
(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/<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`.
|
2026-07-29 12:51:55 +02:00
|
|
|
|
2026-08-12 22:56:54 +02:00
|
|
|
`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).
|
2026-07-29 12:51:55 +02:00
|
|
|
|
2026-08-25 15:38:08 +02:00
|
|
|
**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.
|
|
|
|
|
|
2026-08-24 22:33:16 +02:00
|
|
|
## 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:` 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/<value>`,
|
|
|
|
|
with the segment substituted into `{name}` placeholders in the
|
|
|
|
|
page's resource `key`s (`/review/<chain-hash>` shows that one
|
|
|
|
|
record). One per directory; never in the nav; not a valid `action`
|
|
|
|
|
target.
|
|
|
|
|
- `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.
|
|
|
|
|
|
2026-08-12 22:56:54 +02:00
|
|
|
## Adding a page
|
2026-07-29 12:51:55 +02:00
|
|
|
|
2026-08-24 22:33:16 +02:00
|
|
|
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.
|