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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c488932695
commit
251fe22c25
@@ -1,49 +1,156 @@
|
|||||||
# portal-content
|
# questions
|
||||||
|
|
||||||
Question/alternative/feature content for the [`portal`](../portal) app,
|
Content for the [`portal`](https://project.uhhm.no/uhhm/portal) runtime
|
||||||
kept in its own repo so content edits don't require a Rust rebuild or
|
at [uhhm.no](https://uhhm.no) — every page, form, review desk, and
|
||||||
touch app code at all.
|
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
|
Portal loads every `questions/*.yaml` file (one page each) plus
|
||||||
`src/content.rs` for the exact struct). A question has one or more
|
`aggregates.yaml` (the state graphs) from this repo over Gitea's
|
||||||
`alternatives`; each alternative is a small form made of `features`,
|
contents API — at boot, and again on every push here: this repo's CI
|
||||||
each holding zero or more `requirements` (input fields).
|
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
|
What the runtime provides underneath:
|
||||||
id: /some-path # matches a route; "/" is the landing page; English slugs
|
|
||||||
name: Headline
|
- **Portal** (Rust/Leptos) — renders pages, takes submissions, enforces
|
||||||
description: >
|
the state graphs.
|
||||||
Markdown body text.
|
- **NATS** — every submission and decision publishes to
|
||||||
alternatives:
|
`portal.answers.submitted`; JetStream holds the durable per-record
|
||||||
- name: Alternative label
|
event log and the KV buckets pages read.
|
||||||
description: One line explaining this path.
|
- **Gitea** — hosts this repo, serves the content API, runs the lint
|
||||||
action: /next-question # id of the question to advance to on submit
|
CI, and gates the proposal loop's merges (branch protection on
|
||||||
consequence: [Button label]
|
`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:
|
features:
|
||||||
- name: A feature/section heading
|
- name: Who are you?
|
||||||
description: Optional supporting text.
|
|
||||||
requirements:
|
requirements:
|
||||||
- name: email
|
- name: email
|
||||||
label: Email
|
label: Email
|
||||||
type: 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`,
|
Requirement `type`: `text` (default), `textarea`, `email`, `tel`,
|
||||||
`select`. Omit for plain text. Mark a requirement `optional: true` if
|
`select`, `file`, `prosekit` (rich text), or any HTML input type.
|
||||||
it isn't required.
|
|
||||||
|
|
||||||
There is deliberately no `color`/`icon` styling here — the app has a
|
`self_transition` is the anonymous, single-record counterpart to a
|
||||||
single brand accent variable, not per-alternative colors. And there's
|
review resource's transitions — fireable by whoever holds one specific
|
||||||
no branching/criteria language yet: today a human reads submissions off
|
record's `?chain=` link plus its matching email (the unsubscribe
|
||||||
NATS and decides what happens next. If that grows into something an LLM
|
pattern).
|
||||||
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.
|
|
||||||
|
|
||||||
## Adding a question
|
## Adding a page
|
||||||
|
|
||||||
Drop a new `questions/<id>.yaml` file and reference its `id` from an
|
Drop a `questions/<name>.yaml` with a unique `id`, reference that `id`
|
||||||
existing alternative's `action`. No registration step - the app loads
|
from some alternative's `action`, push. Lint runs, portal hot-reloads,
|
||||||
every file in the directory at startup.
|
the page is live — no registration, no deploy. Or propose it through
|
||||||
|
`/develop-proposal` and let the loop do the pushing.
|
||||||
|
|||||||
Reference in New Issue
Block a user