From 251fe22c252585db2e16766232968700e327d4b4 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Wed, 12 Aug 2026 22:56:54 +0200 Subject: [PATCH] README: the runtime's dependencies and the optimal usecase, in order Replaces the long-stale schema notes (pre-aggregates, pre-resources, pre-proposal-loop) with what the repo actually is now: declare a state graph, give it a way in (submission), a way through (review transitions), let automations react, and change the system through itself. Co-Authored-By: Claude Sonnet 5 --- README.md | 187 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 147 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 449cffd..e6ce6b6 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,156 @@ -# portal-content +# questions -Question/alternative/feature content for the [`portal`](../portal) app, -kept in its own repo so content edits don't require a Rust rebuild or -touch app code at all. +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. -## Schema +## How it runs -Each file in `questions/` is one `Question` (see `portal`'s -`src/content.rs` for the exact struct). A question has one or more -`alternatives`; each alternative is a small form made of `features`, -each holding zero or more `requirements` (input fields). +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. -```yaml -id: /some-path # matches a route; "/" is the landing page; English slugs -name: Headline -description: > - Markdown body text. -alternatives: - - name: Alternative label - description: One line explaining this path. - action: /next-question # id of the question to advance to on submit - consequence: [Button label] - features: - - name: A feature/section heading - description: Optional supporting text. - requirements: - - name: email - label: Email - type: email +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: + + ```yaml + - bucket: investors + initial: open + states: + open: { event: expressed_interest } + 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 + - name: I want to invest + action: /invested # thank-you page to advance to + consequence: [Open the conversation] # the submit button's label + record_as: investors + features: + - name: Who are you? + 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 + - name: Investors + action: /review + consequence: [Confirm] + features: + - name: "" + resource: + source: { kind: kv, bucket: investors } + 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 file in this repo — 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. + +## 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), 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) ``` -`type` on a requirement is one of: `text`, `textarea`, `email`, `tel`, -`select`. Omit for plain text. Mark a requirement `optional: true` if -it isn't required. +Requirement `type`: `text` (default), `textarea`, `email`, `tel`, +`select`, `file`, `prosekit` (rich text), or any HTML input type. -There is deliberately no `color`/`icon` styling here — the app has a -single brand accent variable, not per-alternative colors. And there's -no branching/criteria language yet: today a human reads submissions off -NATS and decides what happens next. If that grows into something an LLM -posts follow-up questions into later, it publishes into the same shape -these files already are — this repo's format doesn't need to change for -that, just its source. +`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 question +## Adding a page -Drop a new `questions/.yaml` file and reference its `id` from an -existing alternative's `action`. No registration step - the app loads -every file in the directory at startup. +Drop a `questions/.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.